Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**
Expand Down Expand Up @@ -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 |
Expand Down
7 changes: 7 additions & 0 deletions internal/tui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
64 changes: 59 additions & 5 deletions internal/tui/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
}
Expand All @@ -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)
}
}
207 changes: 207 additions & 0 deletions internal/tui/search_resume_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading