From ed10b27c2530b1ba90c289943e99e40627b4f1e1 Mon Sep 17 00:00:00 2001 From: keyolk Date: Sun, 30 Aug 2026 21:43:43 +0900 Subject: [PATCH] feat: show live state in search results and resume from a hit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-session search found the right session and then dead-ended. A result row showed project name and age, Enter jumped to the matching message, and that was it — no way to get back to working in that session, and no way to tell whether it was still running. Show [LIVE]/[HERE] on result rows and bind the Actions resume key (r) to resumeSession on the hit. resumeSession already branches the way this needs: attach to the tmux pane when live, otherwise revive the transcript with `claude --resume` in a tmux window. It was simply not reachable from search — only from the session list. Live state is read from the session store, not from the search result. The result carries a snapshot from when the search ran, and a session can start or exit while the results sit on screen; since the badge is what the user makes the jump-or-revive decision from, a stale one is worse than none. The rows are also rebuilt on tick so a session coming up or going down is reflected while the modal is open, preserving the cursor so the refresh does not move the selection out from under the user. The resume case is ordered after the j/k navigation cases. Go's switch takes the first match, so a user who rebinds resume to a navigation key would otherwise lose the ability to scroll the results. Enter keeps its existing meaning (jump to the message); the two are different intents and both are worth having. --- README.md | 8 +- internal/tui/app.go | 7 + internal/tui/search.go | 64 ++++++++- internal/tui/search_resume_test.go | 207 +++++++++++++++++++++++++++++ 4 files changed, 279 insertions(+), 7 deletions(-) create mode 100644 internal/tui/search_resume_test.go 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) + } +}