diff --git a/internal/tui/app.go b/internal/tui/app.go index 1b564a2..f0728ce 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -72,6 +72,7 @@ func scanSessionsCmd(claudeDir string) tea.Cmd { type liveCaptureMsg struct { content string failed bool + sendErr error // keys sent before the capture failed to reach the pane } // Conversation right-pane detail levels. @@ -508,7 +509,9 @@ type App struct { liveInputPane tmux.Pane liveInputModal inputModal liveInputProjDir string // project path for $EDITOR cwd - liveInputSeq int // generation of the latest openLiveInput lookup + liveInputSeq int // generation of the latest openLiveInput/bulkInput lookup + paneJumpSeq int // generation of the latest pane jump lookup + livePreviewSeq int // generation of the latest openLivePreview lookup // Pane proxy: unified live preview + shell-in-preview paneProxy *paneProxyState @@ -1275,9 +1278,29 @@ func (a *App) update(msg tea.Msg) (tea.Model, tea.Cmd) { case liveInputPaneMsg: return a, a.handleLiveInputPane(msg) + case paneJumpFoundMsg: + return a, a.handlePaneJumpFound(msg) + + case paneJumpDoneMsg: + if msg.err != nil { + a.copiedMsg = "Switch failed: " + msg.err.Error() + } + return a, nil + + case livePreviewPaneMsg: + return a, a.handleLivePreviewPane(msg) + + case bulkInputPanesMsg: + a.handleBulkInputPanes(msg) + return a, nil + + case shareRefSentMsg: + a.handleShareRefSent(msg) + return a, nil + case liveInputSentMsg: if msg.err != nil { - a.copiedMsg = "Send failed" + a.copiedMsg = "Send failed: " + msg.err.Error() } else { a.copiedMsg = "Sent!" } @@ -1342,6 +1365,9 @@ func (a *App) update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, nil case liveCaptureMsg: + if msg.sendErr != nil { + a.copiedMsg = "Send failed: " + msg.sendErr.Error() + } if a.paneProxy == nil || a.sessPreviewMode != sessPreviewLive { return a, nil } @@ -1819,7 +1845,7 @@ func (a *App) View() string { c := spinnerColors[a.spinnerFrame/len(spinnerFrames)%len(spinnerColors)] s := lipgloss.NewStyle().Foreground(c).Bold(true) content = "\n " + s.Render(fmt.Sprintf("%s Scanning sessions…", frame)) - help = formatHelp("loading… q:quit") + help = a.footer(" " + helpStyle.Render("loading…")) break } if len(a.sessions) == 0 { @@ -1828,7 +1854,7 @@ func (a *App) View() string { dir = "~/.claude/projects/" } content = "\n " + dimStyle.Render(fmt.Sprintf("No sessions found in %s", dir)) - help = formatHelp("q:quit") + help = a.footer("") break } content = a.renderSessionSplit() @@ -1848,13 +1874,13 @@ func (a *App) View() string { c := spinnerColors[a.spinnerFrame/len(spinnerFrames)%len(spinnerColors)] s := lipgloss.NewStyle().Foreground(c).Bold(true) content = "\n " + s.Render(fmt.Sprintf("%s Scanning %d sessions…", frame, len(a.sessions))) - help = formatHelp("loading… v:views q:quit") + help = a.footer(" "+helpStyle.Render("loading…"), a.bind("session", "views")) } else if a.statsDetail != statsDetailNone { content = a.statsDetailVP.View() - help = formatHelp("p:page ↑↓:scroll esc:back " + a.helpSuffix()) + help = a.footer("", a.bind("session", "page_menu"), fh("↑↓", "scroll"), a.bind("session", "escape")) } else { content = a.globalStatsVP.View() - help = formatHelp("p:page ↑↓:scroll " + a.keymap.Session.Views + ":views " + a.helpSuffix()) + help = a.footer("", a.bind("session", "page_menu"), fh("↑↓", "scroll"), a.bind("session", "views")) } case viewConversation: @@ -1928,7 +1954,7 @@ func (a *App) View() string { if a.convActionsMenu && a.state == viewConversation { hintBox := a.renderConvActionsHintBox() content = overlayCenteredModal(content, hintBox, a.width, ContentHeight(a.height), modalOptions{paddingX: 2, paddingY: 1, maxWidth: max(a.width-8, 28), maxHeight: max(ContentHeight(a.height)-4, 8)}) - help = formatHelp(fmtKey(a.keymap.Conversation.Actions, "actions") + " — pick an action") + help = menuFooter(a.bind("conversation", "actions"), "pick an action") } // Actions menu hint box floating above help line @@ -1936,22 +1962,22 @@ func (a *App) View() string { hintBox := a.renderActionsHintBox() content = overlayCenteredModal(content, hintBox, a.width, ContentHeight(a.height), modalOptions{paddingX: 2, paddingY: 1, maxWidth: max(a.width-8, 28), maxHeight: max(ContentHeight(a.height)-4, 8)}) if a.outputsPreviewActionsActive() { - help = formatHelp("x:actions — pick an action for this output") + help = menuFooter(a.bind("session", "actions"), "pick an action for this output") } else { - help = formatHelp("x:actions — pick an action") + help = menuFooter(a.bind("session", "actions"), "pick an action") } } if a.sessPageMenu && a.state == viewSessions { hintBox := a.renderSessPageHintBox() content = overlayCenteredModal(content, hintBox, a.width, ContentHeight(a.height), modalOptions{paddingX: 2, paddingY: 1, maxWidth: max(a.width-8, 20), maxHeight: max(ContentHeight(a.height)-4, 8)}) - help = formatHelp("p:page — pick a preview") + help = menuFooter(a.bind("session", "page_menu"), "pick a preview") } if a.stateMenu && a.state == viewSessions { hintBox := a.renderStateHintBox() content = overlayCenteredModal(content, hintBox, a.width, ContentHeight(a.height), modalOptions{paddingX: 2, paddingY: 1, maxWidth: max(a.width-8, 20), maxHeight: max(ContentHeight(a.height)-4, 8)}) - help = formatHelp("S:states — toggle which session states show") + help = menuFooter(a.bind("session", "state_menu"), "toggle which session states show") } // Tag menu centered modal @@ -1974,42 +2000,42 @@ func (a *App) View() string { if a.cfgActionsMenu && a.state == viewConfig { hintBox := a.renderCfgActionsHintBox() content = overlayCenteredModal(content, hintBox, a.width, ContentHeight(a.height), modalOptions{paddingX: 2, paddingY: 1, maxWidth: max(a.width-8, 28), maxHeight: max(ContentHeight(a.height)-4, 8)}) - help = formatHelp("x:actions — pick an action") + help = menuFooter(a.bind("session", "actions"), "pick an action") } // Plugin actions menu hint box if a.plgActionsMenu && a.state == viewConfig && a.cfgPluginsPage { hintBox := a.renderPlgActionsHintBox() content = overlayCenteredModal(content, hintBox, a.width, ContentHeight(a.height), modalOptions{paddingX: 2, paddingY: 1, maxWidth: max(a.width-8, 28), maxHeight: max(ContentHeight(a.height)-4, 8)}) - help = formatHelp("x:actions — pick an action") + help = menuFooter(a.bind("session", "actions"), "pick an action") } // Plugin detail actions menu hint box if a.plgCompActionsMenu && a.state == viewConfig && a.cfgPluginsPage && a.plgDetailActive { hintBox := a.renderPlgCompActionsHintBox() content = overlayCenteredModal(content, hintBox, a.width, ContentHeight(a.height), modalOptions{paddingX: 2, paddingY: 1, maxWidth: max(a.width-8, 28), maxHeight: max(ContentHeight(a.height)-4, 8)}) - help = formatHelp("x:actions — pick an action") + help = menuFooter(a.bind("session", "actions"), "pick an action") } // Views menu centered modal if a.viewsMenu { hintBox := a.renderViewsHintBox() content = overlayCenteredModal(content, hintBox, a.width, ContentHeight(a.height), modalOptions{paddingX: 2, paddingY: 1, maxWidth: max(a.width-8, 20), maxHeight: max(ContentHeight(a.height)-4, 8)}) - help = formatHelp("v:views — pick a view") + help = menuFooter(a.bind("session", "views"), "pick a view") } // Edit menu centered modal if a.editMenu { hintBox := a.renderEditHintBox() content = overlayCenteredModal(content, hintBox, a.width, ContentHeight(a.height), modalOptions{paddingX: 2, paddingY: 1, maxWidth: max(a.width-8, 20), maxHeight: max(ContentHeight(a.height)-4, 8)}) - help = formatHelp("e:edit — pick a file") + help = menuFooter(a.bind("actions", "edit"), "pick a file") } // Stats page jump centered modal if a.statsPageMenu && a.state == viewGlobalStats { hintBox := a.renderStatsPageHintBox() content = overlayCenteredModal(content, hintBox, a.width, ContentHeight(a.height), modalOptions{paddingX: 2, paddingY: 1, maxWidth: max(a.width-8, 20), maxHeight: max(ContentHeight(a.height)-4, 8)}) - help = formatHelp("p:page — pick a page") + help = menuFooter(a.bind("session", "page_menu"), "pick a page") } // Conversation inspector facet picker. @@ -2204,10 +2230,8 @@ func (a *App) handleSessionKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return a, tea.Batch(capturePaneCmd(a.paneProxy.pane), liveTickCmd()) case "ctrl+g": // Jump to the actual tmux pane - if err := tmux.SwitchToPane(a.paneProxy.pane); err != nil { - a.copiedMsg = "Switch failed" - } - return a, nil + pane := a.paneProxy.pane + return a, func() tea.Msg { return paneJumpDoneMsg{err: switchToPane(pane)} } case "ctrl+n": // Send backslash + enter for multi-line input in Claude return a, a.liveNewlineCmd() @@ -2220,7 +2244,7 @@ func (a *App) handleSessionKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // View-specific keys. Resolved here, past the text-input and pane-proxy // branches, so a pre-rebind key stays literal when typed into an input or // forwarded to tmux. - key = a.keymap.resolveLegacyKey("session", key) + key = a.keymap.resolveSplitKey(a.keymap.resolveLegacyKey("session", key)) km := a.keymap if a.config.PickMode && key == km.Session.Pick { var items []session.Session @@ -2710,15 +2734,14 @@ func (a *App) handlePaneProxyKey(key string) (tea.Model, tea.Cmd) { func (a *App) liveNewlineCmd() tea.Cmd { pane := a.paneProxy.pane return func() tea.Msg { - target := pane.Session + ":" + pane.Window + "." + pane.Pane - exec.Command("tmux", "send-keys", "-l", "-t", target, "\\").Run() - exec.Command("tmux", "send-keys", "-t", target, "Enter").Run() + // Literal "\\" then Enter: Claude's line-continuation. + sendErr := sendKeys(pane, "\\") time.Sleep(30 * time.Millisecond) content, err := tmux.CapturePane(pane) if err != nil || !tmux.HasClaude(pane.PID) { - return liveCaptureMsg{failed: true} + return liveCaptureMsg{failed: true, sendErr: sendErr} } - return liveCaptureMsg{content: content} + return liveCaptureMsg{content: content, sendErr: sendErr} } } @@ -3280,9 +3303,9 @@ func (a *App) handleGlobalStatsKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // In detail view: tab cycles, esc goes back if a.statsDetail != statsDetailNone { switch key { - case "q": + case a.keymap.Session.Quit: return a.quit() - case "esc": + case a.keymap.Session.Escape: a.statsDetail = statsDetailNone return a, nil case a.keymap.Session.Views: @@ -3294,7 +3317,7 @@ func (a *App) handleGlobalStatsKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return a.openStatsDetail(a.statsDetail.next()) case "shift+tab": return a.openStatsDetail(a.statsDetail.prev()) - case "p": + case a.keymap.Session.PageMenu: a.statsPageMenu = true return a, nil } @@ -3304,16 +3327,16 @@ func (a *App) handleGlobalStatsKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } switch key { - case "q": + case a.keymap.Session.Quit: return a.quit() - case "esc": + case a.keymap.Session.Escape: return a, nil case a.keymap.Session.Views: a.viewsMenu = true return a, nil case a.keymap.Session.Refresh: return a.openGlobalStats() - case "p": + case a.keymap.Session.PageMenu: a.statsPageMenu = true return a, nil case "tab": @@ -3523,22 +3546,6 @@ func (a *App) openStatsDetail(mode statsDetailMode) (tea.Model, tea.Cmd) { // --- Live Preview (tmux capture in split pane) --- -func (a *App) openLivePreview(sess session.Session) (tea.Model, tea.Cmd) { - if !sess.IsLive { - a.copiedMsg = "not a live session" - return a, nil - } - pane, found := tmux.FindPane(sess.ProjectPath, sess.ID) - if !found { - a.copiedMsg = "tmux pane not found" - return a, nil - } - a.paneProxy = &paneProxyState{pane: pane, sessID: sess.ID} - a.toggleSessionPreviewMode(sessPreviewLive) - a.refreshLivePreview() - return a, liveTickCmd() -} - func (a *App) refreshLivePreview() { if a.paneProxy == nil { a.sessSplit.Preview.SetContent(dimStyle.Render("(no pane)")) @@ -3679,23 +3686,10 @@ func (a *App) resumeSession(sess session.Session) (tea.Model, tea.Cmd) { } if sess.IsLive { - // Live session: jump to existing pane - pane, found := tmux.FindPane(sess.ProjectPath, sess.ID) - if found { - if err := tmux.SwitchToPane(pane); err != nil { - a.copiedMsg = "Switch failed" - } - return a, nil - } - // Fallback: take over CSB - cmd, err := a.claudeCmd(dir, "--resume", sess.ID) - if err != nil { - a.copiedMsg = "Claude command failed: " + err.Error() - return a, nil - } - return a, tea.ExecProcess(cmd, func(err error) tea.Msg { - return tea.QuitMsg{} - }) + // Live session: jump to its pane; without one, resume in this + // terminal (handlePaneJumpFound). + resume := sess + return a, a.paneJumpCmd(&resume, sess.ProjectPath, sess.ID) } // Non-live session in tmux: spawn a new tmux window, reusing the window @@ -4337,34 +4331,6 @@ func (a *App) bulkResume(selected []session.Session) (tea.Model, tea.Cmd) { return a, nil } -func (a *App) bulkInput(selected []session.Session) (tea.Model, tea.Cmd) { - if !tmux.InTmux() { - a.copiedMsg = "Requires tmux" - return a, nil - } - var panes []tmux.Pane - for _, s := range selected { - if !s.IsLive { - continue - } - pane, found := tmux.FindPane(s.ProjectPath, s.ID) - if !found || !tmux.HasClaude(pane.PID) { - continue - } - panes = append(panes, pane) - } - if len(panes) == 0 { - a.copiedMsg = "No live Claude panes" - return a, nil - } - a.liveInputPanes = panes - a.liveInputModal = newInputModal() - a.liveInputModal.title = fmt.Sprintf("Send to %d panes", len(panes)) - a.liveInputActive = true - a.liveInputProjDir = selected[0].ProjectPath - return a, nil -} - func (a *App) resolveImagePath(pasteID int) string { home, _ := os.UserHomeDir() p, err := session.ExtractImageToTemp(home, a.currentSess.FilePath, a.currentSess.ID, pasteID) @@ -4727,19 +4693,6 @@ func (a *App) executeCmdNewSession(input string) (tea.Model, tea.Cmd) { }) } -func (a *App) jumpToTmuxPane(projectPath string, sessionID ...string) (tea.Model, tea.Cmd) { - pane, found := tmux.FindPane(projectPath, sessionID...) - if !found { - a.copiedMsg = "No tmux pane found" - return a, nil - } - if err := tmux.SwitchToPane(pane); err != nil { - a.copiedMsg = "Switch failed" - return a, nil - } - return a, nil -} - // liveInputSentMsg is sent after async tmuxSendKeys completes. type liveInputSentMsg struct{ err error } @@ -4760,20 +4713,11 @@ func (a *App) handleLiveInputKey(key string) (tea.Model, tea.Cmd) { if len(a.liveInputPanes) > 0 { panes := a.liveInputPanes a.liveInputPanes = nil - return a, func() tea.Msg { - var lastErr error - for _, p := range panes { - if err := tmux.SendKeys(p, text); err != nil { - lastErr = err - } - } - return liveInputSentMsg{err: lastErr} - } + return a, func() tea.Msg { return liveInputSentMsg{err: sendToPanes(panes, text)} } } pane := a.liveInputPane return a, func() tea.Msg { - err := tmux.SendKeys(pane, text) - return liveInputSentMsg{err: err} + return liveInputSentMsg{err: sendKeys(pane, text)} } case "editor": // Remember editor preference for next time @@ -4807,15 +4751,9 @@ func (a *App) handleLiveInputKey(key string) (tea.Model, tea.Cmd) { } sendText := strings.TrimRight(string(content), "\n") if len(panes) > 0 { - var lastErr error - for _, p := range panes { - if err := tmux.SendKeys(p, sendText); err != nil { - lastErr = err - } - } - return liveInputSentMsg{err: lastErr} + return liveInputSentMsg{err: sendToPanes(panes, sendText)} } - return liveInputSentMsg{err: tmux.SendKeys(pane, sendText)} + return liveInputSentMsg{err: sendKeys(pane, sendText)} } case "cancel": a.liveInputActive = false @@ -5487,7 +5425,7 @@ func (a *App) updateSessionPreviewWith(dispatch bool) tea.Cmd { projectPath := sess.ProjectPath sessID := sess.ID return func() tea.Msg { - pane, found := tmux.FindPane(projectPath, sessID) + pane, found := findPane(projectPath, sessID) return liveFindMsg{pane: pane, found: found, sessID: sessID} } } @@ -7869,52 +7807,39 @@ func (a *App) truncateFooter(h string) string { return out } -// renderActionsHintBox renders a compact bordered hint box for the actions menu. +// renderActionsHintBox renders a compact bordered hint box for the actions +// menu. Keys and labels come from the binding table (actions scope). func (a *App) renderActionsHintBox() string { - hl := lipgloss.NewStyle().Foreground(colorAccent).Bold(true) - d := dimStyle - sp := " " - akm := a.keymap.Actions - - var lines []string if a.outputsPreviewActionsActive() { return a.renderOutputActionsHintBox() } if a.hasMultiSelection() && !a.sessionPreviewActionsActive() { - header := fmt.Sprintf("%d selected", len(a.selectedSet)) - lines = append(lines, lipgloss.NewStyle().Bold(true).Foreground(colorPrimary).Render(header)) - lines = append(lines, hl.Render(displayKey(akm.Delete))+d.Render(":delete")+sp+hl.Render(displayKey(akm.Resume))+d.Render(":resume")+sp+hl.Render(displayKey(akm.Copy))+d.Render(":copy")+sp+hl.Render(displayKey(akm.Kill))+d.Render(":kill")+sp+hl.Render(displayKey(akm.Input))+d.Render(":input")) - lines = append(lines, hl.Render(displayKey(akm.URLs))+d.Render(":urls")+sp+hl.Render(displayKey(akm.Files))+d.Render(":files")+sp+hl.Render(displayKey(akm.Changes))+d.Render(":changes")+sp+hl.Render(displayKey(akm.Tags))+d.Render(":tags")) + header := lipgloss.NewStyle().Bold(true).Foreground(colorPrimary).Render(fmt.Sprintf("%d selected", len(a.selectedSet))) + return renderInteractionHintBoxTitled(header, [][]interactionAction{ + a.actionLine("delete", "resume", "copy", "kill", "input"), + a.actionLine("urls", "files", "changes", "tags"), + }, "esc:cancel") + } + sess := a.actionsSess + var lines [][]interactionAction + if a.sessionPreviewActionsActive() { + lines = append(lines, []interactionAction{labelAction("", "c", "contexts"), labelAction("", "f", "full"), labelAction("", "y", "copy")}) } else { - sess := a.actionsSess - if a.sessionPreviewActionsActive() { - lines = append(lines, hl.Render("c")+d.Render(":contexts")+sp+hl.Render("f")+d.Render(":full")+sp+hl.Render("y")+d.Render(":copy")) - } else { - lines = append(lines, hl.Render(displayKey(akm.Delete))+d.Render(":delete")+sp+hl.Render(displayKey(akm.Move))+d.Render(":move")+sp+hl.Render(displayKey(akm.Resume))+d.Render(":resume")+sp+hl.Render(displayKey(akm.Copy))+d.Render(":copy")+sp+hl.Render(displayKey(akm.CopyPath))+d.Render(":copy-path")) - } - line2 := hl.Render(displayKey(akm.Edit)) + d.Render(":edit") + sp + hl.Render(displayKey(akm.Worktree)) + d.Render(":worktree") + sp + hl.Render(displayKey(akm.URLs)) + d.Render(":urls") + sp + hl.Render(displayKey(akm.Files)) + d.Render(":files") + sp + hl.Render(displayKey(akm.Changes)) + d.Render(":changes") + sp + hl.Render(displayKey(akm.Tags)) + d.Render(":tags") - if sess.HasMemory { - line2 += sp + hl.Render(displayKey(akm.RemoveMem)) + d.Render(":rm-mem") - } - if sess.IsWorktree { - line2 += sp + hl.Render(displayKey(akm.ImportMem)) + d.Render(":import-mem") - } - line2 += sp + hl.Render(displayKey(akm.Fork)) + d.Render(":fork") - line2 += sp + hl.Render(displayKey(akm.New)) + d.Render(":new") - line2 += sp + hl.Render(displayKey(akm.Remote)) + d.Render(":remote") - lines = append(lines, line2) - if sess.IsLive && a.config.TmuxEnabled { - lines = append(lines, hl.Render(displayKey(akm.Kill))+d.Render(":kill")+sp+hl.Render(displayKey(akm.Input))+d.Render(":input")+sp+hl.Render(displayKey(akm.Jump))+d.Render(":jump")) - } + lines = append(lines, a.actionLine("delete", "move", "resume", "copy", "copy_path")) } - lines = append(lines, d.Render("esc:cancel")) - - body := strings.Join(lines, "\n") - boxStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(colorDim). - Padding(0, 1) - return boxStyle.Render(body) + second := []string{"edit", "worktree", "urls", "files", "changes", "tags"} + if sess.HasMemory { + second = append(second, "remove_mem") + } + if sess.IsWorktree { + second = append(second, "import_mem") + } + second = append(second, "fork", "new", "remote") + lines = append(lines, a.actionLine(second...)) + if sess.IsLive && a.config.TmuxEnabled { + lines = append(lines, a.actionLine("kill", "input", "jump")) + } + return renderInteractionHintBox(lines, "esc:cancel") } // renderSearchHintBox renders a compact bordered hint box for search filters. diff --git a/internal/tui/bindings.go b/internal/tui/bindings.go new file mode 100644 index 0000000..355a716 --- /dev/null +++ b/internal/tui/bindings.go @@ -0,0 +1,344 @@ +package tui + +import ( + "fmt" + "reflect" + "sort" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// One binding table drives dispatch, footers, action hint boxes and the "?" +// overlay. Keys live in Keymap (defaults + ~/.config/ccx keymap yaml); this +// table adds what each binding is called in a footer (label) and in the help +// overlay (help). Footers and help look entries up by scope + yaml name, so a +// user override changes every surface at once and a binding without a table +// entry fails TestBindingTableCoversKeymap instead of silently showing a stale +// hand-written key. + +// bindingSpec describes one Keymap field. +type bindingSpec struct { + scope string // "session", "actions", "views", "conversation", "preview" + name string // yaml tag of the Keymap field + label string // short footer / hint-box word + help string // "?" overlay description +} + +var bindingTable = []bindingSpec{ + {"session", "quit", "quit", "Quit"}, + {"session", "escape", "back", "Back / close"}, + {"session", "open", "open", "Open / expand"}, + {"session", "edit", "edit", "Edit session files"}, + {"session", "actions", "actions", "Actions menu"}, + {"session", "views", "views", "Switch views (stats/config/plugins)"}, + {"session", "refresh", "refresh", "Refresh"}, + {"session", "group", "group", "Cycle grouping"}, + {"session", "help", "help", "This help"}, + {"session", "search", "search", "Search / filter"}, + {"session", "global_search", "global", "Global search"}, + {"session", "live", "live", "Live preview"}, + {"session", "switch", "switch", "Jump to the session's tmux window"}, + {"session", "select", "select", "Multi-select"}, + {"session", "preview", "mode", "Preview / cycle mode"}, + {"session", "preview_back", "prev-mode", "Previous preview mode"}, + {"session", "left", "close", "Unfocus / close preview"}, + {"session", "right", "focus", "Open / focus preview"}, + {"session", "resize_shrink", "shrink", "Shrink list"}, + {"session", "resize_grow", "grow", "Grow list"}, + {"session", "command", "cmd", "Command palette (full actions)"}, + {"session", "pick", "pick", "Pick (pick mode)"}, + {"session", "fold_all", "fold", "Fold all groups"}, + {"session", "expand_all", "expand", "Expand all groups"}, + {"session", "fold_group", "fold-group", "Fold group at cursor"}, + {"session", "state_menu", "states", "Toggle session states shown (live/done/…)"}, + {"session", "daily_view", "daily", "Daily view (dates + what each day produced)"}, + {"session", "page_menu", "page", "Page menu"}, + + {"actions", "delete", "delete", "Delete session"}, + {"actions", "move", "move", "Move to another project"}, + {"actions", "resume", "resume", "Resume"}, + {"actions", "copy_path", "copy-path", "Copy path"}, + {"actions", "worktree", "worktree", "Move into a git worktree"}, + {"actions", "kill", "kill", "Stop the live Claude"}, + {"actions", "input", "input", "Send input to the live pane"}, + {"actions", "jump", "jump", "Jump to the live tmux pane"}, + {"actions", "urls", "urls", "URLs"}, + {"actions", "files", "files", "Files"}, + {"actions", "changes", "changes", "Changes"}, + {"actions", "copy", "copy", "Copy"}, + {"actions", "tags", "tags", "Tags"}, + {"actions", "import_mem", "import-mem", "Import memory from the main checkout"}, + {"actions", "remove_mem", "rm-mem", "Remove project memory"}, + {"actions", "fork", "fork", "Fork session"}, + {"actions", "new", "new", "New session here"}, + {"actions", "remote", "remote", "Run remotely"}, + {"actions", "edit", "edit", "Edit session files"}, + + {"views", "stats", "stats", "Global stats"}, + {"views", "config", "config", "Config explorer"}, + {"views", "plugins", "plugins", "Plugins"}, + + {"conversation", "jump_to_tree", "jump", "Jump to origin turn"}, + {"conversation", "switch_region", "region", "Switch region"}, + {"conversation", "execution_contexts", "contexts", "Execution rail"}, + {"conversation", "region_up", "region↑", "Region up"}, + {"conversation", "region_down", "region↓", "Region down"}, + {"conversation", "live_toggle", "live", "Toggle live tail"}, + {"conversation", "edit", "edit", "Edit"}, + {"conversation", "actions", "actions", "Actions"}, + {"conversation", "input", "input", "Live input (tmux)"}, + + {"preview", "fold_all", "fold", "Fold all blocks"}, + {"preview", "expand_all", "expand", "Expand all blocks"}, + {"preview", "filter", "filter", "Filter blocks"}, + {"preview", "copy_mode", "copy", "Copy mode"}, + {"preview", "copy_all", "copy-all", "Copy all"}, +} + +func lookupBinding(scope, name string) (bindingSpec, bool) { + for _, b := range bindingTable { + if b.scope == scope && b.name == name { + return b, true + } + } + return bindingSpec{}, false +} + +// scopeStruct returns the Keymap section for scope, addressable so the +// returned field pointers alias km. +func (km *Keymap) scopeStruct(scope string) (reflect.Value, bool) { + var p any + switch scope { + case "session": + p = &km.Session + case "actions": + p = &km.Actions + case "views": + p = &km.Views + case "conversation": + p = &km.Conversation + case "preview": + p = &km.Preview + default: + return reflect.Value{}, false + } + return reflect.ValueOf(p).Elem(), true +} + +// bindingKey returns the key currently bound to scope/name ("" when unbound +// or unknown). +func (km *Keymap) bindingKey(scope, name string) string { + v, ok := km.scopeStruct(scope) + if !ok { + return "" + } + t := v.Type() + for i := 0; i < t.NumField(); i++ { + if t.Field(i).Tag.Get("yaml") == name && t.Field(i).Type.Kind() == reflect.String { + return v.Field(i).String() + } + } + return "" +} + +// Conflicts reports keys bound to more than one action in the same scope. +// Dispatch is a switch over these keys, so the second binding would be dead. +func (km *Keymap) Conflicts() []string { + seen := map[string]string{} // scope+key -> first name + var out []string + for _, b := range bindingTable { + k := km.bindingKey(b.scope, b.name) + if k == "" { + continue + } + id := b.scope + "\x00" + k + if prev, dup := seen[id]; dup { + out = append(out, fmt.Sprintf("%s: %q bound to both %s and %s", b.scope, k, prev, b.name)) + continue + } + seen[id] = b.name + } + sort.Strings(out) + return out +} + +// resolveSplitKey maps a user-configured Left/Right/ResizeShrink/ResizeGrow +// key onto the canonical key the split pane dispatches on, so an override in +// the keymap yaml actually moves the binding the footer advertises. +func (km *Keymap) resolveSplitKey(key string) string { + for _, m := range []struct{ configured, canonical string }{ + {km.Session.Left, "left"}, + {km.Session.Right, "right"}, + {km.Session.ResizeShrink, "["}, + {km.Session.ResizeGrow, "]"}, + } { + if m.configured != "" && m.configured != m.canonical && key == m.configured { + return m.canonical + } + } + return key +} + +// footerHint is one "key:label" token of a footer. +type footerHint struct { + key, label string + pinned bool // never dropped when the footer is too narrow +} + +// keep marks h as pinned (e.g. the esc that leaves the current view). +func (h footerHint) keep() footerHint { h.pinned = true; return h } + +// fh is a hint for a structural key that is not configurable (arrows, tab, +// digits) — those are handled by the split pane / list widgets directly. +func fh(key, label string) footerHint { return footerHint{key: key, label: label} } + +// bind returns the footer hint for a table binding, using its table label. +// An unbound action yields an empty hint that renderers skip. +func (a *App) bind(scope, name string) footerHint { + spec, _ := lookupBinding(scope, name) + return a.bindAs(scope, name, spec.label) +} + +// bindAs is bind with a context-specific label ("open" reads "jump" in some +// panes) — the key still comes from the table. +func (a *App) bindAs(scope, name, label string) footerHint { + k := a.keymap.bindingKey(scope, name) + if k == "" { + return footerHint{} + } + return footerHint{key: displayKey(k), label: label} +} + +// hintsFromActions adapts the conversation interactionAction lists. +func hintsFromActions(actions ...interactionAction) []footerHint { + out := make([]footerHint, 0, len(actions)) + for _, act := range actions { + if act.Enabled && act.KeyLabel != "" { + out = append(out, footerHint{key: act.KeyLabel, label: act.Label}) + } + } + return out +} + +func renderHint(h footerHint) string { + if h.label == "" { + return helpKeyStyle.Render(h.key) + } + return helpKeyStyle.Render(h.key) + helpStyle.Render(":"+h.label) +} + +// footer renders hints after prefix (already-rendered badges/prompt), then +// the help and quit hints. Hints are given in priority order: when the line +// does not fit a.width, unpinned hints are dropped from the end (display +// order is kept) and "…" marks the cut, so help/quit and pinned hints stay +// visible. +func (a *App) footer(prefix string, hints ...footerHint) string { + var live []footerHint + for _, h := range hints { + if h.key != "" { + live = append(live, h) + } + } + live = append(live, a.bind("session", "help").keep(), a.bind("session", "quit").keep()) + return renderFooter(prefix, live, a.width) +} + +func renderFooter(prefix string, hints []footerHint, width int) string { + render := func(hs []footerHint, cut bool) string { + parts := make([]string, 0, len(hs)+1) + for _, h := range hs { + parts = append(parts, renderHint(h)) + } + if cut { + // The ellipsis goes where hints were dropped: before the + // trailing run of pinned hints. + n := len(hs) + for n > 0 && hs[n-1].pinned { + n-- + } + parts = append(parts[:n], append([]string{helpStyle.Render("…")}, parts[n:]...)...) + } + return prefix + " " + strings.Join(parts, " ") + } + out := render(hints, false) + if width <= 0 || lipgloss.Width(out) <= width { + return out + } + kept := append([]footerHint(nil), hints...) + for { + drop := -1 + for i := len(kept) - 1; i >= 0; i-- { + if !kept[i].pinned { + drop = i + break + } + } + if drop < 0 { + return out // only pinned hints left; truncateFooter clips the rest + } + kept = append(kept[:drop], kept[drop+1:]...) + out = render(kept, true) + if lipgloss.Width(out) <= width { + return out + } + } +} + +// helpRowFor is a "?" overlay row for a table binding. +func (a *App) helpRowFor(scope, name string) helpRow { + spec, _ := lookupBinding(scope, name) + return helpRow{displayKey(a.keymap.bindingKey(scope, name)), spec.help} +} + +// actionLine builds a hint-box line from actions-scope names, using the +// table labels. +func (a *App) actionLine(names ...string) []interactionAction { + out := make([]interactionAction, 0, len(names)) + for _, n := range names { + spec, _ := lookupBinding("actions", n) + out = append(out, bindAction("", a.keymap.bindingKey("actions", n), spec.label)) + } + return out +} + +// menuFooter is the footer shown while a menu opened by h is up. +func menuFooter(h footerHint, desc string) string { + return " " + renderHint(h) + helpStyle.Render(" — "+desc) +} + +// sessionsListHelpRows is the "?" overlay for the session list: every +// session-scope binding that is bound, in table order, so the overlay cannot +// drift from dispatch. Globals live in helpCommonRows; a few rows carry extra +// context the bare table entry lacks. +func (a *App) sessionsListHelpRows() []helpRow { + km := a.keymap + common := map[string]bool{"command": true, "views": true, "search": true, "global_search": true, "escape": true, "help": true, "quit": true} + var rows []helpRow + for _, b := range bindingTable { + if b.scope != "session" || common[b.name] || km.bindingKey("session", b.name) == "" { + continue + } + switch b.name { + case "pick": + if !a.config.PickMode { + continue + } + case "actions": + r := a.helpRowFor("session", "actions") + r.desc += " (" + displayKey(km.Actions.Delete) + "/" + displayKey(km.Actions.Resume) + "/" + displayKey(km.Actions.CopyPath) + "/…)" + rows = append(rows, r) + continue + case "resize_grow": + continue // shown with resize_shrink + case "resize_shrink": + rows = append(rows, helpRow{displayKey(km.Session.ResizeShrink) + " / " + displayKey(km.Session.ResizeGrow), "Resize split"}) + continue + case "page_menu": + rows = append(rows, a.helpRowFor("session", "page_menu"), helpRow{displayKey(km.Session.PageMenu) + " → l", "Live preview (page menu → live)"}) + continue + } + rows = append(rows, a.helpRowFor("session", b.name)) + } + return rows +} diff --git a/internal/tui/bindings_test.go b/internal/tui/bindings_test.go new file mode 100644 index 0000000..c12568b --- /dev/null +++ b/internal/tui/bindings_test.go @@ -0,0 +1,231 @@ +package tui + +import ( + "reflect" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// Every string field of the keymap sections must have exactly one table +// entry, and every entry must name a real field — otherwise a binding has no +// footer label / help text, or a footer reads a key that does not exist. +func TestBindingTableCoversKeymap(t *testing.T) { + km := DefaultKeymap() + seen := map[string]bool{} + for _, b := range bindingTable { + id := b.scope + "." + b.name + if seen[id] { + t.Errorf("duplicate table entry %s", id) + } + seen[id] = true + if b.label == "" || b.help == "" { + t.Errorf("%s: empty label or help", id) + } + } + for _, scope := range []string{"session", "actions", "views", "conversation", "preview"} { + v, ok := km.scopeStruct(scope) + if !ok { + t.Fatalf("no keymap section for %s", scope) + } + fields := map[string]bool{} + for i := 0; i < v.NumField(); i++ { + f := v.Type().Field(i) + if f.Type.Kind() != reflect.String { + continue + } + name := f.Tag.Get("yaml") + fields[name] = true + if !seen[scope+"."+name] { + t.Errorf("keymap field %s.%s has no binding table entry", scope, name) + } + } + for _, b := range bindingTable { + if b.scope == scope && !fields[b.name] { + t.Errorf("table entry %s.%s names no keymap field", scope, b.name) + } + } + } +} + +func TestDefaultKeymapHasNoConflicts(t *testing.T) { + km := DefaultKeymap() + if c := km.Conflicts(); len(c) != 0 { + t.Fatalf("default keymap conflicts: %v", c) + } + km.Actions.Move = km.Actions.Delete + if c := km.Conflicts(); len(c) != 1 || !strings.Contains(c[0], "delete") || !strings.Contains(c[0], "move") { + t.Fatalf("Conflicts() = %v, want one actions conflict", c) + } + // Same key in different scopes is not a conflict (menus dispatch per scope). + km = DefaultKeymap() + km.Actions.Kill = km.Session.Quit + if c := km.Conflicts(); len(c) != 0 { + t.Fatalf("cross-scope reuse reported as conflict: %v", c) + } +} + +// globalIntercepts are keys Update consumes before view dispatch. +func globalIntercepts(km Keymap) map[string]bool { + out := map[string]bool{km.Session.Command: true, km.Session.Help: true} + for c := '0'; c <= '9'; c++ { + out[string(c)] = true + } + return out +} + +// Every binding the "?" overlay lists for the session list must reach its +// handler: bound, not shadowed by another binding in the scope, and not +// swallowed by a global intercept that runs before view dispatch. +func TestSessionsHelpActionsAreReachable(t *testing.T) { + a := newTestApp(fakeSessions()) + km := a.keymap + if c := km.Conflicts(); len(c) != 0 { + t.Fatalf("conflicts: %v", c) + } + intercept := globalIntercepts(km) + helpKeys := map[string]bool{} + for _, r := range append(a.sessionsListHelpRows(), a.helpCommonRows()...) { + if r.key == "" { + t.Errorf("help row %q has no key", r.desc) + } + helpKeys[r.key] = true + } + for _, b := range bindingTable { + if b.scope != "session" || b.name == "command" || b.name == "help" || b.name == "pick" { + continue + } + k := km.bindingKey("session", b.name) + if k == "" { + continue + } + if intercept[k] { + t.Errorf("session.%s = %q is swallowed by a global intercept", b.name, k) + } + if !helpKeys[displayKey(k)] && !strings.Contains(strings.Join(keysOf(helpKeys), " "), displayKey(k)) { + t.Errorf("session.%s (%q) is bound but missing from the ? overlay", b.name, k) + } + } +} + +func keysOf(m map[string]bool) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} + +// footerTokens returns the "key" part of each key:label token. +func footerTokens(s string) []string { + var out []string + for _, tok := range strings.Fields(stripANSI(s)) { + if i := strings.Index(tok, ":"); i > 0 { + out = append(out, tok[:i]) + } + } + return out +} + +// Whatever the session-list footer advertises, the ? overlay documents. +func TestSessionFooterKeysAppearInHelp(t *testing.T) { + a := newTestApp(fakeSessions()) + a.width = 400 + help := map[string]bool{} + for _, r := range append(a.sessionsListHelpRows(), a.helpCommonRows()...) { + for _, k := range strings.Split(r.key, " / ") { + help[strings.TrimSpace(k)] = true + } + } + for _, k := range footerTokens(a.sessHelpLine()) { + if !help[k] { + t.Errorf("footer key %q is not in the ? overlay", k) + } + } +} + +func TestFooterDropsLowPriorityHintsAndPinsHelpQuit(t *testing.T) { + a := newTestApp(fakeSessions()) + a.width = 400 + wide := stripANSI(a.sessHelpLine()) + if strings.Contains(wide, "…") { + t.Fatalf("wide footer should not be cut: %q", wide) + } + + a.width = 36 + narrow := a.sessHelpLine() + plain := stripANSI(narrow) + if w := lipgloss.Width(narrow); w > a.width { + t.Fatalf("footer width %d > %d: %q", w, a.width, plain) + } + if !strings.Contains(plain, "…") { + t.Fatalf("cut footer should show …: %q", plain) + } + if !strings.HasSuffix(plain, "?:help q:quit") { + t.Fatalf("help/quit not pinned at the end: %q", plain) + } + // Highest-priority hint survives. + if !strings.Contains(plain, "↵:open") { + t.Fatalf("first hint dropped before lower ones: %q", plain) + } +} + +func TestFooterKeepsPinnedHint(t *testing.T) { + out := stripANSI(renderFooter("", []footerHint{fh("a", "one"), fh("b", "two"), fh("esc", "back").keep(), fh("?", "help").keep()}, 22)) + if !strings.Contains(out, "esc:back") || strings.Contains(out, "b:two") { + t.Fatalf("pinned hint dropped or low hint kept: %q", out) + } +} + +// A keymap override moves the binding in dispatch, footer and help at once. +func TestKeymapOverrideReachesFooterHelpAndDispatch(t *testing.T) { + a := newTestApp(fakeSessions()) + a.width = 400 + a.keymap.Session.PageMenu = "m" + a.keymap.Session.Views = "w" + + a.state = viewGlobalStats + a.globalStatsLoading = false + if got := stripANSI(a.View()); !strings.Contains(got, "m:page") || !strings.Contains(got, "w:views") { + t.Fatalf("stats footer did not follow the override: %q", got[max(0, len(got)-200):]) + } + a.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'m'}}) + if !a.statsPageMenu { + t.Fatal("overridden page key did not open the stats page menu") + } + + var found bool + for _, r := range a.helpCommonRows() { + found = found || (r.key == "w" && strings.Contains(r.desc, "views")) + } + if !found { + t.Fatal("? overlay did not follow the views override") + } +} + +func TestResolveSplitKeyHonorsOverride(t *testing.T) { + km := DefaultKeymap() + km.Session.Left, km.Session.ResizeGrow = "H", "+" + if got := km.resolveSplitKey("H"); got != "left" { + t.Fatalf("resolveSplitKey(H) = %q, want left", got) + } + if got := km.resolveSplitKey("+"); got != "]" { + t.Fatalf("resolveSplitKey(+) = %q, want ]", got) + } + if got := km.resolveSplitKey("x"); got != "x" { + t.Fatalf("unrelated key rewritten: %q", got) + } +} + +// The state-menu footer used to say "S:states" while the key is "s". +func TestMenuFootersUseBoundKeys(t *testing.T) { + a := newTestApp(fakeSessions()) + a.stateMenu = true + got := stripANSI(a.View()) + want := displayKey(a.keymap.Session.StateMenu) + ":states" + if !strings.Contains(got, want) { + t.Fatalf("state menu footer missing %q", want) + } +} diff --git a/internal/tui/config.go b/internal/tui/config.go index 2e01fd9..b1c2ab9 100644 --- a/internal/tui/config.go +++ b/internal/tui/config.go @@ -335,7 +335,7 @@ func (a *App) handleConfigKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case a.keymap.Session.Views: a.viewsMenu = true return a, nil - case "x": + case a.keymap.Session.Actions: if a.cfgSkillBrowse { return a, nil } diff --git a/internal/tui/conversation.go b/internal/tui/conversation.go index ea55856..8f2a38e 100644 --- a/internal/tui/conversation.go +++ b/internal/tui/conversation.go @@ -364,7 +364,7 @@ func (a *App) handleConversationKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Past the text-input guards, so a pre-rebind key typed into the block // filter or memory search stays literal. - key = a.keymap.resolveLegacyKey("conversation", key) + key = a.keymap.resolveSplitKey(a.keymap.resolveLegacyKey("conversation", key)) // Uppercase K/J move focus between the vertically-stacked regions // (RESOURCES ↑ CONVERSATION ↕ EXECUTION CONTEXTS ↓). Handled before the diff --git a/internal/tui/help.go b/internal/tui/help.go index 25dbf08..9321e0e 100644 --- a/internal/tui/help.go +++ b/internal/tui/help.go @@ -2,34 +2,17 @@ package tui import ( "fmt" - "strings" ) -// fmtHints builds a help line from alternating key, desc pairs. -// Example: fmtHints("↵", "open", "e", "edit", "q", "quit") → "↵:open e:edit q:quit" -func fmtHints(pairs ...string) string { - var parts []string - for i := 0; i+1 < len(pairs); i += 2 { - parts = append(parts, displayKey(pairs[i])+":"+pairs[i+1]) - } - return formatHelp(strings.Join(parts, " ")) -} - -// helpSuffix is the shared trailing hint for navigable footers: the help key -// and quit. The full key list lives in the "?" overlay. -func (a *App) helpSuffix() string { - return fmtKey(a.keymap.Session.Help, "help") + " " + fmtKey(a.keymap.Session.Quit, "quit") -} - // --- Session view help --- func (a *App) sessHelpLine() string { // Loading if a.sessionsLoading && len(a.sessions) == 0 { - return formatHelp("loading… q:quit") + return a.footer(" " + helpStyle.Render("loading…")) } if len(a.sessions) == 0 { - return formatHelp("q:quit") + return a.footer("") } // Modal overlays @@ -46,7 +29,7 @@ func (a *App) sessHelpLine() string { return "" // Tag menu has its own help } if a.sessPageMenu { - return formatHelp("p:page — pick a preview") + return menuFooter(a.bind("session", "page_menu"), "pick a preview") } if a.moveMode { return " " + a.moveInput.View() + helpStyle.Render(" enter:move esc:cancel") @@ -70,43 +53,50 @@ func (a *App) sessHelpLine() string { return " " + a.paneProxyIndicator() + " " + formatHelp("keys→pane ^G:jump ^N:newline ^Q:unfocus") } if a.paneProxy != nil && a.sessPreviewMode == sessPreviewLive && !a.sessSplit.Focus { - return " " + a.paneProxyIndicator() + " " + formatHelp("→:focus esc:close []:resize") + return a.footer(" "+a.paneProxyIndicator(), a.bind("session", "right"), a.bindAs("session", "escape", "close"), a.resizeHint()) } // Normal session list/preview — concise; full list is in the "?" overlay. - sk := a.keymap.Session - var h string - if !a.sessSplit.Show { - h = fmtKey(sk.Open, "open") + " " + fmtKey(sk.Actions, "actions") + " →:preview p:page " + fmtKey(sk.Search, "search") - } else if a.sessSplit.Focus { + // Hints are in priority order; footer() drops from the end when narrow. + nav, unfocus, mode, page := fh("↑↓", "nav"), a.bindAs("session", "left", "unfocus"), a.bind("session", "preview"), a.bind("session", "page_menu") + var hints []footerHint + switch { + case !a.sessSplit.Show: + hints = []footerHint{a.bind("session", "open"), a.bind("session", "actions"), a.bindAs("session", "right", "preview"), page, a.bind("session", "search")} + case a.sessSplit.Focus: switch { case a.outputsPreviewActionsActive(): // Both digests: the row under the cursor is what the keys act on. - h = "↑↓:nav ↵:jump o:open " + fmtKey(sk.Actions, "actions") + hints = []footerHint{nav, a.bindAs("session", "open", "jump"), fh("o", "open"), a.bind("session", "actions")} if a.selectedOwnsDayPane() { // Only the day pane is tabbed by kind — the per-session digest // has one fixed list, so advertising it there would mislead. - h += " 1-9/tab:kind" + hints = append(hints, fh("1-9/tab", "kind")) } - h += " ←:unfocus p:page" + hints = append(hints, unfocus, page) case a.sessPreviewMode == sessPreviewConversation: - h = "↑↓:nav c:full " + fmtKey(sk.Open, "jump") + " ←:unfocus tab:mode" + hints = []footerHint{nav, fh("c", "full"), a.bindAs("session", "open", "jump"), unfocus, mode} case a.sessPreviewMode == sessPreviewAgents: - h = "↑↓:nav " + fmtKey(sk.Open, "jump") + " ←:unfocus tab:mode" + hints = []footerHint{nav, a.bindAs("session", "open", "jump"), unfocus, mode} case a.sessPreviewMode == sessPreviewWorkflows: - h = "↑↓:agent ↵:transcript ←:unfocus tab:mode" + hints = []footerHint{fh("↑↓", "agent"), a.bindAs("session", "open", "transcript"), unfocus, mode} case a.sessPreviewMode == sessPreviewRefs: - h = "↑↓:nav ↵:open sp:select ←:unfocus tab:mode" + hints = []footerHint{nav, a.bind("session", "open"), a.bind("session", "select"), unfocus, mode} case a.sessPreviewMode == sessPreviewContexts: - h = "↑↓:node ↵:open ←:unfocus tab:mode" + hints = []footerHint{fh("↑↓", "node"), a.bind("session", "open"), unfocus, mode} default: - h = "↑↓:scroll ←:unfocus tab:mode" + hints = []footerHint{fh("↑↓", "scroll"), unfocus, mode} } - } else { - h = "↑↓:nav →:focus tab:mode p:page ←:close" + default: + hints = []footerHint{nav, a.bind("session", "right"), mode, page, a.bindAs("session", "left", "close")} } - h += " " + a.helpSuffix() - return formatHelp(h) + return a.footer("", hints...) +} + +// resizeHint is the combined shrink/grow hint ("[]:resize"). +func (a *App) resizeHint() footerHint { + k := displayKey(a.keymap.Session.ResizeShrink) + displayKey(a.keymap.Session.ResizeGrow) + return footerHint{key: k, label: "resize"} } // --- Conversation view help --- @@ -116,7 +106,11 @@ func (a *App) convHelpLine(badges string) string { return formatHelp("↵:jump to origin esc:close") } if a.conv.execution.Focused { - return formatHelp("↑↓:context ↵:open x:menu A/esc:back q:quit") + back := a.bindAs("conversation", "execution_contexts", "back") + if back.key != "" { + back.key += "/" + displayKey(a.keymap.Session.Escape) + } + return a.footer("", fh("↑↓", "context"), a.bind("session", "open"), a.bindAs("conversation", "actions", "menu"), back) } if a.conv.memorySearching { return " " + a.conv.memorySearchTI.View() + helpStyle.Render(" enter:apply esc:cancel") @@ -126,22 +120,26 @@ func (a *App) convHelpLine(badges string) string { } sp := &a.conv.split - h := interactionHelpText(a.conversationPrimaryHelpActions()...) + hints := hintsFromActions(a.conversationPrimaryHelpActions()...) escLabel := "sessions" if sp.Show { if sp.Focus { next := previewModeLabels[(a.conv.rightPaneMode+1)%len(previewModeLabels)] + var pane []footerHint if a.conv.rightPaneMode == previewText { - h = joinHelpSections(h, interactionHelpText(a.conversationPreviewTextHelpActions(next)...)) + pane = hintsFromActions(a.conversationPreviewTextHelpActions(next)...) } else { - h = joinHelpSections(h, interactionHelpText(a.conversationPreviewStructuredHelpActions(next)...)) + pane = hintsFromActions(a.conversationPreviewStructuredHelpActions(next)...) } // On the Changes tab, `m` toggles per-occurrence vs per-file merge. if a.conv.inspector.Tab == inspectorChanges { - h = joinHelpSections(h, interactionHelpText(labelAction("", "m", "merge"))) + pane = append(pane, fh("m", "merge")) } + // The focused pane's keys come first: a narrow footer drops the + // list-level hints before them. + hints = append(pane, hints...) } else { - h = joinHelpSections(h, interactionHelpText(a.conversationPreviewUnfocusedHelpActions("inspector")...)) + hints = append(hints, hintsFromActions(a.conversationPreviewUnfocusedHelpActions("inspector")...)...) } switch { case a.conv.inspector.MemorySearch != "": @@ -157,21 +155,24 @@ func (a *App) convHelpLine(badges string) string { default: escLabel = "close" } - h = joinHelpSections(h, interactionHelpText(labelAction("", "esc", escLabel), resizeHelpAction(a))) - } else if len(a.navStack) > 0 || a.conv.task.ID != "" || a.conv.cron.ID != "" { - escLabel = "parent" - h = joinHelpSections(h, interactionHelpText(a.conversationPreviewHiddenHelpActions()...), "esc:"+escLabel) + // esc is how the user leaves this view: a narrow footer drops the + // optional hints before it. + hints = append(hints, a.bindAs("session", "escape", escLabel).keep(), a.resizeHint()) } else { - h = joinHelpSections(h, interactionHelpText(a.conversationPreviewHiddenHelpActions()...), "esc:"+escLabel) + if len(a.navStack) > 0 || a.conv.task.ID != "" || a.conv.cron.ID != "" { + escLabel = "parent" + } + hints = append(hints, hintsFromActions(a.conversationPreviewHiddenHelpActions()...)...) + hints = append(hints, a.bindAs("session", "escape", escLabel).keep()) } + prefix := badges if sp.Folds != nil && sp.Folds.BlockFilter != "" { vis := countVisibleBlocks(sp.Folds.BlockVisible) total := len(sp.Folds.Entry.Content) - filterInfo := filterBadge.Render(fmt.Sprintf(" [%d/%d] %s", vis, total, sp.Folds.BlockFilter)) - return filterInfo + " " + badges + formatHelp(joinHelpSections(h, a.helpSuffix())) + prefix = filterBadge.Render(fmt.Sprintf(" [%d/%d] %s", vis, total, sp.Folds.BlockFilter)) + " " + badges } - return badges + formatHelp(joinHelpSections(h, a.helpSuffix())) + return a.footer(prefix, hints...) } // --- Config view help --- @@ -187,7 +188,6 @@ func (a *App) configHelpLine() string { return " " + a.cfgSearchInput.View() + helpStyle.Render(" enter:apply esc:cancel") } if a.cfgSkillBrowse { - h := "↵:edit e:edit /:search esc:back" if a.cfgSearchTerm != "" { badge := fmt.Sprintf("[%d/%d]", a.cfgSearchIdx+1, len(a.cfgSearchMatch)) if len(a.cfgSearchMatch) == 0 { @@ -196,10 +196,9 @@ func (a *App) configHelpLine() string { return " " + filterBadge.Render(badge) + formatHelp(" n/b:next/prev esc:clear-search") } if a.cfgSplit.Show && a.cfgSplit.Focus { - h = "↑↓:scroll esc:unfocus" + return a.footer("", fh("↑↓", "scroll"), fh("esc", "unfocus")) } - h += " " + a.helpSuffix() - return formatHelp(h) + return a.footer("", fh("↵", "edit"), fh("e", "edit"), a.bind("session", "search"), fh("esc", "back")) } if a.cfgPluginsPage { // PLUGINS page reuses the plugins help line plus page-cycling hints. @@ -213,34 +212,33 @@ func (a *App) configHelpLine() string { return " " + filterBadge.Render(badge) + formatHelp(" n/b:next/prev esc:clear") } - h := "↵:open x:actions []:page " + a.keymap.Session.Search + ":search " + a.keymap.Session.Views + ":views" + actions, page, views := a.bind("session", "actions"), fh("[]", "page"), a.bind("session", "views") + hints := []footerHint{fh("↵", "open"), actions, page, a.bind("session", "search"), views} if a.cfgHasSelection() { - h = "sp:sel x:actions []:page esc:clear" + hints = []footerHint{fh("sp", "sel"), actions, page, fh("esc", "clear")} } if a.cfgSplit.Show { if a.cfgSplit.Focus { - h = "↑↓:scroll esc:unfocus" + hints = []footerHint{fh("↑↓", "scroll"), fh("esc", "unfocus")} } else { - h = "↑↓:nav →:focus x:actions []:page " + a.keymap.Session.Views + ":views" + hints = []footerHint{fh("↑↓", "nav"), fh("→", "focus"), actions, page, views} } } - h += " " + a.helpSuffix() - var badges string + prefix := " " if a.cfgHasSelection() { - badges += filterBadge.Render(fmt.Sprintf("%d selected", len(a.cfgSelectedSet))) + " " + prefix += filterBadge.Render(fmt.Sprintf("%d selected", len(a.cfgSelectedSet))) + " " } - return " " + badges + formatHelp(h) + return a.footer(prefix, hints...) } // --- Plugins view help --- func (a *App) pluginsHelpLine() string { if a.plgDetailActive { - h := "↑↓:nav →:preview x:actions e:edit esc:back" if a.plgDetailSplit.Show && a.plgDetailSplit.Focus { - h = "↑↓:scroll ←:unfocus" + return a.footer(" ", fh("↑↓", "scroll"), fh("←", "unfocus")) } - return " " + formatHelp(h+" "+a.helpSuffix()) + return a.footer(" ", fh("↑↓", "nav"), fh("→", "preview"), a.bind("session", "actions"), fh("e", "edit"), fh("esc", "back")) } if a.plgSearching { return " " + a.plgSearchInput.View() + helpStyle.Render(" enter:apply esc:cancel") @@ -248,14 +246,13 @@ func (a *App) pluginsHelpLine() string { if a.plgSearchTerm != "" { return " " + filterBadge.Render(a.plgSearchTerm) + formatHelp(" n/b:next/prev esc:clear") } - h := "↵:open →:preview x:actions []:page " + a.keymap.Session.Search + ":search" + hints := []footerHint{a.bind("session", "open"), fh("→", "preview"), a.bind("session", "actions"), fh("[]", "page"), a.bind("session", "search")} if a.plgSplit.Show && a.plgSplit.Focus { - h = "↑↓:scroll ←:unfocus []:page" + hints = []footerHint{fh("↑↓", "scroll"), fh("←", "unfocus"), fh("[]", "page")} } - h += " " + a.helpSuffix() + prefix := " " if a.plgHasSelection() { - badges := filterBadge.Render(fmt.Sprintf("%d sel", len(a.plgSelectedSet))) - return " " + badges + formatHelp(" "+h) + prefix += filterBadge.Render(fmt.Sprintf("%d sel", len(a.plgSelectedSet))) + " " } - return " " + formatHelp(h) + return a.footer(prefix, hints...) } diff --git a/internal/tui/interactions.go b/internal/tui/interactions.go index a4c6f2e..cc33d20 100644 --- a/internal/tui/interactions.go +++ b/internal/tui/interactions.go @@ -88,7 +88,16 @@ func renderInteractionLine(actions ...interactionAction) string { } func renderInteractionHintBox(lines [][]interactionAction, footer string) string { - rendered := make([]string, 0, len(lines)+1) + return renderInteractionHintBoxTitled("", lines, footer) +} + +// renderInteractionHintBoxTitled is renderInteractionHintBox with an +// already-styled first line (e.g. "3 selected"). +func renderInteractionHintBoxTitled(header string, lines [][]interactionAction, footer string) string { + rendered := make([]string, 0, len(lines)+2) + if header != "" { + rendered = append(rendered, header) + } for _, line := range lines { if text := renderInteractionLine(line...); text != "" { rendered = append(rendered, text) diff --git a/internal/tui/interactions_test.go b/internal/tui/interactions_test.go index 12f0479..e00da6d 100644 --- a/internal/tui/interactions_test.go +++ b/internal/tui/interactions_test.go @@ -56,6 +56,9 @@ func TestZoomedInspectorHelpUsesConfigurableActionBinding(t *testing.T) { app := setupConvApp(t, testEntries(), 120, 30) app.keymap.Conversation.Actions = "A" app.openInspector(inspectorConversation, session.ScopeNode, true) + // This checks where the key comes from, not how the footer fits; at 120 + // columns the focused-pane hints push "actions" past the cut. + app.width = 400 help := stripANSI(app.convHelpLine("")) if !strings.Contains(help, "A:actions") { diff --git a/internal/tui/keymap.go b/internal/tui/keymap.go index 8184911..427799e 100644 --- a/internal/tui/keymap.go +++ b/internal/tui/keymap.go @@ -151,32 +151,13 @@ func (km *Keymap) legacyAliases() map[string][]legacyBinding { // would shadow a live binding can be skipped. func (km *Keymap) scopeKeys(scope string) map[string]bool { out := make(map[string]bool) - add := func(keys ...string) { - for _, k := range keys { - if k != "" { - out[k] = true - } + for _, b := range bindingTable { + if b.scope != scope { + continue + } + if k := km.bindingKey(b.scope, b.name); k != "" { + out[k] = true } - } - switch scope { - case "session": - s := km.Session - add(s.Quit, s.Escape, s.Open, s.Edit, s.Actions, s.Views, s.Refresh, - s.Group, s.Help, s.Search, s.GlobalSearch, s.Live, s.Switch, - s.Select, s.Preview, s.PreviewBack, s.Command, s.Pick, - s.FoldAll, s.ExpandAll, s.FoldGroup, s.StateMenu, s.DailyView, s.PageMenu) - case "actions": - a := km.Actions - add(a.Delete, a.Move, a.Resume, a.CopyPath, a.Worktree, a.Kill, a.Input, - a.Jump, a.URLs, a.Files, a.Changes, a.Copy, a.Tags, a.ImportMem, - a.RemoveMem, a.Fork, a.New, a.Remote, a.Edit) - case "conversation": - c := km.Conversation - add(c.JumpToTree, c.SwitchRegion, c.ExecutionContexts, c.RegionUp, - c.RegionDown, c.LiveToggle, c.Edit, c.Actions, c.Input) - case "preview": - p := km.Preview - add(p.FoldAll, p.ExpandAll, p.Filter, p.CopyMode, p.CopyAll) } return out } diff --git a/internal/tui/pane_actions.go b/internal/tui/pane_actions.go index 6902889..81c0c08 100644 --- a/internal/tui/pane_actions.go +++ b/internal/tui/pane_actions.go @@ -174,7 +174,7 @@ func (a *App) handleLiveInputPane(msg liveInputPaneMsg) tea.Cmd { return liveInputSentMsg{err: fmt.Errorf("empty")} } sendText := strings.TrimRight(string(content), "\n") - return liveInputSentMsg{err: tmux.SendKeys(pane, sendText)} + return liveInputSentMsg{err: sendKeys(pane, sendText)} } } diff --git a/internal/tui/plugins.go b/internal/tui/plugins.go index c88a086..54f86d2 100644 --- a/internal/tui/plugins.go +++ b/internal/tui/plugins.go @@ -352,7 +352,7 @@ func (a *App) handlePluginKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case a.keymap.Session.Views: a.viewsMenu = true return a, nil - case "x": + case a.keymap.Session.Actions: a.plgActionsMenu = true return a, nil case " ": @@ -493,7 +493,7 @@ func (a *App) handleCfgPluginKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case a.keymap.Session.Views: a.viewsMenu = true return a, nil - case "x": + case a.keymap.Session.Actions: a.plgActionsMenu = true return a, nil case " ": @@ -1062,7 +1062,7 @@ func (a *App) handlePluginDetailKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return a.copyPluginPath() case "o": return a.openPluginShell() - case "x": + case a.keymap.Session.Actions: a.plgCompActionsMenu = true return a, nil case " ": diff --git a/internal/tui/sessions.go b/internal/tui/sessions.go index 02a72b9..12042c7 100644 --- a/internal/tui/sessions.go +++ b/internal/tui/sessions.go @@ -1808,10 +1808,11 @@ func (a *App) helpModalContextRows() (title string, rows []helpRow) { } case viewGlobalStats: return "Global stats", []helpRow{ - {"p", "Page menu (tools/mcp/agents/…)"}, + {displayKey(km.Session.PageMenu), "Page menu (tools/mcp/agents/…)"}, {"↑↓", "Scroll"}, {"1-9", "Jump to page"}, - {"esc", "Back"}, + {displayKey(km.Session.Escape), "Back"}, + a.helpRowFor("session", "refresh"), } case viewConfig: return "Config", []helpRow{ @@ -1834,19 +1835,7 @@ func (a *App) helpModalContextRows() (title string, rows []helpRow) { if a.sessSplit.Show && a.sessSplit.Focus { return "Preview (focused)", a.sessionsPreviewContextRows() } - return "Sessions", []helpRow{ - {displayKey(km.Session.Open), "Open / expand"}, - {displayKey(km.Session.Right) + " / tab", "Preview / cycle mode"}, - {displayKey(km.Session.Actions), "Actions (" + displayKey(km.Actions.Delete) + "/" + displayKey(km.Actions.Resume) + "/" + displayKey(km.Actions.CopyPath) + "/…)"}, - {displayKey(km.Session.Search), "Search / filter"}, - {displayKey(km.Session.Views), "Views (stats/config/plugins)"}, - {displayKey(km.Session.Refresh), "Refresh"}, - {displayKey(km.Session.PageMenu) + " → l", "Live preview (page menu → live)"}, - {displayKey(km.Session.Select), "Multi-select"}, - {displayKey(km.Session.FoldGroup) + " / " + displayKey(km.Session.FoldAll) + " / " + displayKey(km.Session.ExpandAll), "Fold group / all / expand all"}, - {displayKey(km.Session.StateMenu), "Toggle session states shown (live/done/…)"}, - {displayKey(km.Session.DailyView), "Daily view (dates + what each day produced)"}, - } + return "Sessions", a.sessionsListHelpRows() } } @@ -1905,16 +1894,11 @@ func (a *App) sessionsPreviewContextRows() []helpRow { // helpCommonRows returns the cross-view keys shown in every help overlay. func (a *App) helpCommonRows() []helpRow { - km := a.keymap.Session - return []helpRow{ - {displayKey(km.Command), "Command palette (full actions)"}, - {displayKey(km.Views), "Switch views"}, - {displayKey(km.Search), "Search / filter"}, - {displayKey(km.GlobalSearch), "Global search"}, - {displayKey(km.Escape), "Back / close"}, - {displayKey(km.Help), "This help"}, - {displayKey(km.Quit), "Quit"}, + var rows []helpRow + for _, name := range []string{"command", "views", "search", "global_search", "escape", "help", "quit"} { + rows = append(rows, a.helpRowFor("session", name)) } + return rows } func (a *App) renderHelpModal(bg string, screenW, screenH int) string { diff --git a/internal/tui/share_ref.go b/internal/tui/share_ref.go index 4ae4c46..afcee98 100644 --- a/internal/tui/share_ref.go +++ b/internal/tui/share_ref.go @@ -10,7 +10,6 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/sendbird/ccx/internal/session" - "github.com/sendbird/ccx/internal/tmux" ) // shareRefItem is one shareable artifact from the source session: a memory @@ -31,7 +30,7 @@ const ( // openShareRef opens a two-step picker: (1) pick a shareable artifact from the // currently selected session, (2) pick a target live session to receive an // @ reference prompt. The prompt is injected into the target's tmux pane -// via tmux.SendKeys — the same channel openLiveInput uses. +// via sendKeys — the same channel openLiveInput uses. func (a *App) openShareRef() (tea.Model, tea.Cmd) { sess, ok := a.selectedSession() if !ok { @@ -179,14 +178,9 @@ func (a *App) confirmShareRef() (tea.Model, tea.Cmd) { } prompt := buildShareRefPrompt(a.shareRefSrcShort, pickedItem) - a.copiedMsg = fmt.Sprintf("Shared @%s → %s", filepath.Base(pickedItem.Path), shortSessionLabel(target)) - return a, func() tea.Msg { - pane, found := tmux.FindPane(target.ProjectPath, target.ID) - if !found || !tmux.HasClaude(pane.PID) { - return liveInputSentMsg{err: fmt.Errorf("no live Claude pane for %s", shortSessionLabel(target))} - } - return liveInputSentMsg{err: tmux.SendKeys(pane, prompt)} - } + label := fmt.Sprintf("@%s → %s", filepath.Base(pickedItem.Path), shortSessionLabel(target)) + a.copiedMsg = "Sharing " + label + "…" + return a, shareRefSendCmd(target, prompt, "Shared "+label) } // buildShareRefPrompt composes the prompt injected into the target session. diff --git a/internal/tui/tmux_async.go b/internal/tui/tmux_async.go new file mode 100644 index 0000000..3670a63 --- /dev/null +++ b/internal/tui/tmux_async.go @@ -0,0 +1,202 @@ +package tui + +import ( + "errors" + "fmt" + "os" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/sendbird/ccx/internal/session" + "github.com/sendbird/ccx/internal/tmux" +) + +// Pane jump, live preview, bulk input and share-ref used to call +// tmux.FindPane (list every pane + walk the process table) inside Update. +// Each now resolves panes in a tea.Cmd; the result message carries the +// generation it was issued under and Update drops it when a newer request +// of the same kind has started, so a slow lookup never acts on a stale +// target. Same pattern as openLiveInput in pane_actions.go. +var ( + switchToPane = tmux.SwitchToPane + sendKeys = tmux.SendKeys +) + +// paneJumpFoundMsg is the lookup result for jumpToTmuxPane/resumeSession. +type paneJumpFoundMsg struct { + seq int + pane tmux.Pane + found bool + // resume, when set, is the session to resume in this terminal if no + // pane runs it (resumeSession's fallback). + resume *session.Session +} + +// paneJumpDoneMsg reports the switch-client outcome. +type paneJumpDoneMsg struct{ err error } + +func (a *App) jumpToTmuxPane(projectPath string, sessionID ...string) (tea.Model, tea.Cmd) { + return a, a.paneJumpCmd(nil, projectPath, sessionID...) +} + +func (a *App) paneJumpCmd(resume *session.Session, projectPath string, sessionID ...string) tea.Cmd { + a.paneJumpSeq++ + seq := a.paneJumpSeq + return func() tea.Msg { + pane, found := findPane(projectPath, sessionID...) + return paneJumpFoundMsg{seq: seq, pane: pane, found: found, resume: resume} + } +} + +func (a *App) handlePaneJumpFound(msg paneJumpFoundMsg) tea.Cmd { + if msg.seq != a.paneJumpSeq { + return nil // superseded: switching now would land on an older target + } + if msg.found { + pane := msg.pane + return func() tea.Msg { return paneJumpDoneMsg{err: switchToPane(pane)} } + } + if msg.resume == nil { + a.copiedMsg = "No tmux pane found" + return nil + } + // Live session without a pane: take over this terminal. + dir := msg.resume.ProjectPath + if dir == "" { + dir, _ = os.UserHomeDir() + } + cmd, err := a.claudeCmd(dir, "--resume", msg.resume.ID) + if err != nil { + a.copiedMsg = "Claude command failed: " + err.Error() + return nil + } + return tea.ExecProcess(cmd, func(error) tea.Msg { return tea.QuitMsg{} }) +} + +// livePreviewPaneMsg is the lookup result for openLivePreview. +type livePreviewPaneMsg struct { + seq int + sessID string + pane tmux.Pane + found bool +} + +func (a *App) openLivePreview(sess session.Session) (tea.Model, tea.Cmd) { + if !sess.IsLive { + a.copiedMsg = "not a live session" + return a, nil + } + a.livePreviewSeq++ + seq := a.livePreviewSeq + projectPath, id := sess.ProjectPath, sess.ID + return a, func() tea.Msg { + pane, found := findPane(projectPath, id) + return livePreviewPaneMsg{seq: seq, sessID: id, pane: pane, found: found} + } +} + +func (a *App) handleLivePreviewPane(msg livePreviewPaneMsg) tea.Cmd { + if msg.seq != a.livePreviewSeq { + return nil + } + if sess, ok := a.selectedSession(); !ok || sess.ID != msg.sessID { + return nil // cursor moved while looking up + } + if !msg.found { + a.copiedMsg = "tmux pane not found" + return nil + } + a.paneProxy = &paneProxyState{pane: msg.pane, sessID: msg.sessID} + a.toggleSessionPreviewMode(sessPreviewLive) + return tea.Batch(capturePaneCmd(msg.pane), liveTickCmd()) +} + +// bulkInputPanesMsg carries the live Claude panes for bulkInput. +type bulkInputPanesMsg struct { + seq int + panes []tmux.Pane + projDir string +} + +func (a *App) bulkInput(selected []session.Session) (tea.Model, tea.Cmd) { + if !tmux.InTmux() { + a.copiedMsg = "Requires tmux" + return a, nil + } + type target struct{ path, id string } + var targets []target + for _, s := range selected { + if s.IsLive { + targets = append(targets, target{s.ProjectPath, s.ID}) + } + } + projDir := "" + if len(selected) > 0 { + projDir = selected[0].ProjectPath + } + a.liveInputSeq++ + seq := a.liveInputSeq + return a, func() tea.Msg { + var panes []tmux.Pane + for _, t := range targets { + pane, found := findPane(t.path, t.id) + if found && paneHasClaude(pane.PID) { + panes = append(panes, pane) + } + } + return bulkInputPanesMsg{seq: seq, panes: panes, projDir: projDir} + } +} + +func (a *App) handleBulkInputPanes(msg bulkInputPanesMsg) { + // Shares liveInputSeq with openLiveInput: whichever input request is + // newest owns the modal. + if msg.seq != a.liveInputSeq { + return + } + if len(msg.panes) == 0 { + a.copiedMsg = "No live Claude panes" + return + } + a.liveInputPanes = msg.panes + a.liveInputModal = newInputModal() + a.liveInputModal.title = fmt.Sprintf("Send to %d panes", len(msg.panes)) + a.liveInputActive = true + a.liveInputProjDir = msg.projDir +} + +// shareRefSentMsg reports the outcome of sending a share-ref prompt. +type shareRefSentMsg struct { + okStatus string + err error +} + +func shareRefSendCmd(target session.Session, prompt, okStatus string) tea.Cmd { + return func() tea.Msg { + pane, found := findPane(target.ProjectPath, target.ID) + if !found || !paneHasClaude(pane.PID) { + return shareRefSentMsg{err: fmt.Errorf("no live Claude pane for %s", shortSessionLabel(target))} + } + return shareRefSentMsg{okStatus: okStatus, err: sendKeys(pane, prompt)} + } +} + +func (a *App) handleShareRefSent(msg shareRefSentMsg) { + if msg.err != nil { + a.copiedMsg = "Share failed: " + msg.err.Error() + return + } + a.copiedMsg = msg.okStatus +} + +// sendToPanes sends text to every pane and joins the errors so a partial +// failure is reported instead of the last error only. +func sendToPanes(panes []tmux.Pane, text string) error { + var errs []error + for _, p := range panes { + if err := sendKeys(p, text); err != nil { + errs = append(errs, fmt.Errorf("%s:%s.%s: %w", p.Session, p.Window, p.Pane, err)) + } + } + return errors.Join(errs...) +} diff --git a/internal/tui/tmux_async_test.go b/internal/tui/tmux_async_test.go new file mode 100644 index 0000000..02ca001 --- /dev/null +++ b/internal/tui/tmux_async_test.go @@ -0,0 +1,140 @@ +package tui + +import ( + "errors" + "strings" + "testing" + + "github.com/sendbird/ccx/internal/session" + "github.com/sendbird/ccx/internal/tmux" +) + +// stubPaneIO replaces switch-client and send-keys and counts calls. +func stubPaneIO(t *testing.T, sendErr error) (switches, sends *int) { + t.Helper() + switches, sends = new(int), new(int) + origSwitch, origSend := switchToPane, sendKeys + t.Cleanup(func() { switchToPane, sendKeys = origSwitch, origSend }) + switchToPane = func(tmux.Pane) error { *switches++; return nil } + sendKeys = func(tmux.Pane, string) error { *sends++; return sendErr } + return switches, sends +} + +func TestJumpToTmuxPaneLooksUpOffUpdateAndDropsStale(t *testing.T) { + finds, _ := stubTmux(t, true, nil) + switches, _ := stubPaneIO(t, nil) + a := newTestApp(fakeSessions()) + + _, first := a.jumpToTmuxPane("/p", "id1") + _, second := a.jumpToTmuxPane("/p", "id2") + if *finds != 0 { + t.Fatalf("FindPane ran on Update path %d time(s)", *finds) + } + if _, cmd := a.Update(first()); cmd != nil { + t.Fatal("stale lookup must not switch") + } + _, cmd := a.Update(second()) + if cmd == nil { + t.Fatal("latest lookup should return a switch command") + } + if *switches != 0 { + t.Fatal("switch-client ran on Update path") + } + a.Update(cmd()) + if *switches != 1 { + t.Fatalf("switches = %d, want 1", *switches) + } +} + +func TestJumpToTmuxPaneNotFound(t *testing.T) { + stubTmux(t, false, nil) + a := newTestApp(fakeSessions()) + _, cmd := a.jumpToTmuxPane("/p", "id1") + a.Update(cmd()) + if a.copiedMsg != "No tmux pane found" { + t.Fatalf("copiedMsg = %q", a.copiedMsg) + } +} + +func TestOpenLivePreviewLooksUpOffUpdateAndChecksCursor(t *testing.T) { + finds, _ := stubTmux(t, true, nil) + a := newTestApp(fakeSessions()) + sess, ok := a.selectedSession() + if !ok || !sess.IsLive { + t.Fatalf("expected a live selected session, got %+v", sess) + } + + _, cmd := a.openLivePreview(sess) + if *finds != 0 { + t.Fatal("FindPane ran on Update path") + } + msg := cmd() + if a.paneProxy != nil || a.sessPreviewMode == sessPreviewLive { + t.Fatal("preview opened before the lookup returned") + } + + // Cursor moved while the lookup ran: result is for another session. + stale := msg.(livePreviewPaneMsg) + stale.sessID = "someone-else" + a.Update(stale) + if a.paneProxy != nil { + t.Fatal("result for a session no longer selected opened the preview") + } + + a.Update(msg) + if a.paneProxy == nil || a.sessPreviewMode != sessPreviewLive { + t.Fatalf("live preview not opened: proxy=%v mode=%v", a.paneProxy, a.sessPreviewMode) + } +} + +func TestBulkInputLooksUpOffUpdate(t *testing.T) { + t.Setenv("TMUX", "/tmp/fake,1,0") + finds, _ := stubTmux(t, true, nil) + a := newTestApp(fakeSessions()) + live := []session.Session{a.sessions[0], a.sessions[2]} + + _, cmd := a.bulkInput(live) + if *finds != 0 { + t.Fatal("FindPane ran on Update path") + } + a.Update(cmd()) + if !a.liveInputActive || len(a.liveInputPanes) != 2 { + t.Fatalf("modal not opened for 2 panes: active=%v panes=%d", a.liveInputActive, len(a.liveInputPanes)) + } +} + +func TestShareRefSendReportsSendError(t *testing.T) { + stubTmux(t, true, nil) + stubPaneIO(t, errors.New("can't find pane")) + a := newTestApp(fakeSessions()) + a.Update(shareRefSendCmd(a.sessions[0], "prompt", "Shared x")()) + if !strings.Contains(a.copiedMsg, "Share failed") || !strings.Contains(a.copiedMsg, "can't find pane") { + t.Fatalf("copiedMsg = %q", a.copiedMsg) + } + + stubPaneIO(t, nil) + a.Update(shareRefSendCmd(a.sessions[0], "prompt", "Shared x")()) + if a.copiedMsg != "Shared x" { + t.Fatalf("copiedMsg = %q, want %q", a.copiedMsg, "Shared x") + } +} + +func TestSendToPanesReportsEveryFailure(t *testing.T) { + _, sends := stubPaneIO(t, errors.New("boom")) + err := sendToPanes([]tmux.Pane{{Session: "a"}, {Session: "b"}}, "hi") + if *sends != 2 { + t.Fatalf("sends = %d, want 2", *sends) + } + if err == nil || strings.Count(err.Error(), "boom") != 2 { + t.Fatalf("err = %v, want both failures", err) + } +} + +// The `\` continuation used to drop send-keys errors. +func TestLiveCaptureSurfacesSendError(t *testing.T) { + a := newTestApp(fakeSessions()) + a.Update(liveCaptureMsg{content: "x", sendErr: errors.New("no server")}) + if !strings.Contains(a.copiedMsg, "no server") { + t.Fatalf("copiedMsg = %q", a.copiedMsg) + } +}