From 0240fe389a0e55257cc8cd87dcc54f5e7fb833a7 Mon Sep 17 00:00:00 2001 From: Gavin Jeong Date: Wed, 29 Apr 2026 21:58:42 +0900 Subject: [PATCH 1/3] feat: add copy action and preserve selection on refresh in conv views --- internal/tui/conversation.go | 8 +++- internal/tui/copymode.go | 75 +++++++++++++++++++++++++++++++ internal/tui/interactions.go | 6 ++- internal/tui/interactions_test.go | 72 +++++++++++++++++++++++++++++ internal/tui/keymap.go | 7 ++- internal/tui/msgfull.go | 54 +++++++++++++++++++++- internal/tui/urls.go | 7 +++ 7 files changed, 224 insertions(+), 5 deletions(-) diff --git a/internal/tui/conversation.go b/internal/tui/conversation.go index 002d0f4..5a6f1d3 100644 --- a/internal/tui/conversation.go +++ b/internal/tui/conversation.go @@ -2151,9 +2151,12 @@ func (a *App) refreshConversation() tea.Cmd { a.conv.items = buildConvItems(a.conv.sess, a.conv.merged, agents, tasks, crons) a.conv.sess.Tasks = tasks - // Preserve cursor position + // Preserve list cursor and preview selection across the rebuild oldIdx := a.convList.Index() + prevCacheKey := a.conv.split.CacheKey + prevYOffset := a.conv.split.Preview.YOffset a.rebuildConversationList(oldIdx) + a.conv.split.CacheKey = prevCacheKey // During live tail, skip preview update here — handleLiveTail owns the // preview lifecycle (select last → update → scroll-to-tail). Updating here // would "consume" the CacheKey change, making handleLiveTail's update a @@ -2161,6 +2164,9 @@ func (a *App) refreshConversation() tea.Cmd { // RefreshFoldPreview→ScrollToBlock. if !a.liveTail { a.updateConvPreview() + if a.conv.split.Folds != nil { + a.conv.split.Preview.YOffset = prevYOffset + } } return nil } diff --git a/internal/tui/copymode.go b/internal/tui/copymode.go index fe77eb1..1514631 100644 --- a/internal/tui/copymode.go +++ b/internal/tui/copymode.go @@ -216,3 +216,78 @@ func (a *App) renderCopyMode() { vp.SetContent(sb.String()) vp.YOffset = offset } + +// copyConvSelection copies the currently selected conversation preview content +// to the clipboard. If blocks are explicitly selected (via space toggling), only +// those blocks are copied; otherwise the block under the cursor is copied. When +// no fold state exists yet, falls back to the message-level text. +func (a *App) copyConvSelection() { + sp := &a.conv.split + if sp.Folds == nil || len(sp.Folds.Entry.Content) == 0 { + a.copyConvSelectedMessage() + return + } + fs := sp.Folds + if len(fs.Selected) > 0 { + var parts []string + count := 0 + for i, block := range fs.Entry.Content { + if !fs.Selected[i] { + continue + } + if text := blockPlainText(block); text != "" { + parts = append(parts, text) + count++ + } + } + if count == 0 { + a.copiedMsg = "Nothing to copy" + return + } + copyToClipboard(strings.Join(parts, "\n\n")) + a.copiedMsg = fmt.Sprintf("Copied %d block", count) + if count != 1 { + a.copiedMsg += "s" + } + a.copiedMsg += "!" + fs.Selected = nil + sp.RefreshFoldPreview(a.width, a.splitRatio) + return + } + if fs.BlockCursor >= 0 && fs.BlockCursor < len(fs.Entry.Content) { + text := blockPlainText(fs.Entry.Content[fs.BlockCursor]) + if text != "" { + copyToClipboard(text) + a.copiedMsg = "Copied block!" + return + } + } + a.copyConvSelectedMessage() +} + +// copyConvSelectedMessage copies the full text of the currently selected +// conversation list item, used when there is no block-level selection. +func (a *App) copyConvSelectedMessage() { + item, ok := a.convList.SelectedItem().(convItem) + if !ok { + a.copiedMsg = "Nothing to copy" + return + } + var entry session.Entry + switch item.kind { + case convMsg: + entry = item.merged.entry + case convAgent: + entry = buildAgentPreviewEntry(item.agent) + default: + a.copiedMsg = "Nothing to copy" + return + } + text := entryFullText(entry) + if text == "" { + a.copiedMsg = "Nothing to copy" + return + } + copyToClipboard(text) + a.copiedMsg = "Copied message!" +} diff --git a/internal/tui/interactions.go b/internal/tui/interactions.go index 3e4759e..a929b18 100644 --- a/internal/tui/interactions.go +++ b/internal/tui/interactions.go @@ -10,9 +10,10 @@ import ( type interactionActionID string const ( - interactionActionURLs interactionActionID = "urls" - interactionActionFiles interactionActionID = "files" + interactionActionURLs interactionActionID = "urls" + interactionActionFiles interactionActionID = "files" interactionActionChanges interactionActionID = "changes" + interactionActionCopy interactionActionID = "copy" ) type interactionAction struct { @@ -184,6 +185,7 @@ func (a *App) conversationActionMenuActions() []interactionAction { bindAction(interactionActionURLs, a.keymap.Actions.URLs, "urls"), bindAction(interactionActionFiles, a.keymap.Actions.Files, "files"), bindAction(interactionActionChanges, a.keymap.Actions.Changes, "changes"), + bindAction(interactionActionCopy, a.keymap.Actions.Copy, "copy"), } } diff --git a/internal/tui/interactions_test.go b/internal/tui/interactions_test.go index d6b1c2b..1316523 100644 --- a/internal/tui/interactions_test.go +++ b/internal/tui/interactions_test.go @@ -158,3 +158,75 @@ func TestHandleBulkActionsMenuOpensBulkChanges(t *testing.T) { t.Fatalf("expected change map populated for both sessions, got %d", len(app.urlChangeMap)) } } + +func TestHandleConvActionsMenuCopyCopiesSelectedBlock(t *testing.T) { + app := setupConvApp(t, testEntries(), 120, 30) + app.conv.split.Show = true + app.conv.split.Focus = true + app.keymap.Actions.Copy = "c" + + selectConvItemBy(t, app, func(ci convItem) bool { + return ci.kind == convMsg && ci.merged.entry.Role == "assistant" + }) + app.updateConvPreview() + if app.conv.split.Folds == nil || len(app.conv.split.Folds.Entry.Content) == 0 { + t.Fatal("expected fold state after preview update") + } + app.conv.split.Folds.BlockCursor = 0 + app.copiedMsg = "" + + m, _ := app.handleConvActionsMenu("c") + app = m.(*App) + if app.convActionsMenu { + t.Fatal("expected actions menu to close after handling copy") + } + if !strings.Contains(app.copiedMsg, "Copied") { + t.Fatalf("expected copy confirmation, got %q", app.copiedMsg) + } +} + +func TestRefreshConversationPreservesFoldSelection(t *testing.T) { + entries := testEntries() + app := setupConvApp(t, entries, 120, 30) + app.conv.split.Show = true + + selectConvItemBy(t, app, func(ci convItem) bool { + return ci.kind == convMsg && ci.merged.entry.Role == "assistant" + }) + app.updateConvPreview() + + if app.conv.split.Folds == nil || len(app.conv.split.Folds.Entry.Content) == 0 { + t.Fatal("expected fold state populated before refresh") + } + prevCursor := 1 + if prevCursor >= len(app.conv.split.Folds.Entry.Content) { + prevCursor = len(app.conv.split.Folds.Entry.Content) - 1 + } + app.conv.split.Folds.BlockCursor = prevCursor + app.conv.split.Folds.Selected = foldSet{prevCursor: true} + prevListIdx := app.convList.Index() + prevCacheKey := app.conv.split.CacheKey + + // Simulate refreshConversation's rebuild step (no file I/O) + app.conv.items = buildConvItems(app.conv.sess, app.conv.merged, nil, nil, nil) + prevYOffset := app.conv.split.Preview.YOffset + app.rebuildConversationList(prevListIdx) + app.conv.split.CacheKey = prevCacheKey + app.updateConvPreview() + if app.conv.split.Folds != nil { + app.conv.split.Preview.YOffset = prevYOffset + } + + if app.convList.Index() != prevListIdx { + t.Fatalf("list cursor should be preserved across refresh: got %d want %d", app.convList.Index(), prevListIdx) + } + if app.conv.split.Folds == nil { + t.Fatal("fold state should remain after refresh") + } + if app.conv.split.Folds.BlockCursor != prevCursor { + t.Fatalf("block cursor should be preserved: got %d want %d", app.conv.split.Folds.BlockCursor, prevCursor) + } + if !app.conv.split.Folds.Selected[prevCursor] { + t.Fatal("block selection should be preserved across refresh") + } +} diff --git a/internal/tui/keymap.go b/internal/tui/keymap.go index 372a5da..9b14d78 100644 --- a/internal/tui/keymap.go +++ b/internal/tui/keymap.go @@ -45,6 +45,7 @@ type ActionsKeymap struct { URLs string `yaml:"urls"` Files string `yaml:"files"` Changes string `yaml:"changes"` + Copy string `yaml:"copy"` Tags string `yaml:"tags"` ImportMem string `yaml:"import_mem"` RemoveMem string `yaml:"remove_mem"` @@ -138,7 +139,8 @@ func DefaultKeymap() Keymap { Jump: "j", URLs: "u", Files: "f", - Changes: "g", + Changes: "g", + Copy: "c", Tags: "t", ImportMem: "M", RemoveMem: "X", @@ -300,6 +302,9 @@ func mergeKeymap(dst *Keymap, src Keymap) { if src.Actions.Changes != "" { dst.Actions.Changes = src.Actions.Changes } + if src.Actions.Copy != "" { + dst.Actions.Copy = src.Actions.Copy + } if src.Actions.ImportMem != "" { dst.Actions.ImportMem = src.Actions.ImportMem } diff --git a/internal/tui/msgfull.go b/internal/tui/msgfull.go index 8e1cc97..84a9f6d 100644 --- a/internal/tui/msgfull.go +++ b/internal/tui/msgfull.go @@ -162,6 +162,10 @@ func (a *App) handleMessageFullKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "x": a.convActionsMenu = true return a, nil + case a.keymap.Session.Refresh: + a.refreshMsgFull() + a.copiedMsg = "Refreshed" + return a, nil } // Search navigation (when search term is active) @@ -321,7 +325,55 @@ func (a *App) handleLiveTailMsgFull() { a.msgFull.vp.YOffset = maxOffset } -// refreshMsgFullPreview re-renders the message full viewport. +// refreshMsgFull reloads messages for the current message-full session, +// preserving the existing fold/cursor/selection state when possible. +func (a *App) refreshMsgFull() { + entries, err := session.LoadMessages(a.msgFull.sess.FilePath) + if err != nil { + return + } + a.msgFull.messages = entries + a.msgFull.merged = filterConversation(mergeConversationTurns(entries)) + + if len(a.msgFull.merged) == 0 { + return + } + + idx := a.msgFull.idx + if idx < 0 { + idx = 0 + } + if idx >= len(a.msgFull.merged) { + idx = len(a.msgFull.merged) - 1 + } + a.msgFull.idx = idx + + newEntry := a.msgFull.merged[idx].entry + fs := &a.msgFull.folds + oldEntry := fs.Entry + oldBlockCount := len(oldEntry.Content) + newBlockCount := len(newEntry.Content) + + if oldBlockCount == 0 || newBlockCount < oldBlockCount { + fs.Reset(newEntry) + } else { + fs.GrowBlocks(newEntry, oldBlockCount, nil, nil) + } + if fs.BlockCursor < 0 || fs.BlockCursor >= len(fs.Entry.Content) { + if last := fs.lastVisibleBlock(); last >= 0 { + fs.BlockCursor = last + } + } + + contentH := ContentHeight(a.height) + a.msgFull.content = renderFullMessage(newEntry, a.width) + if a.msgFull.vp.Width == 0 || a.msgFull.vp.Height == 0 { + a.msgFull.vp = viewport.New(a.width, contentH) + } + a.refreshMsgFullPreview() +} + + func (a *App) refreshMsgFullPreview() { fs := &a.msgFull.folds ro := renderOpts{visible: fs.BlockVisible, hideHooks: fs.HideHooks, selected: fs.Selected} diff --git a/internal/tui/urls.go b/internal/tui/urls.go index 49d8a30..9928b86 100644 --- a/internal/tui/urls.go +++ b/internal/tui/urls.go @@ -34,6 +34,13 @@ func (a *App) handleConvActionsMenu(key string) (tea.Model, tea.Cmd) { return a.openMsgFullChangesMenu() } return a.openConvChangesMenu() + case interactionKeyMatches(actions, key, interactionActionCopy): + if a.state == viewMessageFull { + a.copyMsgFullBlocks() + return a, nil + } + a.copyConvSelection() + return a, nil } return a, nil } From bbecba0784c8abd6cc3501c3b2c91207c9240941 Mon Sep 17 00:00:00 2001 From: Gavin Jeong Date: Thu, 30 Apr 2026 01:19:23 +0900 Subject: [PATCH 2/3] fix: respect session preview focus for actions Keep preview-focused actions and space handling scoped to the right pane so existing left-list selections do not leak into preview interactions. --- internal/tui/app.go | 94 +++++++++++++++++++++--- internal/tui/help.go | 2 +- internal/tui/interactions_test.go | 72 ++++++++++++++++++ internal/tui/session_keybindings_test.go | 13 ++++ 4 files changed, 170 insertions(+), 11 deletions(-) diff --git a/internal/tui/app.go b/internal/tui/app.go index c9a0cc5..bc3cefc 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -1524,6 +1524,9 @@ func (a *App) handleSessionKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { a.currentSess = sess return a, a.openConversation(sess) case km.Session.Select: + if sp.Focus && sp.Show { + return a, nil + } sess, ok := a.selectedSession() if !ok { return a, nil @@ -1885,13 +1888,15 @@ func (a *App) handleConvPreviewKeys(sp *SplitPane, key string) (tea.Model, tea.C a.sessPreviewPinned = a.sessConvCursor < len(visible)-1 return a, nil, true case "c": - if a.sessConvCursor < len(visible) { - text := entryFullText(visible[a.sessConvCursor].entry) - if text != "" { - a.sessConvFullText = text - a.sessConvFullScroll = 0 - } + return a.openSessionPreviewFullText(visible), nil, true + case a.keymap.Session.Actions: + if sess, ok := a.selectedSession(); ok { + a.actionsSess = sess } + a.actionsMenu = true + return a, nil, true + case a.keymap.Actions.Copy: + a.copySessionPreviewSelection() return a, nil, true case "enter": m, cmd := a.jumpToConvMessage() @@ -2278,6 +2283,67 @@ func (a *App) copySelectedSessionPath() (tea.Model, tea.Cmd) { return a, nil } +func (a *App) openSessionPreviewFullText(visible []mergedMsg) *App { + if a.sessConvCursor < 0 || a.sessConvCursor >= len(visible) { + return a + } + text := entryFullText(visible[a.sessConvCursor].entry) + if text == "" { + return a + } + a.sessConvFullText = text + a.sessConvFullScroll = 0 + return a +} + +func (a *App) copySessionPreviewSelection() { + visible := a.convVisibleEntries() + if a.sessPreviewMode != sessPreviewConversation || a.sessConvCursor < 0 || a.sessConvCursor >= len(visible) { + a.copiedMsg = "Nothing to copy" + return + } + text := entryFullText(visible[a.sessConvCursor].entry) + if text == "" { + a.copiedMsg = "Nothing to copy" + return + } + if err := copyToClipboard(text); err != nil { + a.copiedMsg = "Copy failed" + return + } + a.copiedMsg = "Copied message!" +} + +func (a *App) copySessionAction() (tea.Model, tea.Cmd) { + if a.sessSplit.Focus && a.sessSplit.Show && a.sessPreviewMode == sessPreviewConversation { + a.copySessionPreviewSelection() + return a, nil + } + return a.copySelectedSessionPath() +} + +func (a *App) copySelectedSessionPaths(selected []session.Session) (tea.Model, tea.Cmd) { + var paths []string + for _, sess := range selected { + if sess.FilePath != "" { + paths = append(paths, sess.FilePath) + } + } + if len(paths) == 0 { + a.copiedMsg = "No session files" + return a, nil + } + if err := copyToClipboard(strings.Join(paths, "\n")); err != nil { + a.copiedMsg = "Copy failed" + return a, nil + } + a.copiedMsg = fmt.Sprintf("Copied %d session path", len(paths)) + if len(paths) != 1 { + a.copiedMsg += "s" + } + return a, nil +} + // --- Edit file with $EDITOR --- type editChoice struct { @@ -2430,10 +2496,14 @@ func (a *App) renderEditHintBox() string { return boxStyle.Render(body) } +func (a *App) sessionPreviewActionsActive() bool { + return a.sessSplit.Focus && a.sessSplit.Show && a.sessPreviewMode == sessPreviewConversation +} + func (a *App) handleActionsMenu(key string) (tea.Model, tea.Cmd) { a.actionsMenu = false a.copiedMsg = "" - if a.hasMultiSelection() { + if !a.sessionPreviewActionsActive() && a.hasMultiSelection() { return a.handleBulkActionsMenu(key) } akm := a.keymap.Actions @@ -2452,6 +2522,8 @@ func (a *App) handleActionsMenu(key string) (tea.Model, tea.Cmd) { return a.resumeSession(sess) case akm.CopyPath: return a.copySelectedSessionPath() + case akm.Copy: + return a.copySessionAction() case akm.Move: if sess.ProjectPath == "" { a.copiedMsg = "No project path" @@ -2597,6 +2669,8 @@ func (a *App) handleBulkActionsMenu(key string) (tea.Model, tea.Cmd) { return a.bulkKill(selected) case akm.Input: return a.bulkInput(selected) + case akm.Copy, akm.CopyPath: + return a.copySelectedSessionPaths(selected) case akm.Tags: // Collect all selected session IDs var sessIDs []string @@ -4764,14 +4838,14 @@ func (a *App) renderActionsHintBox() string { akm := a.keymap.Actions var lines []string - if a.hasMultiSelection() { + 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.Kill))+d.Render(":kill")+sp+hl.Render(displayKey(akm.Input))+d.Render(":input")) + 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")) } else { sess := a.actionsSess - 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.CopyPath))+d.Render(":copy-path")) + 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.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") diff --git a/internal/tui/help.go b/internal/tui/help.go index 0be60e8..6ea6bc1 100644 --- a/internal/tui/help.go +++ b/internal/tui/help.go @@ -74,7 +74,7 @@ func (a *App) sessHelpLine() string { } else if a.sessSplit.Focus { switch a.sessPreviewMode { case sessPreviewConversation: - h += " ↑↓:nav c:full " + fmtKey(sk.Open, "jump") + " ←:unfocus /:search tab:mode" + h += " ↑↓:nav c:full " + fmtKey(sk.Actions, "actions") + " " + fmtKey(sk.Open, "jump") + " ←:unfocus /:search tab:mode" case sessPreviewAgents: h += " ↑↓:nav " + fmtKey(sk.Open, "jump") + " ←:unfocus tab:mode" default: diff --git a/internal/tui/interactions_test.go b/internal/tui/interactions_test.go index 1316523..ade0709 100644 --- a/internal/tui/interactions_test.go +++ b/internal/tui/interactions_test.go @@ -185,6 +185,78 @@ func TestHandleConvActionsMenuCopyCopiesSelectedBlock(t *testing.T) { } } +func TestHandleSessionPreviewActionsMenuCopyCopiesPreviewMessage(t *testing.T) { + entries := testEntries() + app := newTestApp(fakeSessions()) + app.sessSplit.Show = true + app.sessSplit.Focus = true + app.sessPreviewMode = sessPreviewConversation + app.sessConvEntries = filterConversation(mergeConversationTurns(entries)) + app.sessConvCursor = 0 + app.keymap.Session.Actions = "x" + app.keymap.Actions.Copy = "c" + + m, _, _ := app.handleConvPreviewKeys(&app.sessSplit, "x") + app = m.(*App) + if !app.actionsMenu { + t.Fatal("expected session preview actions menu to open") + } + + m, _ = app.handleActionsMenu("c") + app = m.(*App) + if app.actionsMenu { + t.Fatal("expected actions menu to close after copy") + } + if !strings.Contains(app.copiedMsg, "Copied message") { + t.Fatalf("expected preview copy confirmation, got %q", app.copiedMsg) + } +} + +func TestHandleSessionPreviewActionsMenuIgnoresExistingMultiSelection(t *testing.T) { + entries := testEntries() + app := newTestApp(fakeSessions()) + app.sessSplit.Show = true + app.sessSplit.Focus = true + app.sessPreviewMode = sessPreviewConversation + app.sessConvEntries = filterConversation(mergeConversationTurns(entries)) + app.sessConvCursor = 0 + app.selectedSet = map[string]bool{"bbb": true} + app.keymap.Session.Actions = "x" + + m, _, _ := app.handleConvPreviewKeys(&app.sessSplit, "x") + app = m.(*App) + if !app.actionsMenu { + t.Fatal("expected session preview actions menu to open") + } + + hint := stripANSI(app.renderActionsHintBox()) + if strings.Contains(hint, "selected") { + t.Fatalf("expected preview actions menu, got bulk hint %q", hint) + } + if !strings.Contains(hint, "copy-path") { + t.Fatalf("expected single-session action hint, got %q", hint) + } +} + +func TestHandleBulkActionsMenuCopyCopiesSelectedSessionPaths(t *testing.T) { + sessions := fakeSessions() + sessions[0].FilePath = "/tmp/a.jsonl" + sessions[1].FilePath = "/tmp/b.jsonl" + app := newTestApp(sessions) + app.selectedSet = map[string]bool{"aaa": true, "bbb": true} + app.actionsMenu = true + app.keymap.Actions.Copy = "c" + + m, _ := app.handleActionsMenu("c") + app = m.(*App) + if app.actionsMenu { + t.Fatal("expected bulk actions menu to close after copy") + } + if !strings.Contains(app.copiedMsg, "Copied 2 session paths") { + t.Fatalf("expected bulk copy confirmation, got %q", app.copiedMsg) + } +} + func TestRefreshConversationPreservesFoldSelection(t *testing.T) { entries := testEntries() app := setupConvApp(t, entries, 120, 30) diff --git a/internal/tui/session_keybindings_test.go b/internal/tui/session_keybindings_test.go index a2da5a6..31f0d43 100644 --- a/internal/tui/session_keybindings_test.go +++ b/internal/tui/session_keybindings_test.go @@ -71,6 +71,19 @@ func TestSessionsTabStillCyclesGroupMode(t *testing.T) { } } +func TestSessionsSpaceDoesNotSelectWhenPreviewFocused(t *testing.T) { + app := newSessionKeybindingApp() + app.sessSplit.Show = true + app.sessSplit.Focus = true + app.sessPreviewMode = sessPreviewConversation + + m, _ := app.handleSessionKeys(tea.KeyMsg{Type: tea.KeySpace}) + app = m.(*App) + if app.hasMultiSelection() { + t.Fatalf("space in focused preview should not multi-select session, got %v", app.selectedSet) + } +} + func TestSessionsHelpShowsNavigationAndTabGrouping(t *testing.T) { app := newSessionKeybindingApp() app.sessSplit.Show = false From 4a5dedb291c7e955a2890e7d71985792719a25dc Mon Sep 17 00:00:00 2001 From: Gavin Jeong Date: Mon, 11 May 2026 09:32:35 +0900 Subject: [PATCH 3/3] feat: pin current-window sessions and add shells preview - Detect sessions whose project path matches a pane in the current tmux window and surface them under a "Current Window" header. - Track background Bash and Monitor tool invocations as ShellJobs and add a "shells" preview mode for inspecting them. - Add filter token "is:here" for current-window sessions. --- internal/session/constants.go | 54 +++---- internal/session/filter.go | 3 + internal/session/models.go | 92 +++++++----- internal/session/scanner_stream.go | 7 + internal/session/shells.go | 123 +++++++++++++++ internal/session/shells_test.go | 78 ++++++++++ internal/tmux/live.go | 34 ++++- internal/tmux/pane.go | 14 ++ internal/tui/app.go | 223 ++++++++++++++++++++++++++-- internal/tui/current_window_test.go | 160 ++++++++++++++++++++ internal/tui/diff.go | 75 +++++++++- internal/tui/live_preview_test.go | 1 + internal/tui/sessions.go | 181 ++++++++++++++++++++-- internal/tui/shells_test.go | 90 +++++++++++ internal/tui/stats_load_test.go | 49 ++++++ internal/tui/styles.go | 20 +-- 16 files changed, 1102 insertions(+), 102 deletions(-) create mode 100644 internal/session/shells.go create mode 100644 internal/session/shells_test.go create mode 100644 internal/tui/current_window_test.go create mode 100644 internal/tui/shells_test.go create mode 100644 internal/tui/stats_load_test.go diff --git a/internal/session/constants.go b/internal/session/constants.go index 181c9f6..7308e73 100644 --- a/internal/session/constants.go +++ b/internal/session/constants.go @@ -42,42 +42,46 @@ var ( bTaskCreateS = []byte(`"name": "TaskCreate"`) bCronCreate = []byte(`"name":"CronCreate"`) bCronCreateS = []byte(`"name": "CronCreate"`) + bMonitorTool = []byte(`"name":"Monitor"`) + bMonitorToolS = []byte(`"name": "Monitor"`) + bRunInBackground = []byte(`"run_in_background":true`) + bRunInBackgroundS = []byte(`"run_in_background": true`) // Fork detection markers bForkedFrom = []byte(`"forkedFrom"`) // Stats-specific markers - bUsage = []byte(`"usage":{`) - bUsageS = []byte(`"usage": {`) - bToolUse = []byte(`"type":"tool_use"`) - bToolUseS = []byte(`"type": "tool_use"`) - bIsErrorT = []byte(`"is_error":true`) - bIsErrorTS = []byte(`"is_error": true`) - bToolRes = []byte(`"type":"tool_result"`) - bToolResS = []byte(`"type": "tool_result"`) - bNameQ = []byte(`"name":"`) - bNameQS = []byte(`"name": "`) - bFilePathQ = []byte(`"file_path":"`) - bFilePathS = []byte(`"file_path": "`) - bModelQ = []byte(`"model":"`) - bModelQS = []byte(`"model": "`) - bSkillQ = []byte(`"skill":"`) - bSkillQS = []byte(`"skill": "`) + bUsage = []byte(`"usage":{`) + bUsageS = []byte(`"usage": {`) + bToolUse = []byte(`"type":"tool_use"`) + bToolUseS = []byte(`"type": "tool_use"`) + bIsErrorT = []byte(`"is_error":true`) + bIsErrorTS = []byte(`"is_error": true`) + bToolRes = []byte(`"type":"tool_result"`) + bToolResS = []byte(`"type": "tool_result"`) + bNameQ = []byte(`"name":"`) + bNameQS = []byte(`"name": "`) + bFilePathQ = []byte(`"file_path":"`) + bFilePathS = []byte(`"file_path": "`) + bModelQ = []byte(`"model":"`) + bModelQS = []byte(`"model": "`) + bSkillQ = []byte(`"skill":"`) + bSkillQS = []byte(`"skill": "`) bSubagentQ = []byte(`"subagent_type":"`) bSubagentQS = []byte(`"subagent_type": "`) bCmdTag = []byte(``) bCmdTagEnd = []byte(``) - bIDCol = []byte(`"id":"`) - bIDColS = []byte(`"id": "`) - bTUIDCol = []byte(`"tool_use_id":"`) - bTUIDColS = []byte(`"tool_use_id": "`) + bIDCol = []byte(`"id":"`) + bIDColS = []byte(`"id": "`) + bTUIDCol = []byte(`"tool_use_id":"`) + bTUIDColS = []byte(`"tool_use_id": "`) // Hook markers (inside progress lines) - bHookProgress = []byte(`"hook_progress"`) - bHookEvent = []byte(`"hookEvent":"`) - bHookEventS = []byte(`"hookEvent": "`) - bHookCommand = []byte(`"command":"`) - bHookCommandS = []byte(`"command": "`) + bHookProgress = []byte(`"hook_progress"`) + bHookEvent = []byte(`"hookEvent":"`) + bHookEventS = []byte(`"hookEvent": "`) + bHookCommand = []byte(`"command":"`) + bHookCommandS = []byte(`"command": "`) // Path decode cache decodedPathCache sync.Map // dirName → decoded path (string, "" if unresolvable) diff --git a/internal/session/filter.go b/internal/session/filter.go index 7afa24b..a927aef 100644 --- a/internal/session/filter.go +++ b/internal/session/filter.go @@ -26,6 +26,9 @@ func FilterValueFor(s Session, cwdProjectPaths []string) string { if s.TmuxWindowName != "" { parts = append(parts, "win:"+s.TmuxWindowName, s.TmuxWindowName) } + if s.IsCurrentWindow { + parts = append(parts, "is:here") + } if s.IsLive { parts = append(parts, "is:live") } diff --git a/internal/session/models.go b/internal/session/models.go index 87aff92..931c5d7 100644 --- a/internal/session/models.go +++ b/internal/session/models.go @@ -18,39 +18,56 @@ type TaskItem struct { } type CronItem struct { - ID string - Cron string - Prompt string - Recurring bool - Status string // active, deleted - CreatedAt time.Time - DeletedAt time.Time + ID string + Cron string + Prompt string + Recurring bool + Status string // active, deleted + CreatedAt time.Time + DeletedAt time.Time +} + +// ShellJob represents a long-running shell or monitor invocation discovered in +// the conversation: either a Bash tool call with run_in_background=true, or a +// Monitor tool call. Status reflects the latest lifecycle event we observed. +type ShellJob struct { + ID string // tool_use ID (links BashOutput/KillShell back) + ToolName string // "Bash" or "Monitor" + Command string + Description string + Persistent bool // Monitor.persistent + TimeoutMS int // Bash.timeout (ms) or Monitor.timeout_ms + DangerouslyDisableSandbox bool + StartedAt time.Time + LastEventAt time.Time + PollCount int // BashOutput calls observed against this shell + Status string // "running", "polled", "killed", "stopped" } type Session struct { - ID string - ShortID string - FilePath string - ProjectPath string - ProjectName string - GitBranch string - ModTime time.Time - MsgCount int - FirstPrompt string - Created time.Time - IsWorktree bool + ID string + ShortID string + FilePath string + ProjectPath string + ProjectName string + GitBranch string + ModTime time.Time + MsgCount int + FirstPrompt string + Created time.Time + IsWorktree bool IsLive bool IsResponding bool - HasMemory bool - HasTodos bool - Todos []TodoItem - HasTasks bool - HasCrons bool - HasPlan bool - PlanSlug string // first plan slug (kept for compat) - PlanSlugs []string // all distinct plan slugs in order - Tasks []TaskItem - Crons []CronItem + HasMemory bool + HasTodos bool + Todos []TodoItem + HasTasks bool + HasCrons bool + HasPlan bool + PlanSlug string // first plan slug (kept for compat) + PlanSlugs []string // all distinct plan slugs in order + Tasks []TaskItem + Crons []CronItem TeamName string // e.g. "supports-build" TeamRole string // "leader", "teammate", "" TeammateName string // e.g. "build-deploy" (teammate only) @@ -61,10 +78,13 @@ type Session struct { HasCompaction bool HasSkills bool HasMCP bool + HasShellJobs bool // background Bash or Monitor invocations present + ShellJobs []ShellJob // populated lazily when HasShellJobs is true CustomBadges []string // user-created badge tags - TmuxWindowName string // tmux window name (set if pane CWD matches ProjectPath) + TmuxWindowName string // tmux window name (set if pane CWD matches ProjectPath) + IsCurrentWindow bool // session lives in the same tmux window as ccx (pane CWD matches) // Remote execution IsRemote bool // virtual remote session @@ -97,13 +117,13 @@ type HookInfo struct { } type ContentBlock struct { - Type string - Text string - ToolName string - ToolInput string - IsError bool - ID string // tool_use block ID (e.g., "toolu_01...") - Hooks []HookInfo // hooks that ran for this tool_use block + Type string + Text string + ToolName string + ToolInput string + IsError bool + ID string // tool_use block ID (e.g., "toolu_01...") + Hooks []HookInfo // hooks that ran for this tool_use block TagName string // for system_tag blocks: the XML tag name (e.g., "system-reminder") ImagePasteID int // for image blocks: the paste ID for cache lookup (0 = not set) } diff --git a/internal/session/scanner_stream.go b/internal/session/scanner_stream.go index e58f1cf..f0fe67f 100644 --- a/internal/session/scanner_stream.go +++ b/internal/session/scanner_stream.go @@ -134,6 +134,13 @@ func scanSessionStream(path string, modTime time.Time, home string, badgeStore * sess.HasCrons = true } } + if !sess.HasShellJobs { + if bytes.Contains(line, bMonitorTool) || bytes.Contains(line, bMonitorToolS) { + sess.HasShellJobs = true + } else if bytes.Contains(line, bRunInBackground) || bytes.Contains(line, bRunInBackgroundS) { + sess.HasShellJobs = true + } + } // Team detection (check any line for teamName/agentName) if sess.TeamName == "" { diff --git a/internal/session/shells.go b/internal/session/shells.go new file mode 100644 index 0000000..d8e3c8d --- /dev/null +++ b/internal/session/shells.go @@ -0,0 +1,123 @@ +package session + +import ( + "encoding/json" +) + +type shellInputBash struct { + Command string `json:"command"` + Description string `json:"description"` + RunInBackground bool `json:"run_in_background"` + Timeout json.Number `json:"timeout"` + DangerouslyDisableSandbox bool `json:"dangerouslyDisableSandbox"` +} + +type shellInputMonitor struct { + Command string `json:"command"` + Description string `json:"description"` + Persistent bool `json:"persistent"` + TimeoutMS json.Number `json:"timeout_ms"` + DangerouslyDisableSandbox bool `json:"dangerouslyDisableSandbox"` +} + +type shellInputResult struct { + ToolUseID string `json:"tool_use_id"` +} + +// LoadShellJobsFromEntries scans parsed entries for background Bash and Monitor +// tool invocations. It correlates BashOutput/KillShell calls (which carry a +// tool_use_id) back to the originating shell so we can show how many polls +// happened and whether the shell was explicitly killed. +// +// Entries are assumed to be in chronological order, matching how the JSONL is +// stored on disk. +func LoadShellJobsFromEntries(entries []Entry) []ShellJob { + var jobs []ShellJob + byID := make(map[string]int) // tool_use ID → index in jobs + + for _, e := range entries { + for _, b := range e.Content { + if b.Type != "tool_use" { + continue + } + switch b.ToolName { + case "Bash": + var in shellInputBash + if err := json.Unmarshal([]byte(b.ToolInput), &in); err != nil { + continue + } + if !in.RunInBackground { + continue + } + timeout, _ := in.Timeout.Int64() + job := ShellJob{ + ID: b.ID, + ToolName: "Bash", + Command: in.Command, + Description: in.Description, + TimeoutMS: int(timeout), + DangerouslyDisableSandbox: in.DangerouslyDisableSandbox, + StartedAt: e.Timestamp, + LastEventAt: e.Timestamp, + Status: "running", + } + if b.ID != "" { + byID[b.ID] = len(jobs) + } + jobs = append(jobs, job) + + case "Monitor": + var in shellInputMonitor + if err := json.Unmarshal([]byte(b.ToolInput), &in); err != nil { + continue + } + timeout, _ := in.TimeoutMS.Int64() + job := ShellJob{ + ID: b.ID, + ToolName: "Monitor", + Command: in.Command, + Description: in.Description, + Persistent: in.Persistent, + TimeoutMS: int(timeout), + DangerouslyDisableSandbox: in.DangerouslyDisableSandbox, + StartedAt: e.Timestamp, + LastEventAt: e.Timestamp, + Status: "running", + } + if b.ID != "" { + byID[b.ID] = len(jobs) + } + jobs = append(jobs, job) + + case "BashOutput": + var in shellInputResult + if err := json.Unmarshal([]byte(b.ToolInput), &in); err != nil { + continue + } + if idx, ok := byID[in.ToolUseID]; ok { + jobs[idx].PollCount++ + if !e.Timestamp.IsZero() { + jobs[idx].LastEventAt = e.Timestamp + } + if jobs[idx].Status == "running" { + jobs[idx].Status = "polled" + } + } + + case "KillShell": + var in shellInputResult + if err := json.Unmarshal([]byte(b.ToolInput), &in); err != nil { + continue + } + if idx, ok := byID[in.ToolUseID]; ok { + if !e.Timestamp.IsZero() { + jobs[idx].LastEventAt = e.Timestamp + } + jobs[idx].Status = "killed" + } + } + } + } + + return jobs +} diff --git a/internal/session/shells_test.go b/internal/session/shells_test.go new file mode 100644 index 0000000..7e2fbbb --- /dev/null +++ b/internal/session/shells_test.go @@ -0,0 +1,78 @@ +package session + +import ( + "testing" + "time" +) + +func TestLoadShellJobsFromEntries(t *testing.T) { + t1 := time.Date(2026, 5, 7, 10, 0, 0, 0, time.UTC) + t2 := t1.Add(2 * time.Minute) + t3 := t1.Add(5 * time.Minute) + t4 := t1.Add(7 * time.Minute) + t5 := t1.Add(8 * time.Minute) + + entries := []Entry{ + {Timestamp: t1, Role: "assistant", Content: []ContentBlock{{ + Type: "tool_use", + ToolName: "Bash", + ID: "toolu_bash_1", + ToolInput: `{"command":"npm run build","description":"build app","run_in_background":true,"timeout":120000}`, + }}}, + {Timestamp: t2, Role: "assistant", Content: []ContentBlock{{ + Type: "tool_use", + ToolName: "Bash", + ID: "toolu_bash_fg", + ToolInput: `{"command":"ls","description":"list","run_in_background":false}`, + }}}, + {Timestamp: t3, Role: "assistant", Content: []ContentBlock{{ + Type: "tool_use", + ToolName: "Monitor", + ID: "toolu_mon_1", + ToolInput: `{"command":"while true; do echo .; sleep 60; done","description":"poll secrets","persistent":true,"timeout_ms":300000}`, + }}}, + {Timestamp: t4, Role: "assistant", Content: []ContentBlock{{ + Type: "tool_use", + ToolName: "BashOutput", + ToolInput: `{"tool_use_id":"toolu_bash_1"}`, + }}}, + {Timestamp: t5, Role: "assistant", Content: []ContentBlock{{ + Type: "tool_use", + ToolName: "KillShell", + ToolInput: `{"tool_use_id":"toolu_bash_1"}`, + }}}, + } + + jobs := LoadShellJobsFromEntries(entries) + if len(jobs) != 2 { + t.Fatalf("expected 2 shell jobs, got %d (%v)", len(jobs), jobs) + } + + bash := jobs[0] + if bash.ToolName != "Bash" || bash.Command != "npm run build" || bash.Description != "build app" { + t.Fatalf("bash job mismatch: %+v", bash) + } + if bash.TimeoutMS != 120000 { + t.Errorf("bash timeout: got %d, want 120000", bash.TimeoutMS) + } + if bash.PollCount != 1 { + t.Errorf("bash poll count: got %d, want 1", bash.PollCount) + } + if bash.Status != "killed" { + t.Errorf("bash status: got %q, want killed", bash.Status) + } + if !bash.LastEventAt.Equal(t5) { + t.Errorf("bash last event: got %v, want %v", bash.LastEventAt, t5) + } + + mon := jobs[1] + if mon.ToolName != "Monitor" || !mon.Persistent { + t.Fatalf("monitor job mismatch: %+v", mon) + } + if mon.Status != "running" { + t.Errorf("monitor status: got %q, want running", mon.Status) + } + if mon.TimeoutMS != 300000 { + t.Errorf("monitor timeout: got %d, want 300000", mon.TimeoutMS) + } +} diff --git a/internal/tmux/live.go b/internal/tmux/live.go index 8d7b167..d60cbaa 100644 --- a/internal/tmux/live.go +++ b/internal/tmux/live.go @@ -38,21 +38,33 @@ func markLiveSessionsTmux(sessions []session.Session) { return } + currentKey := CurrentWindowKey() // "session|window" for the current ccx pane + // Group session indices by ProjectPath pathIdx := map[string][]int{} for i, s := range sessions { pathIdx[s.ProjectPath] = append(pathIdx[s.ProjectPath], i) } - // Set TmuxWindowName for ALL sessions by matching ProjectPath to pane CWD + // Set TmuxWindowName for ALL sessions by matching ProjectPath to pane CWD. + // Track which absolute paths appear in the current tmux window so even + // sessions without a live process can be pinned when their project path + // matches a pane in this window (e.g. shell-only panes). pathWindow := make(map[string]string, len(panes)) + currentWindowPaths := make(map[string]bool) for _, p := range panes { absPath, _ := filepath.Abs(p.Path) - if absPath != "" && p.WindowName != "" { + if absPath == "" { + continue + } + if p.WindowName != "" { if _, exists := pathWindow[absPath]; !exists { pathWindow[absPath] = p.WindowName } } + if currentKey != "" && p.Session+"|"+p.Window == currentKey { + currentWindowPaths[absPath] = true + } } for i := range sessions { if wn, ok := pathWindow[sessions[i].ProjectPath]; ok && sessions[i].TmuxWindowName == "" { @@ -83,16 +95,18 @@ func markLiveSessionsTmux(sessions []session.Session) { // Build pane PID → claude args map (direct children) type claudeMatch struct { - args string - windowName string - path string + args string + windowName string + path string + currentWindow bool } var cps []claudeMatch for _, p := range panes { if args, ok := directByPPID[p.PID]; ok { absPath, _ := filepath.Abs(p.Path) if absPath != "" { - cps = append(cps, claudeMatch{args: args, windowName: p.WindowName, path: absPath}) + inCur := currentKey != "" && p.Session+"|"+p.Window == currentKey + cps = append(cps, claudeMatch{args: args, windowName: p.WindowName, path: absPath, currentWindow: inCur}) } } } @@ -101,7 +115,7 @@ func markLiveSessionsTmux(sessions []session.Session) { if len(orphaned) > 0 { orphanCwds := resolveOrphanCwds(orphaned) for _, oc := range orphanCwds { - cps = append(cps, claudeMatch{args: oc.args, path: oc.cwd}) + cps = append(cps, claudeMatch{args: oc.args, path: oc.cwd, currentWindow: currentWindowPaths[oc.cwd]}) } } @@ -114,6 +128,9 @@ func markLiveSessionsTmux(sessions []session.Session) { if strings.Contains(cp.args, sessions[si].ID) { sessions[si].IsLive = true sessions[si].TmuxWindowName = cp.windowName + if cp.currentWindow { + sessions[si].IsCurrentWindow = true + } matched[ci] = true break } @@ -137,6 +154,9 @@ func markLiveSessionsTmux(sessions []session.Session) { if bestIdx >= 0 { sessions[bestIdx].IsLive = true sessions[bestIdx].TmuxWindowName = cp.windowName + if cp.currentWindow { + sessions[bestIdx].IsCurrentWindow = true + } } } } diff --git a/internal/tmux/pane.go b/internal/tmux/pane.go index cc3f4f5..daa73a5 100644 --- a/internal/tmux/pane.go +++ b/internal/tmux/pane.go @@ -362,6 +362,20 @@ func CurrentWindowClaudes() []string { return paths } +// CurrentWindowKey returns "session_name|window_index" for the current tmux window, +// or "" when not running inside tmux. +func CurrentWindowKey() string { + if !InTmux() { + return "" + } + out, err := exec.Command("tmux", "display-message", "-p", + "#{session_name}|#{window_index}").Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + // MoveWithAndSwitchPane moves the current pane (CSB) to the target's tmux window // as a side-by-side split, then focuses the target pane. func MoveWithAndSwitchPane(target Pane) error { diff --git a/internal/tui/app.go b/internal/tui/app.go index bc3cefc..cc20d1c 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -280,6 +280,8 @@ type App struct { sessMemoryCacheKey string sessTasksCache string sessTasksCacheKey string + sessShellsCache string + sessShellsCacheKey string sessPreviewAgents []session.Subagent // agents shown in Tasks/Plan preview sessAgentCursor int // cursor within agents list @@ -552,9 +554,10 @@ const ( sessPreviewMemory sessPreviewTasksPlan sessPreviewAgents + sessPreviewShells sessPreviewLive // tmux pane capture sessPreviewRemote // remote session status/stream - numSessPreviewModes = 7 + numSessPreviewModes = 8 ) // Config holds application configuration from CLI flags. @@ -632,7 +635,7 @@ func NewApp(sessions []session.Session, cfg Config) *App { } } if a.config.PreviewMode != "" { - modeMap := map[string]sessPreview{"conv": sessPreviewConversation, "stats": sessPreviewStats, "mem": sessPreviewMemory, "tasks": sessPreviewTasksPlan, "agents": sessPreviewAgents, "live": sessPreviewLive} + modeMap := map[string]sessPreview{"conv": sessPreviewConversation, "stats": sessPreviewStats, "mem": sessPreviewMemory, "tasks": sessPreviewTasksPlan, "agents": sessPreviewAgents, "shells": sessPreviewShells, "live": sessPreviewLive} if m, ok := modeMap[a.config.PreviewMode]; ok { a.sessPreviewMode = m a.sessSplit.Show = true @@ -912,6 +915,7 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } } + a.bumpPastHeader(0, +1) return a, a.autoSelectSession() } return a, nil @@ -920,11 +924,15 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { stats := session.GlobalStats(msg) a.globalStatsCache = &stats a.globalStatsLoading = false - // Switch to global stats view now that data is ready + // Only render/switch into the stats view if the user is still there. + // If they navigated away while stats were loading, just cache the + // result and stay where they are. + if a.state != viewGlobalStats { + return a, nil + } contentH := a.height - 3 a.globalStatsVP = viewport.New(a.width, contentH) a.globalStatsVP.SetContent(renderGlobalStats(stats, a.width)) - a.state = viewGlobalStats return a, nil case searchBatchMsg: @@ -1615,6 +1623,7 @@ func (a *App) handleSessionKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if a.sessPendingG { a.sessPendingG = false a.sessionList.Select(0) + a.bumpPastHeader(0, +1) return a, a.schedulePreviewUpdate() } a.sessPendingG = true @@ -1624,11 +1633,13 @@ func (a *App) handleSessionKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { items := a.sessionList.VisibleItems() if len(items) > 0 { a.sessionList.Select(len(items) - 1) + a.bumpPastHeader(len(items)-1, -1) } return a, a.schedulePreviewUpdate() case "home": a.sessPendingG = false a.sessionList.Select(0) + a.bumpPastHeader(0, +1) return a, a.schedulePreviewUpdate() } } @@ -1671,6 +1682,14 @@ func (a *App) handleSessionKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { oldIdx := a.sessionList.Index() m, cmd := a.updateSessionList(msg) newIdx := a.sessionList.Index() + // If the cursor landed on a section header (e.g. after Up/Down jumped + // across the "Sessions" divider), skip to the next session item. + if newIdx != oldIdx { + a.skipHeaderInDirection(oldIdx, newIdx) + newIdx = a.sessionList.Index() + } else { + a.skipHeaderInDirection(oldIdx, newIdx) + } if sp.Show && oldIdx == newIdx { switch key { case "down", "up", "pgdown", "pgup": @@ -1682,6 +1701,44 @@ func (a *App) handleSessionKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmd, debounceCmd) } +// skipHeaderInDirection moves the list cursor past header items in the same +// direction the user was navigating (down if newIdx >= oldIdx, otherwise up). +// At the boundaries it bumps the other way so we never get stuck on a header. +func (a *App) skipHeaderInDirection(oldIdx, newIdx int) { + visible := a.sessionList.VisibleItems() + if len(visible) == 0 { + return + } + cur := a.sessionList.Index() + if cur < 0 || cur >= len(visible) { + return + } + if _, ok := visible[cur].(sessionItem); ok { + return + } + dir := 1 + if newIdx < oldIdx { + dir = -1 + } + idx := cur + dir + for idx >= 0 && idx < len(visible) { + if _, ok := visible[idx].(sessionItem); ok { + a.sessionList.Select(idx) + return + } + idx += dir + } + // Reverse direction if we hit a boundary on a header. + idx = cur - dir + for idx >= 0 && idx < len(visible) { + if _, ok := visible[idx].(sessionItem); ok { + a.sessionList.Select(idx) + return + } + idx -= dir + } +} + // handlePaneProxyKey forwards a key to the tmux pane and captures the result. // Uses captureAfterKeyCmd to send key + capture in one Cmd (no polling needed). func (a *App) handlePaneProxyKey(key string) (tea.Model, tea.Cmd) { @@ -3935,6 +3992,8 @@ func (a *App) updateSessionPreview() tea.Cmd { a.updateSessionTasksPlanPreview(sess) case sessPreviewAgents: a.updateSessionAgentsPreview(sess) + case sessPreviewShells: + a.updateSessionShellsPreview(sess) case sessPreviewLive: if sess.IsLive { a.sessSplit.Preview.SetContent(dimStyle.Render("(connecting…)")) @@ -4440,6 +4499,120 @@ func (a *App) updateSessionAgentsPreview(sess session.Session) { a.sessSplit.Preview.SetContent(a.buildAgentsPreviewContent(sess)) } +func (a *App) updateSessionShellsPreview(sess session.Session) { + if a.sessShellsCacheKey != sess.ID { + a.sessShellsCache = a.buildShellsPreviewContent(sess) + a.sessShellsCacheKey = sess.ID + } + previewW := max(a.width-a.sessSplit.ListWidth(a.width, a.splitRatio)-1, 1) + contentH := max(a.height-3, 1) + a.sessSplit.Preview = viewport.New(previewW, contentH) + a.sessSplit.Preview.SetContent(a.sessShellsCache) +} + +func (a *App) buildShellsPreviewContent(sess session.Session) string { + if !sess.HasShellJobs { + return dimStyle.Render("No background shells or monitors found for this session.") + } + jobs := sess.ShellJobs + if len(jobs) == 0 { + entries, err := session.LoadMessages(sess.FilePath) + if err != nil { + return dimStyle.Render("Failed to load session: " + err.Error()) + } + jobs = session.LoadShellJobsFromEntries(entries) + } + if len(jobs) == 0 { + return dimStyle.Render("No background shells or monitors found for this session.") + } + + bashCount, monCount, killed, polled := 0, 0, 0, 0 + for _, j := range jobs { + switch j.ToolName { + case "Bash": + bashCount++ + case "Monitor": + monCount++ + } + switch j.Status { + case "killed", "stopped": + killed++ + case "polled": + polled++ + } + } + + var sb strings.Builder + header := fmt.Sprintf("── Background shells [%d Bash, %d Monitor", bashCount, monCount) + if polled > 0 { + header += fmt.Sprintf(", %d polled", polled) + } + if killed > 0 { + header += fmt.Sprintf(", %d killed", killed) + } + header += "] ──" + sb.WriteString(dimStyle.Render(header) + "\n\n") + + bashStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FBBF24")).Bold(true) + monStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#22D3EE")).Bold(true) + + for _, j := range jobs { + icon, statusColor := "◉", colorAssistant + switch j.Status { + case "polled": + icon = "◑" + statusColor = colorAccent + case "killed", "stopped": + icon = "⏹" + statusColor = colorDim + } + statusStyle := lipgloss.NewStyle().Foreground(statusColor).Bold(true) + + toolLabel := bashStyle.Render("Bash") + tag := "" + if j.ToolName == "Monitor" { + toolLabel = monStyle.Render("Monitor") + if j.Persistent { + tag = dimStyle.Render(" [persistent]") + } + } else { + tag = dimStyle.Render(" [bg]") + } + + headline := fmt.Sprintf("%s %s%s %s", statusStyle.Render(icon), toolLabel, tag, statusStyle.Render(j.Status)) + if j.PollCount > 0 { + headline += dimStyle.Render(fmt.Sprintf(" (%d polls)", j.PollCount)) + } + if j.TimeoutMS > 0 { + headline += dimStyle.Render(fmt.Sprintf(" timeout=%dms", j.TimeoutMS)) + } + sb.WriteString(headline + "\n") + + if j.Description != "" { + sb.WriteString(dimStyle.Render(" # "+j.Description) + "\n") + } + cmd := j.Command + if cmd == "" { + cmd = "(empty command)" + } + for _, line := range splitLines(cmd) { + if len(line) > 110 { + line = line[:107] + "..." + } + sb.WriteString(bashCmdStyle.Render(" $ "+line) + "\n") + } + if !j.StartedAt.IsZero() { + sb.WriteString(dimStyle.Render(" started: "+timeAgo(j.StartedAt)) + "\n") + } + if !j.LastEventAt.IsZero() && !j.LastEventAt.Equal(j.StartedAt) { + sb.WriteString(dimStyle.Render(" last: "+timeAgo(j.LastEventAt)) + "\n") + } + sb.WriteString("\n") + } + + return sb.String() +} + func (a *App) buildTasksPlanContent(sess session.Session) string { home, _ := os.UserHomeDir() var sb strings.Builder @@ -5169,13 +5342,18 @@ func (a *App) updateActiveComponent(msg tea.Msg) (tea.Model, tea.Cmd) { // one (sessions are sorted by ModTime descending, so first match wins). // If the matched session is live, auto-enters it with live tail enabled. func (a *App) autoSelectSession() tea.Cmd { + visible := a.sessionList.VisibleItems() for _, projPath := range tmux.CurrentWindowClaudes() { absProj, _ := filepath.Abs(projPath) if absProj == "" { absProj = projPath } - for i, s := range a.sessions { - sp := s.ProjectPath + for i, item := range visible { + si, ok := item.(sessionItem) + if !ok { + continue + } + sp := si.sess.ProjectPath absSP, _ := filepath.Abs(sp) if absSP == "" { absSP = sp @@ -5183,17 +5361,40 @@ func (a *App) autoSelectSession() tea.Cmd { if absSP == absProj { a.sessionList.Select(i) // Auto-enter live sessions (only if TmuxAutoLive is enabled) - if s.IsLive && a.config.TmuxAutoLive { - a.currentSess = s - return a.openConversation(s) + if si.sess.IsLive && a.config.TmuxAutoLive { + a.currentSess = si.sess + return a.openConversation(si.sess) } return nil } } } + // Fallback: ensure cursor isn't parked on a header. + a.bumpPastHeader(0, +1) return nil } +// bumpPastHeader moves the list cursor in `dir` until it lands on a session +// item (or hits the boundary). When called with start>=0 it Selects start +// first. +func (a *App) bumpPastHeader(start, dir int) { + visible := a.sessionList.VisibleItems() + if len(visible) == 0 { + return + } + if start < 0 || start >= len(visible) { + start = a.sessionList.Index() + } + idx := start + for idx >= 0 && idx < len(visible) { + if _, ok := visible[idx].(sessionItem); ok { + a.sessionList.Select(idx) + return + } + idx += dir + } +} + func (a *App) resizeAll() tea.Cmd { contentH := a.height - 3 var cmd tea.Cmd @@ -5323,10 +5524,12 @@ func (a *App) rebuildSessionList() { for i, item := range a.sessionList.VisibleItems() { if si, ok := item.(sessionItem); ok && si.sess.ID == selectedID { a.sessionList.Select(i) - break + return } } } + // Default: ensure cursor isn't parked on a header. + a.bumpPastHeader(0, +1) } func (a *App) listReady(l *list.Model) bool { diff --git a/internal/tui/current_window_test.go b/internal/tui/current_window_test.go new file mode 100644 index 0000000..b720e99 --- /dev/null +++ b/internal/tui/current_window_test.go @@ -0,0 +1,160 @@ +package tui + +import ( + "testing" + "time" + + "github.com/charmbracelet/bubbles/list" + "github.com/sendbird/ccx/internal/session" +) + +func TestBuildGroupedItems_PinsCurrentWindowSessions(t *testing.T) { + now := time.Now() + sessions := []session.Session{ + {ID: "s1", ShortID: "s1", ProjectPath: "/tmp/a", ProjectName: "a", ModTime: now.Add(-10 * time.Minute)}, + {ID: "s2", ShortID: "s2", ProjectPath: "/tmp/b", ProjectName: "b", ModTime: now.Add(-5 * time.Minute), IsCurrentWindow: true}, + {ID: "s3", ShortID: "s3", ProjectPath: "/tmp/c", ProjectName: "c", ModTime: now.Add(-1 * time.Minute)}, + {ID: "s4", ShortID: "s4", ProjectPath: "/tmp/d", ProjectName: "d", ModTime: now.Add(-30 * time.Minute), IsCurrentWindow: true}, + } + + items := buildGroupedItems(sessions, groupFlat) + if len(items) < 5 { + t.Fatalf("expected at least 5 items (2 headers + 4 sessions), got %d", len(items)) + } + + // Item 0: "Current Window" header. + h0, ok := items[0].(headerItem) + if !ok || h0.label != "Current Window" { + t.Fatalf("expected first item to be Current Window header, got %T %v", items[0], items[0]) + } + + // Item 1 + 2: current-window sessions (most recent first → s2 then s4). + si1, ok := items[1].(sessionItem) + if !ok || si1.sess.ID != "s2" { + t.Fatalf("expected items[1]=s2, got %v", items[1]) + } + si2, ok := items[2].(sessionItem) + if !ok || si2.sess.ID != "s4" { + t.Fatalf("expected items[2]=s4, got %v", items[2]) + } + + // Then the "Sessions" header divider. + h1, ok := items[3].(headerItem) + if !ok || h1.label != "Sessions" { + t.Fatalf("expected items[3]=Sessions header, got %T %v", items[3], items[3]) + } + + // Then the rest of the sessions in their normal order. + rest1, _ := items[4].(sessionItem) + rest2, _ := items[5].(sessionItem) + if rest1.sess.ID != "s1" || rest2.sess.ID != "s3" { + t.Fatalf("expected rest items s1,s3 got %s,%s", rest1.sess.ID, rest2.sess.ID) + } +} + +func TestBuildGroupedItems_NoCurrentWindow_NoHeader(t *testing.T) { + sessions := []session.Session{ + {ID: "s1", ShortID: "s1", ProjectPath: "/tmp/a", ProjectName: "a"}, + {ID: "s2", ShortID: "s2", ProjectPath: "/tmp/b", ProjectName: "b"}, + } + items := buildGroupedItems(sessions, groupFlat) + for _, it := range items { + if _, ok := it.(headerItem); ok { + t.Fatalf("did not expect a header when no current-window sessions present") + } + } + if len(items) != 2 { + t.Fatalf("expected 2 items, got %d", len(items)) + } +} + +func TestPinCurrentWindowFilter_AlwaysIncludesCurrent(t *testing.T) { + sessions := []session.Session{ + {ID: "s1", ProjectPath: "/tmp/match", ProjectName: "match"}, + {ID: "s2", ProjectPath: "/tmp/here", ProjectName: "here", IsCurrentWindow: true}, + } + items := buildGroupedItems(sessions, groupFlat) + targets := make([]string, len(items)) + for i, it := range items { + targets[i] = it.FilterValue() + } + + filter := wrapPinCurrentWindow(items, substringFilter) + ranks := filter("nomatch", targets) + + // "Current Window" header + the pinned session must be present even if + // neither matches "nomatch". + if len(ranks) < 2 { + t.Fatalf("expected at least 2 ranks for header + pinned session, got %d", len(ranks)) + } + gotIDs := map[string]bool{} + for _, r := range ranks { + switch v := items[r.Index].(type) { + case headerItem: + gotIDs["header:"+v.label] = true + case sessionItem: + gotIDs[v.sess.ID] = true + } + } + if !gotIDs["header:Current Window"] { + t.Fatalf("expected Current Window header to be pinned in filtered ranks") + } + if !gotIDs["s2"] { + t.Fatalf("expected pinned current-window session s2 to be visible under filter") + } + if gotIDs["s1"] { + t.Fatalf("did not expect non-matching s1 to leak through filter") + } + if gotIDs["header:Sessions"] { + t.Fatalf("expected trailing Sessions header to be trimmed when no rest items match") + } +} + +func TestPinCurrentWindowFilter_KeepsMatchedRest(t *testing.T) { + sessions := []session.Session{ + {ID: "s1", ProjectPath: "/tmp/match", ProjectName: "alpha-match"}, + {ID: "s2", ProjectPath: "/tmp/here", ProjectName: "here-proj", IsCurrentWindow: true}, + } + items := buildGroupedItems(sessions, groupFlat) + targets := make([]string, len(items)) + for i, it := range items { + targets[i] = it.FilterValue() + } + + filter := wrapPinCurrentWindow(items, substringFilter) + ranks := filter("alpha", targets) + + got := map[string]bool{} + for _, r := range ranks { + switch v := items[r.Index].(type) { + case headerItem: + got["header:"+v.label] = true + case sessionItem: + got[v.sess.ID] = true + } + } + if !got["s1"] { + t.Fatalf("expected matched session s1 to be visible") + } + if !got["s2"] { + t.Fatalf("expected pinned current-window session s2 to remain visible") + } + if !got["header:Current Window"] || !got["header:Sessions"] { + t.Fatalf("expected both section headers when both sections have items: %v", got) + } +} + +// Sanity: header items declare their FilterValue as the header sentinel so +// they are never produced by substringFilter on user input. +func TestHeaderItem_FilterValueDoesNotMatchUserTerms(t *testing.T) { + h := headerItem{label: "Current Window"} + targets := []string{h.FilterValue(), "regular"} + ranks := substringFilter("Current", targets) + for _, r := range ranks { + if r.Index == 0 { + t.Fatalf("expected header sentinel to not match user term Current") + } + } + // Just to confirm filter does work for the regular value. + _ = list.Rank{} +} diff --git a/internal/tui/diff.go b/internal/tui/diff.go index 0a2c951..f19028c 100644 --- a/internal/tui/diff.go +++ b/internal/tui/diff.go @@ -208,12 +208,14 @@ func isWriteTool(name string) bool { // --- Bash pretty-print --- type bashInput struct { - Command string `json:"command"` - Description string `json:"description"` - Timeout int `json:"timeout"` + Command string `json:"command"` + Description string `json:"description"` + Timeout int `json:"timeout"` + RunInBackground bool `json:"run_in_background"` } var bashCmdStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FBBF24")).Bold(true) // yellow +var bashBgBadge = lipgloss.NewStyle().Foreground(lipgloss.Color("#22D3EE")).Bold(true) func formatBashFolded(toolInput string) string { var b bashInput @@ -227,6 +229,9 @@ func formatBashFolded(toolInput string) string { // Replace newlines with semicolons for compact display cmd = strings.ReplaceAll(cmd, "\n", "; ") s := bashCmdStyle.Render("$ " + cmd) + if b.RunInBackground { + s = bashBgBadge.Render("[bg]") + " " + s + } if b.Description != "" { s = dimStyle.Render(b.Description+" ") + s } @@ -239,6 +244,9 @@ func formatBashExpanded(toolInput string, width int) string { return "" } var buf strings.Builder + if b.RunInBackground { + buf.WriteString(bashBgBadge.Render(" [background shell]") + "\n") + } if b.Description != "" { buf.WriteString(dimStyle.Render(" # "+b.Description) + "\n") } @@ -248,6 +256,61 @@ func formatBashExpanded(toolInput string, width int) string { return buf.String() } +// --- Monitor pretty-print --- + +type monitorInput struct { + Command string `json:"command"` + Description string `json:"description"` + Persistent bool `json:"persistent"` + TimeoutMS int `json:"timeout_ms"` +} + +var monitorBadgeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#22D3EE")).Bold(true) + +func formatMonitorFolded(toolInput string) string { + var m monitorInput + if json.Unmarshal([]byte(toolInput), &m) != nil || m.Command == "" { + return "" + } + tag := "[monitor]" + if m.Persistent { + tag = "[monitor·persistent]" + } + cmd := m.Command + if len(cmd) > 70 { + cmd = cmd[:67] + "..." + } + cmd = strings.ReplaceAll(cmd, "\n", "; ") + s := monitorBadgeStyle.Render(tag) + " " + bashCmdStyle.Render("$ "+cmd) + if m.Description != "" { + s = dimStyle.Render(m.Description+" ") + s + } + return s +} + +func formatMonitorExpanded(toolInput string, width int) string { + var m monitorInput + if json.Unmarshal([]byte(toolInput), &m) != nil || m.Command == "" { + return "" + } + var buf strings.Builder + tag := " [monitor]" + if m.Persistent { + tag = " [monitor · persistent]" + } + buf.WriteString(monitorBadgeStyle.Render(tag) + "\n") + if m.Description != "" { + buf.WriteString(dimStyle.Render(" # "+m.Description) + "\n") + } + for _, line := range splitLines(m.Command) { + buf.WriteString(bashCmdStyle.Render(" $ "+line) + "\n") + } + if m.TimeoutMS > 0 { + buf.WriteString(dimStyle.Render(fmt.Sprintf(" timeout: %dms", m.TimeoutMS)) + "\n") + } + return buf.String() +} + // --- Read pretty-print --- type readInput struct { @@ -289,7 +352,7 @@ func formatGrepFolded(toolInput string) string { if json.Unmarshal([]byte(toolInput), &g) != nil || g.Pattern == "" { return "" } - s := grepPatStyle.Render("/"+g.Pattern+"/") + s := grepPatStyle.Render("/" + g.Pattern + "/") if g.Path != "" { s += " " + dimStyle.Render(session.ShortenPath(g.Path, homeDir())) } @@ -358,6 +421,8 @@ func toolFoldedSummary(block session.ContentBlock) string { return formatWriteFolded(block.ToolInput) case block.ToolName == "Bash": return formatBashFolded(block.ToolInput) + case block.ToolName == "Monitor": + return formatMonitorFolded(block.ToolInput) case block.ToolName == "Read": return formatReadFolded(block.ToolInput) case block.ToolName == "Grep": @@ -379,6 +444,8 @@ func toolDiffOutput(block session.ContentBlock, width int) string { return formatWriteDiff(block.ToolInput, width) case block.ToolName == "Bash": return formatBashExpanded(block.ToolInput, width) + case block.ToolName == "Monitor": + return formatMonitorExpanded(block.ToolInput, width) } return "" } diff --git a/internal/tui/live_preview_test.go b/internal/tui/live_preview_test.go index 1035ea8..6d55186 100644 --- a/internal/tui/live_preview_test.go +++ b/internal/tui/live_preview_test.go @@ -355,6 +355,7 @@ func TestPreviewModeConstants(t *testing.T) { sessPreviewMemory, sessPreviewTasksPlan, sessPreviewAgents, + sessPreviewShells, sessPreviewLive, sessPreviewRemote, } diff --git a/internal/tui/sessions.go b/internal/tui/sessions.go index 977f120..d245ce9 100644 --- a/internal/tui/sessions.go +++ b/internal/tui/sessions.go @@ -28,24 +28,57 @@ const ( // buildGroupedItems returns list items for the given group mode. func buildGroupedItems(sessions []session.Session, groupMode int, worktreeDir ...string) []list.Item { + currentSessions, rest := splitCurrentWindow(sessions) + + var restItems []list.Item switch groupMode { case groupProject: - return buildProjectGroupItems(sessions) + restItems = buildProjectGroupItems(rest) case groupTree: - return buildTreeItems(sessions) + restItems = buildTreeItems(rest) case groupChain: - return buildChainGroupItems(sessions) + restItems = buildChainGroupItems(rest) case groupFork: - return buildForkGroupItems(sessions) + restItems = buildForkGroupItems(rest) case groupBaseProject: - return buildBaseProjectGroupItems(sessions, worktreeDir...) + restItems = buildBaseProjectGroupItems(rest, worktreeDir...) default: - items := make([]list.Item, len(sessions)) - for i, s := range sessions { - items[i] = sessionItem{sess: s} + restItems = make([]list.Item, len(rest)) + for i, s := range rest { + restItems[i] = sessionItem{sess: s} + } + } + + if len(currentSessions) == 0 { + return restItems + } + + items := make([]list.Item, 0, len(currentSessions)+len(restItems)+2) + items = append(items, headerItem{label: "Current Window"}) + for _, s := range currentSessions { + items = append(items, sessionItem{sess: s}) + } + if len(restItems) > 0 { + items = append(items, headerItem{label: "Sessions"}) + items = append(items, restItems...) + } + return items +} + +// splitCurrentWindow partitions sessions into those in the current tmux window +// (preserving most-recent-first order) and the rest. +func splitCurrentWindow(sessions []session.Session) (current, rest []session.Session) { + for _, s := range sessions { + if s.IsCurrentWindow { + current = append(current, s) + } else { + rest = append(rest, s) } - return items } + sort.Slice(current, func(i, j int) bool { + return current[i].ModTime.After(current[j].ModTime) + }) + return current, rest } // substringFilter matches items whose FilterValue contains the search term as a substring. @@ -88,6 +121,24 @@ func (s sessionItem) FilterValue() string { return session.FilterValueFor(s.sess, nil) } +// headerSentinel is returned by headerItem.FilterValue so headers never match a +// user filter via substring search. The filter wrapper still injects them back +// in to keep section titles visible. +const headerSentinel = "\x00ccx-header\x00" + +// headerItem is a non-selectable list item that renders a section divider. +type headerItem struct { + label string +} + +func (h headerItem) FilterValue() string { return headerSentinel } + +// isSeparator reports whether item is a non-session decorative row. +func isSeparator(item list.Item) bool { + _, ok := item.(headerItem) + return ok +} + type sessionDelegate struct { timeW int // max width of time-ago column msgW int // max width of message count column @@ -99,7 +150,33 @@ func (d sessionDelegate) Height() int { return 2 } func (d sessionDelegate) Spacing() int { return 0 } func (d sessionDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } +// renderHeader draws a section divider like "── Current Window ──". +// It always occupies 2 rows (label + blank) so cursor math stays consistent +// with sessionItem rows. +func (d sessionDelegate) renderHeader(w io.Writer, m list.Model, h headerItem) { + width := m.Width() + if width <= 0 { + fmt.Fprint(w, "\n") + return + } + label := " " + h.label + " " + dashes := width - lipgloss.Width(label) - 2 + if dashes < 0 { + dashes = 0 + } + left := strings.Repeat("─", dashes/2) + right := strings.Repeat("─", dashes-dashes/2) + style := dimStyle.Bold(true) + line1 := style.Render(left + label + right) + clamp := lipgloss.NewStyle().MaxWidth(width) + fmt.Fprintf(w, "%s\n%s", clamp.Render(line1), "") +} + func (d sessionDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) { + if h, ok := item.(headerItem); ok { + d.renderHeader(w, m, h) + return + } si, ok := item.(sessionItem) if !ok { return @@ -161,6 +238,10 @@ func (d sessionDelegate) Render(w io.Writer, m list.Model, index int, item list. badges := "" badgesW := 0 hide := d.hiddenBadges + if s.IsCurrentWindow && !hide["HERE"] { + badges += " " + hereBadge.Render("[HERE]") + badgesW += 7 + } if s.IsLive && !hide["LIVE"] { if s.IsResponding { badges += " " + busyBadge.Render("[BUSY]") @@ -205,6 +286,10 @@ func (d sessionDelegate) Render(w io.Writer, m list.Model, index int, item list. badges += " " + mcpBadgeStyle.Render("[X]") badgesW += 4 } + if s.HasShellJobs && !hide["B"] { + badges += " " + shellBadge.Render("[B]") + badgesW += 4 + } if s.ParentSessionID != "" && !hide["F"] { badges += " " + forkBadge.Render("[F]") badgesW += 4 @@ -330,16 +415,88 @@ func newSessionList(sessions []session.Session, width, height int, groupMode int // Use chain-aware filter for grouped modes so children stay visible // when their parent matches (and vice versa). + var base list.FilterFunc if groupMode == groupChain || groupMode == groupFork || groupMode == groupTree || groupMode == groupBaseProject { - l.Filter = buildChainAwareFilter(items) + base = buildChainAwareFilter(items) } else { - l.Filter = substringFilter + base = substringFilter } + l.Filter = wrapPinCurrentWindow(items, base) configureListSearch(&l) l.SetSize(width, height) // re-compute pagination after hiding bars return l } +// wrapPinCurrentWindow ensures that current-window sessions and section +// headers are always included in filter results, regardless of the search +// term. Matched items keep their highlight; pinned items are returned with +// no MatchedIndexes so they render normally. +func wrapPinCurrentWindow(items []list.Item, base list.FilterFunc) list.FilterFunc { + pinned := make(map[int]bool) + hasCurrent := false + for i, item := range items { + switch v := item.(type) { + case headerItem: + pinned[i] = true + case sessionItem: + if v.sess.IsCurrentWindow { + pinned[i] = true + hasCurrent = true + } + } + } + return func(term string, targets []string) []list.Rank { + ranks := base(term, targets) + if !hasCurrent && len(pinned) == 0 { + return ranks + } + seen := make(map[int]list.Rank, len(ranks)) + for _, r := range ranks { + seen[r.Index] = r + } + for idx := range pinned { + if _, ok := seen[idx]; !ok { + seen[idx] = list.Rank{Index: idx} + } + } + out := make([]list.Rank, 0, len(seen)) + for i := range items { + if r, ok := seen[i]; ok { + out = append(out, r) + } + } + // Drop any trailing/empty section headers (e.g. "Sessions" with no + // rest items left after filtering). + out = trimEmptyHeaders(out, items) + return out + } +} + +// trimEmptyHeaders removes header items that have no non-header item following +// them in the rank list. +func trimEmptyHeaders(ranks []list.Rank, items []list.Item) []list.Rank { + if len(ranks) == 0 { + return ranks + } + keep := make([]bool, len(ranks)) + hasNonHeaderAfter := false + for i := len(ranks) - 1; i >= 0; i-- { + if _, isHeader := items[ranks[i].Index].(headerItem); isHeader { + keep[i] = hasNonHeaderAfter + } else { + keep[i] = true + hasNonHeaderAfter = true + } + } + out := ranks[:0] + for i, r := range ranks { + if keep[i] { + out = append(out, r) + } + } + return out +} + // buildChainAwareFilter returns a filter function that preserves parent-child // relationships. When a depth=0 parent matches, all its depth=1 children stay // visible. When a depth=1 child matches, its parent also stays visible. @@ -871,6 +1028,7 @@ func renderHelpModal(bg string, screenW, screenH int, km Keymap, shortcutHint st desc string } allBadges := []badge{ + {hereBadge, "[HERE]", "In current tmux window"}, {liveBadge, "[LIVE]", "Running Claude"}, {busyBadge, "[BUSY]", "Responding"}, {memoryBadge, "[M]", "Has memory"}, @@ -903,6 +1061,7 @@ func renderHelpModal(bg string, screenW, screenH int, km Keymap, shortcutHint st sb.WriteString("\n" + headerStyle.Render(" Search Filters") + "\n") type filter struct{ filter, desc string } allFilters := []filter{ + {"is:here", "In current window"}, {"is:live", "Live sessions"}, {"is:busy", "Busy sessions"}, {"is:wt", "Worktree sessions"}, diff --git a/internal/tui/shells_test.go b/internal/tui/shells_test.go new file mode 100644 index 0000000..ce727cd --- /dev/null +++ b/internal/tui/shells_test.go @@ -0,0 +1,90 @@ +package tui + +import ( + "strings" + "testing" + "time" + + "github.com/sendbird/ccx/internal/session" +) + +func TestFormatBashFolded_BackgroundFlag(t *testing.T) { + out := stripANSI(formatBashFolded(`{"command":"npm run watch","run_in_background":true}`)) + if !strings.Contains(out, "[bg]") { + t.Fatalf("expected background hint in folded bash, got %q", out) + } + if !strings.Contains(out, "$ npm run watch") { + t.Fatalf("expected command in folded bash, got %q", out) + } + + plain := stripANSI(formatBashFolded(`{"command":"ls"}`)) + if strings.Contains(plain, "[bg]") { + t.Fatalf("foreground bash should not show bg hint, got %q", plain) + } +} + +func TestFormatMonitorFolded_PersistentFlag(t *testing.T) { + out := stripANSI(formatMonitorFolded(`{"command":"while true; do sleep 60; done","persistent":true,"description":"watch logs"}`)) + if !strings.Contains(out, "[monitor·persistent]") { + t.Fatalf("expected persistent monitor tag, got %q", out) + } + if !strings.Contains(out, "watch logs") { + t.Fatalf("expected description, got %q", out) + } + + once := stripANSI(formatMonitorFolded(`{"command":"echo hi"}`)) + if !strings.Contains(once, "[monitor]") || strings.Contains(once, "persistent") { + t.Fatalf("unexpected monitor folded output: %q", once) + } +} + +func TestBuildShellsPreviewContent(t *testing.T) { + app := newTestApp(fakeSessions()) + sess := session.Session{ + ID: "shellsess", ShortID: "shellses", + HasShellJobs: true, + ShellJobs: []session.ShellJob{ + { + ID: "tu1", ToolName: "Bash", + Command: "npm run dev", Description: "dev server", + TimeoutMS: 120000, + StartedAt: time.Now().Add(-3 * time.Minute), + LastEventAt: time.Now().Add(-1 * time.Minute), + PollCount: 2, Status: "polled", + }, + { + ID: "tu2", ToolName: "Monitor", + Command: "while true; do echo .; sleep 60; done", + Description: "watch secrets", + Persistent: true, TimeoutMS: 300000, + StartedAt: time.Now().Add(-5 * time.Minute), + LastEventAt: time.Now().Add(-5 * time.Minute), + Status: "running", + }, + }, + } + + out := stripANSI(app.buildShellsPreviewContent(sess)) + for _, want := range []string{ + "Background shells", + "Bash", + "Monitor", + "persistent", + "npm run dev", + "watch secrets", + "polls", + } { + if !strings.Contains(out, want) { + t.Fatalf("expected shells preview to contain %q, got %q", want, out) + } + } +} + +func TestBuildShellsPreviewContent_EmptyState(t *testing.T) { + app := newTestApp(fakeSessions()) + sess := session.Session{ID: "x", ShortID: "x"} + out := stripANSI(app.buildShellsPreviewContent(sess)) + if !strings.Contains(out, "No background shells") { + t.Fatalf("expected empty state, got %q", out) + } +} diff --git a/internal/tui/stats_load_test.go b/internal/tui/stats_load_test.go new file mode 100644 index 0000000..4a4ee24 --- /dev/null +++ b/internal/tui/stats_load_test.go @@ -0,0 +1,49 @@ +package tui + +import ( + "testing" + + "github.com/sendbird/ccx/internal/session" +) + +func TestGlobalStatsMsg_DoesNotForceViewAfterEscape(t *testing.T) { + app := newTestApp(fakeSessions()) + app.state = viewSessions + + // Simulate a stats scan that was in flight when the user left the view. + app.globalStatsLoading = true + + msg := globalStatsMsg(session.AggregateStats(app.sessions, "")) + m, _ := app.Update(msg) + got := m.(*App) + + if got.state != viewSessions { + t.Fatalf("stats msg must not yank user back to stats view, got state=%v", got.state) + } + if got.globalStatsLoading { + t.Fatalf("globalStatsLoading should be cleared even when we discard the result") + } + if got.globalStatsCache == nil { + t.Fatalf("globalStatsCache should be populated so re-entering stats is instant") + } +} + +func TestGlobalStatsMsg_StillUpdatesWhenStillOnStatsView(t *testing.T) { + app := newTestApp(fakeSessions()) + app.state = viewGlobalStats + app.globalStatsLoading = true + + msg := globalStatsMsg(session.AggregateStats(app.sessions, "")) + m, _ := app.Update(msg) + got := m.(*App) + + if got.state != viewGlobalStats { + t.Fatalf("expected stats view to stay active, got %v", got.state) + } + if got.globalStatsLoading { + t.Fatalf("globalStatsLoading must be cleared after data arrives") + } + if got.globalStatsCache == nil { + t.Fatalf("expected globalStatsCache to be populated") + } +} diff --git a/internal/tui/styles.go b/internal/tui/styles.go index fce3598..427186c 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -3,15 +3,15 @@ package tui import "github.com/charmbracelet/lipgloss" var ( - colorPrimary = lipgloss.Color("#7C3AED") - colorTitleBg = lipgloss.Color("#1E293B") // subtle dark bg for title bar - colorDim = lipgloss.Color("#6B7280") - colorAccent = lipgloss.Color("#10B981") - colorUser = lipgloss.Color("#3B82F6") - colorAssistant = lipgloss.Color("#F59E0B") - colorError = lipgloss.Color("#EF4444") - colorWorktree = lipgloss.Color("#8B5CF6") - colorFilter = lipgloss.Color("#EC4899") + colorPrimary = lipgloss.Color("#7C3AED") + colorTitleBg = lipgloss.Color("#1E293B") // subtle dark bg for title bar + colorDim = lipgloss.Color("#6B7280") + colorAccent = lipgloss.Color("#10B981") + colorUser = lipgloss.Color("#3B82F6") + colorAssistant = lipgloss.Color("#F59E0B") + colorError = lipgloss.Color("#EF4444") + colorWorktree = lipgloss.Color("#8B5CF6") + colorFilter = lipgloss.Color("#EC4899") colorBorderFocused = lipgloss.Color("#38BDF8") colorBorderDim = lipgloss.Color("#374151") @@ -37,9 +37,11 @@ var ( taskBadge = lipgloss.NewStyle().Foreground(lipgloss.Color("#FB923C")).Bold(true) cronBadge = lipgloss.NewStyle().Foreground(lipgloss.Color("#10B981")).Bold(true) planBadge = lipgloss.NewStyle().Foreground(lipgloss.Color("#A78BFA")).Bold(true) + shellBadge = lipgloss.NewStyle().Foreground(lipgloss.Color("#22D3EE")).Bold(true) liveBadge = lipgloss.NewStyle().Foreground(lipgloss.Color("#22C55E")).Bold(true) busyBadge = lipgloss.NewStyle().Foreground(lipgloss.Color("#F59E0B")).Bold(true) forkBadge = lipgloss.NewStyle().Foreground(lipgloss.Color("#F59E0B")).Bold(true) + hereBadge = lipgloss.NewStyle().Foreground(lipgloss.Color("#F472B6")).Bold(true) customBadgeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#84CC16")).Bold(true).Italic(true) blockCursorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#38BDF8")).Bold(true) blockSelectedBg = lipgloss.NewStyle().Background(lipgloss.Color("#1E293B"))