diff --git a/README.md b/README.md index a3f7b89..ce94a36 100644 --- a/README.md +++ b/README.md @@ -192,7 +192,11 @@ Search inside conversation content across all sessions (`Ctrl+S` or `:search`). - Searches text, tool inputs/outputs, thinking blocks - Results stream in real-time as they're found - Matched terms are highlighted in snippets -- Press `Enter` to jump directly to the matching message +- `[LIVE]` / `[HERE]` badges show which hits belong to a running session, updated + while the results are open +- Press `Enter` to jump to the matching message +- Press `r` to go straight back to work in that session: attach to its tmux pane + if it is live, otherwise resume the transcript in a tmux window - Press `/` to edit the query **Example queries:** @@ -379,7 +383,7 @@ Multi-select plugin components and press `t` to launch an isolated Claude sessio | `x` | Actions menu (delete, move, resume, fork, URLs, files, ...) — on a focused outputs pane, the row's own actions | | `v` | Views menu (stats/config/plugins) | | `:` | Command mode | -| `Ctrl+S` | Cross-session search | +| `Ctrl+S` | Cross-session search (in results: `enter` jumps, `r` attaches/resumes) | | `L` | Live preview (tmux) | | `I` | Send input to live session | | `J` | Jump to tmux pane | diff --git a/internal/tui/app.go b/internal/tui/app.go index 1cbd6f3..52a8550 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -4943,6 +4943,13 @@ func (a *App) handleTick() tea.Cmd { a.refreshRespondingState() } + // The search modal's LIVE badges are what the user decides "jump or revive" + // from, so they have to track sessions starting and exiting while the + // results are on screen — not stay frozen at search time. + if a.searchActive && len(a.searchResults) > 0 { + a.rebuildSearchResultItems() + } + // Poll remote pod phases at most every 30s. Off the main goroutine. var pollCmd tea.Cmd if time.Since(a.remoteLastPoll) >= 30*time.Second { diff --git a/internal/tui/search.go b/internal/tui/search.go index 0f252e7..7cdf3c4 100644 --- a/internal/tui/search.go +++ b/internal/tui/search.go @@ -14,6 +14,11 @@ import ( type searchResultItem struct { result session.SearchResult + // live is resolved from the session store at render time, not taken from + // result.Session — the latter is a snapshot from when the search ran, and a + // session can start or stop while the results are on screen. + live bool + here bool } func (i searchResultItem) FilterValue() string { @@ -22,7 +27,17 @@ func (i searchResultItem) FilterValue() string { func (i searchResultItem) Title() string { sess := i.result.Session - return fmt.Sprintf("%s • %s", sess.ProjectName, timeAgo(sess.ModTime)) + var badges string + if i.here { + badges += hereBadge.Render("[HERE]") + } + if i.live { + badges += liveBadge.Render("[LIVE]") + } + if badges != "" { + badges += " " + } + return fmt.Sprintf("%s%s • %s", badges, sess.ProjectName, timeAgo(sess.ModTime)) } func (i searchResultItem) Description() string { @@ -155,6 +170,27 @@ func (a *App) handleSearchKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "k", "up": a.searchResultList, _ = a.searchResultList.Update(msg) return a, nil + + case a.keymap.Actions.Resume: + // Take the hit straight into a working session: attach to the live pane + // if one exists, otherwise resume the transcript in a tmux window. + // resumeSession already branches on live vs not, so searching and then + // resuming is the same action the session list offers. + item, ok := a.searchResultList.SelectedItem().(searchResultItem) + if !ok || item.result.Session == nil { + return a, nil + } + sess, ok := a.sessionByIDFromStore(item.result.Session.ID) + if !ok { + // The session left the store (deleted or moved) since the search ran. + sess = *item.result.Session + } + if sess.IsRemote { + a.copiedMsg = "Use Enter to attach to remote session" + return a, nil + } + a.exitSearchMode() + return a.resumeSession(sess) } return a, nil @@ -255,7 +291,7 @@ func (a *App) renderSearchModal(bg string) string { case a.searchInput.Focused(): help = "enter:search esc:close" case len(a.searchResults) > 0: - help = "↑↓/jk:nav enter:open /:edit esc:close" + help = fmt.Sprintf("↑↓/jk:nav enter:open %s:resume/attach /:edit esc:close", a.keymap.Actions.Resume) default: help = "esc:close" } @@ -280,10 +316,28 @@ func (a *App) renderSearchModal(bg string) string { func (a *App) updateSearchResults(results []session.SearchResult) { a.searchResults = results a.searchLoading = false + a.rebuildSearchResultItems() +} - items := make([]list.Item, len(results)) - for i, r := range results { - items[i] = searchResultItem{result: r} +// rebuildSearchResultItems re-renders the result rows against the current +// session store. Live state is read here rather than carried on the search +// result: a search snapshot goes stale the moment a session starts or exits, +// and the badge is what the user decides "jump or resume" from. +func (a *App) rebuildSearchResultItems() { + items := make([]list.Item, len(a.searchResults)) + for i, r := range a.searchResults { + item := searchResultItem{result: r} + if r.Session != nil { + if fresh, ok := a.sessionByIDFromStore(r.Session.ID); ok { + item.live = fresh.IsLive + item.here = fresh.IsCurrentWindow + } + } + items[i] = item } + idx := a.searchResultList.Index() a.searchResultList.SetItems(items) + if idx > 0 && idx < len(items) { + a.searchResultList.Select(idx) + } } diff --git a/internal/tui/search_resume_test.go b/internal/tui/search_resume_test.go new file mode 100644 index 0000000..a14f815 --- /dev/null +++ b/internal/tui/search_resume_test.go @@ -0,0 +1,207 @@ +package tui + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/sendbird/ccx/internal/session" +) + +func searchApp(t *testing.T, sessions []session.Session, results []session.SearchResult) *App { + t.Helper() + a := newTestApp(sessions) + a.enterSearchMode() + a.searchInput.Blur() // focus the result list, not the query box + a.searchQuery = "q" + a.updateSearchResults(results) + return a +} + +// A search hit has to say whether its session is still running: that is what +// decides between "jump to the pane" and "revive it". +func TestSearchResultShowsLiveState(t *testing.T) { + live := session.Session{ID: "live-1", ShortID: "live-1", ProjectName: "proj-live", IsLive: true} + dead := session.Session{ID: "dead-1", ShortID: "dead-1", ProjectName: "proj-dead"} + + liveCopy, deadCopy := live, dead + a := searchApp(t, []session.Session{live, dead}, []session.SearchResult{ + {Session: &liveCopy, Snippet: "hit in live"}, + {Session: &deadCopy, Snippet: "hit in dead"}, + }) + + items := a.searchResultList.Items() + if len(items) != 2 { + t.Fatalf("items = %d, want 2", len(items)) + } + + byProject := map[string]searchResultItem{} + for _, raw := range items { + item := raw.(searchResultItem) + byProject[item.result.Session.ProjectName] = item + } + if !byProject["proj-live"].live { + t.Error("live session's result row is not marked live") + } + if byProject["proj-dead"].live { + t.Error("dead session's result row is marked live") + } + if !strings.Contains(stripANSI(byProject["proj-live"].Title()), "LIVE") { + t.Errorf("live row title has no LIVE badge: %q", stripANSI(byProject["proj-live"].Title())) + } + if strings.Contains(stripANSI(byProject["proj-dead"].Title()), "LIVE") { + t.Errorf("dead row title claims LIVE: %q", stripANSI(byProject["proj-dead"].Title())) + } +} + +// The search result carries a snapshot from when the search ran. Live state has +// to come from the store instead, or a session that started or exited while the +// results were on screen is reported wrongly. +func TestSearchResultLiveStateComesFromStore(t *testing.T) { + // Snapshot says dead... + snapshot := session.Session{ID: "s1", ShortID: "s1", ProjectName: "proj", IsLive: false} + // ...but the store says it is live now. + a := newTestApp([]session.Session{{ID: "s1", ShortID: "s1", ProjectName: "proj", IsLive: true}}) + a.enterSearchMode() + a.searchInput.Blur() + a.updateSearchResults([]session.SearchResult{{Session: &snapshot, Snippet: "hit"}}) + + item := a.searchResultList.Items()[0].(searchResultItem) + if !item.live { + t.Error("stale snapshot won over the store — a session that came up is shown as dead") + } + + // And the reverse: store says dead, snapshot claimed live. + staleLive := session.Session{ID: "s2", ShortID: "s2", ProjectName: "proj2", IsLive: true} + b := newTestApp([]session.Session{{ID: "s2", ShortID: "s2", ProjectName: "proj2", IsLive: false}}) + b.enterSearchMode() + b.searchInput.Blur() + b.updateSearchResults([]session.SearchResult{{Session: &staleLive, Snippet: "hit"}}) + + if b.searchResultList.Items()[0].(searchResultItem).live { + t.Error("stale snapshot won over the store — an exited session is still shown live") + } +} + +// Resuming from a search hit must reach resumeSession, which is what branches +// on live (attach to pane) vs not (revive in a tmux window). +func TestResumeKeyFromSearchResults(t *testing.T) { + sess := session.Session{ID: "s1", ShortID: "s1", ProjectName: "proj", ProjectPath: t.TempDir()} + snapshot := sess + a := searchApp(t, []session.Session{sess}, []session.SearchResult{ + {Session: &snapshot, Snippet: "hit"}, + }) + if !a.searchActive { + t.Fatal("search mode not active") + } + + key := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(a.keymap.Actions.Resume)} + m, _ := a.handleSearchKey(key) + got := m.(*App) + + // Whatever the resume outcome (no tmux in tests), the modal must close — + // otherwise the search overlay stays on top of the session being resumed. + if got.searchActive { + t.Error("resume key did not close the search modal") + } +} + +// A rebound Resume key must not swallow list navigation. +func TestSearchNavigationKeysWinOverRebooundResume(t *testing.T) { + sessions := []session.Session{ + {ID: "s1", ShortID: "s1", ProjectName: "p1"}, + {ID: "s2", ShortID: "s2", ProjectName: "p2"}, + } + s1, s2 := sessions[0], sessions[1] + a := searchApp(t, sessions, []session.SearchResult{ + {Session: &s1, Snippet: "hit 1"}, + {Session: &s2, Snippet: "hit 2"}, + }) + // Pathological rebinding: Resume bound to the down key. + a.keymap.Actions.Resume = "j" + + before := a.searchResultList.Index() + m, _ := a.handleSearchKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) + got := m.(*App) + + if !got.searchActive { + t.Fatal("j triggered resume instead of moving the cursor") + } + if got.searchResultList.Index() == before { + t.Error("j did not move the result cursor") + } +} + +// The resume key must be discoverable from the modal itself. +func TestSearchHelpMentionsResumeKey(t *testing.T) { + sess := session.Session{ID: "s1", ShortID: "s1", ProjectName: "proj"} + snapshot := sess + a := searchApp(t, []session.Session{sess}, []session.SearchResult{ + {Session: &snapshot, Snippet: "hit"}, + }) + a.width, a.height = 120, 40 + + view := stripANSI(a.renderSearchModal("")) + if !strings.Contains(view, "resume") { + t.Errorf("search modal help does not mention resume:\n%s", view) + } +} + +// A session that starts or exits while the results are on screen must be +// reflected in the badge — that badge is what the resume decision is made from. +func TestSearchBadgesTrackLiveChangesWhileOpen(t *testing.T) { + sess := session.Session{ID: "s1", ShortID: "s1", ProjectName: "proj"} + snapshot := sess + a := searchApp(t, []session.Session{sess}, []session.SearchResult{ + {Session: &snapshot, Snippet: "hit"}, + }) + if a.searchResultList.Items()[0].(searchResultItem).live { + t.Fatal("session started out live") + } + + // The session comes up while the modal is open. + for i := range a.sessions { + if a.sessions[i].ID == "s1" { + a.sessions[i].IsLive = true + } + } + a.handleTick() + + if !a.searchResultList.Items()[0].(searchResultItem).live { + t.Error("badge did not pick up the session coming live while results were open") + } + + // And back down again. + for i := range a.sessions { + if a.sessions[i].ID == "s1" { + a.sessions[i].IsLive = false + } + } + a.handleTick() + + if a.searchResultList.Items()[0].(searchResultItem).live { + t.Error("badge did not pick up the session exiting while results were open") + } +} + +// Refreshing the rows must not move the user's cursor out from under them. +func TestSearchBadgeRefreshPreservesCursor(t *testing.T) { + sessions := []session.Session{ + {ID: "s1", ShortID: "s1", ProjectName: "p1"}, + {ID: "s2", ShortID: "s2", ProjectName: "p2"}, + {ID: "s3", ShortID: "s3", ProjectName: "p3"}, + } + s1, s2, s3 := sessions[0], sessions[1], sessions[2] + a := searchApp(t, sessions, []session.SearchResult{ + {Session: &s1, Snippet: "hit 1"}, + {Session: &s2, Snippet: "hit 2"}, + {Session: &s3, Snippet: "hit 3"}, + }) + + a.searchResultList.Select(2) + a.handleTick() + + if got := a.searchResultList.Index(); got != 2 { + t.Errorf("cursor moved on refresh: index = %d, want 2", got) + } +}