From d859e5b0218682f33deea708ee3524e77e3afdbb Mon Sep 17 00:00:00 2001 From: gadflysu Date: Tue, 16 Jun 2026 16:44:13 +0800 Subject: [PATCH 1/2] fix(source): write MetaCache back in ReloadSession to prevent stale title/CWD on cold start Closes #18 --- main.go | 3 +- picker/model.go | 20 ++-- picker/model_test.go | 208 +++++++++++++++++++++--------------------- source/claude.go | 15 ++- source/claude_test.go | 48 +++++++++- 5 files changed, 177 insertions(+), 117 deletions(-) diff --git a/main.go b/main.go index dc90a57..4621872 100644 --- a/main.go +++ b/main.go @@ -76,6 +76,7 @@ func runInteractiveStreaming(cfg cmd.Config, from, until *time.Time) { combined := cfg.MultiAgent() cache := source.LoadPIDCache() + metaCache := source.LoadMetaCache() var wg sync.WaitGroup wg.Add(1) @@ -168,7 +169,7 @@ func runInteractiveStreaming(cfg cmd.Config, from, until *time.Time) { } }() - session, err := picker.RunStreaming(stream, combined, cache) + session, err := picker.RunStreaming(stream, combined, cache, metaCache) wg.Wait() if err != nil { fmt.Fprintf(os.Stderr, "picker error: %v\n", err) diff --git a/picker/model.go b/picker/model.go index e389c01..ec8ca01 100644 --- a/picker/model.go +++ b/picker/model.go @@ -151,7 +151,8 @@ type Model struct { tickCount int // total tick count, drives spinner frames activeConfs map[string]activeConf // sessions with a running process: guessed or confirmed - pidCache *source.PIDCache // persistent pid→sessionID mapping + pidCache *source.PIDCache // persistent pid→sessionID mapping + metaCache *source.MetaCache // updated by applyRefresh so cold-start reads fresh metadata procs []source.ProcInfo // running procs snapshot from startup (cwd→proc index) // matchIdx maps session ID to per-field matched rune offsets populated by applyFilter. @@ -171,7 +172,7 @@ type Model struct { sgrBuf string // accumulates partial SGR mouse fragment split by ESC-disambiguation timer } -func newModel(sessions []source.Session, combined bool, w *watcher.Watcher, cache *source.PIDCache) Model { +func newModel(sessions []source.Session, combined bool, w *watcher.Watcher, cache *source.PIDCache, metaCache *source.MetaCache) Model { ti := textinput.New() ti.Prompt = "" ti.Placeholder = " search query" @@ -210,6 +211,7 @@ func newModel(sessions []source.Session, combined bool, w *watcher.Watcher, cach w: w, activeConfs: activeConfs, pidCache: cache, + metaCache: metaCache, procs: procs, } } @@ -1236,13 +1238,13 @@ func (m Model) View() string { // The picker starts immediately with an empty session list (loading=true) // and consumes batches from stream until a Done batch is received. // combined=true shows the SRC column. -func RunStreaming(stream <-chan SessionBatch, combined bool, cache *source.PIDCache) (*source.Session, error) { +func RunStreaming(stream <-chan SessionBatch, combined bool, cache *source.PIDCache, metaCache *source.MetaCache) (*source.Session, error) { home, _ := os.UserHomeDir() baseDir := filepath.Join(home, ".claude", "projects") w, _ := watcher.New(baseDir) - m := newModel(nil, combined, w, cache) + m := newModel(nil, combined, w, cache, metaCache) m.loading = true m.streamCh = stream firstViewLogged.Store(false) @@ -1277,13 +1279,13 @@ func RunStreaming(stream <-chan SessionBatch, combined bool, cache *source.PIDCa // Run starts the interactive session picker and returns the chosen session, // or nil if the user cancelled. combined=true shows the SRC column. // statusText/statusIsErr set the initial bottom status bar content. -func Run(sessions []source.Session, combined bool, cache *source.PIDCache, statusText string, statusIsErr bool) (*source.Session, error) { +func Run(sessions []source.Session, combined bool, cache *source.PIDCache, metaCache *source.MetaCache, statusText string, statusIsErr bool) (*source.Session, error) { home, _ := os.UserHomeDir() baseDir := filepath.Join(home, ".claude", "projects") w, _ := watcher.New(baseDir) // failure degrades to poll-only; w is never nil - m := newModel(sessions, combined, w, cache) + m := newModel(sessions, combined, w, cache, metaCache) m.statusText = statusText m.statusIsErr = statusIsErr firstViewLogged.Store(false) @@ -1328,7 +1330,7 @@ func (m *Model) applyRefresh(paths []string) { anchorClient, anchorID := m.cursorAnchor() for _, path := range paths { - updated, err := source.ReloadSession(path, false) + updated, err := source.ReloadSession(path, false, m.metaCache) if err != nil { continue } @@ -1376,6 +1378,10 @@ func (m *Model) applyRefresh(paths []string) { m.updateMaxColOffset() m.restoreCursor(anchorClient, anchorID) + + if m.metaCache != nil { + _ = m.metaCache.Save() + } } // applySessionBatch upserts the sessions in batch into m.sessions by (Client, ID), diff --git a/picker/model_test.go b/picker/model_test.go index beb22d8..99aa877 100644 --- a/picker/model_test.go +++ b/picker/model_test.go @@ -71,7 +71,7 @@ func TestAdaptiveColWidths_IDFromSessions(t *testing.T) { {ID: "abc", MsgCount: 1}, {ID: "abcdefghij", MsgCount: 9999}, } - m := newModel(sessions, false, nil, nil) + m := newModel(sessions, false, nil, nil, nil) wantID := lipgloss.Width("abcdefghij") // 10 if m.idColW != wantID { t.Errorf("idColW = %d, want %d", m.idColW, wantID) @@ -83,7 +83,7 @@ func TestAdaptiveColWidths_MsgFromSessions(t *testing.T) { {ID: "a", MsgCount: 1}, {ID: "b", MsgCount: 9999}, } - m := newModel(sessions, false, nil, nil) + m := newModel(sessions, false, nil, nil, nil) // AdaptiveMsgWidth has a floor of len("TURNS")=5 so the header fits. wantMsg := len("TURNS") // 5, because 9999 (4 cols) < floor if m.msgColW != wantMsg { @@ -96,7 +96,7 @@ func TestAdaptiveColWidths_StableAfterFilter(t *testing.T) { {ID: "abcdefghij", MsgCount: 9999, Title: "alpha"}, {ID: "x", MsgCount: 1, Title: "beta"}, } - m := newModel(sessions, false, nil, nil) + m := newModel(sessions, false, nil, nil, nil) idBefore := m.idColW msgBefore := m.msgColW m.query = "beta" @@ -110,7 +110,7 @@ func TestAdaptiveColWidths_StableAfterFilter(t *testing.T) { } func TestNewModel_SearchPlaceholder(t *testing.T) { - m := newModel(makeSessions(), false, nil, nil) + m := newModel(makeSessions(), false, nil, nil, nil) if m.search.Placeholder != " search query" { t.Fatalf("search placeholder = %q, want %q", m.search.Placeholder, " search query") } @@ -138,7 +138,7 @@ func TestListTitleWidth_ExpandsWithTerminalWidth(t *testing.T) { sessions := []source.Session{ {ID: "abc", Title: longTitle, CWDDisplay: "~/projects/aps", MsgCount: 1}, } - m := newModel(sessions, false, nil, nil) + m := newModel(sessions, false, nil, nil, nil) m.state = stateList // At a narrow terminal the title is capped at titleColWidth+2 (no surplus). @@ -163,7 +163,7 @@ func TestListTitleWidth_NeverExceedsNaturalMax(t *testing.T) { sessions := []source.Session{ {ID: "abc", Title: longTitle, MsgCount: 1}, } - m := newModel(sessions, false, nil, nil) + m := newModel(sessions, false, nil, nil, nil) m.state = stateList m.width, m.height = 500, 20 @@ -186,7 +186,7 @@ func makeSessions() []source.Session { func TestApplyFilter_EmptyQuery(t *testing.T) { sessions := makeSessions() - m := newModel(sessions, false, nil, nil) + m := newModel(sessions, false, nil, nil, nil) m.query = "" m.applyFilter() if len(m.filtered) != len(sessions) { @@ -195,7 +195,7 @@ func TestApplyFilter_EmptyQuery(t *testing.T) { } func TestApplyFilter_MatchesTitle(t *testing.T) { - m := newModel(makeSessions(), false, nil, nil) + m := newModel(makeSessions(), false, nil, nil, nil) m.query = "login" m.applyFilter() if len(m.filtered) == 0 { @@ -207,7 +207,7 @@ func TestApplyFilter_MatchesTitle(t *testing.T) { } func TestApplyFilter_MatchesCWDDisplay(t *testing.T) { - m := newModel(makeSessions(), false, nil, nil) + m := newModel(makeSessions(), false, nil, nil, nil) m.query = "backend" m.applyFilter() if len(m.filtered) == 0 { @@ -219,7 +219,7 @@ func TestApplyFilter_MatchesCWDDisplay(t *testing.T) { } func TestApplyFilter_NoMatches(t *testing.T) { - m := newModel(makeSessions(), false, nil, nil) + m := newModel(makeSessions(), false, nil, nil, nil) m.query = "zzznomatch999" m.applyFilter() if len(m.filtered) != 0 { @@ -229,7 +229,7 @@ func TestApplyFilter_NoMatches(t *testing.T) { func TestApplyFilter_QueryClearedRestoresAll(t *testing.T) { sessions := makeSessions() - m := newModel(sessions, false, nil, nil) + m := newModel(sessions, false, nil, nil, nil) m.query = "login" m.applyFilter() m.query = "" @@ -253,7 +253,7 @@ func TestApplyFilter_PreservesTimeOrder(t *testing.T) { // Reorder to newest-first to match real data flow. sessions = []source.Session{sessions[2], sessions[0], sessions[1]} - m := newModel(sessions, false, nil, nil) + m := newModel(sessions, false, nil, nil, nil) m.query = "fix bug" m.applyFilter() @@ -295,7 +295,7 @@ func TestHighlightField_ContainsANSI(t *testing.T) { // TestApplyFilter_PopulatesMatchIdx verifies that matchIdx is set after a query. func TestApplyFilter_PopulatesMatchIdx(t *testing.T) { - m := newModel(makeSessions(), false, nil, nil) + m := newModel(makeSessions(), false, nil, nil, nil) m.query = "login" m.applyFilter() @@ -313,7 +313,7 @@ func TestApplyFilter_PopulatesMatchIdx(t *testing.T) { // TestApplyFilter_ClearsMatchIdxOnEmptyQuery verifies matchIdx is nil after clearing the query. func TestApplyFilter_ClearsMatchIdxOnEmptyQuery(t *testing.T) { - m := newModel(makeSessions(), false, nil, nil) + m := newModel(makeSessions(), false, nil, nil, nil) m.query = "login" m.applyFilter() m.query = "" @@ -329,7 +329,7 @@ func TestRenderRow_TitleHighlightedOnMatch(t *testing.T) { sessions := []source.Session{ {ID: "abc", Title: "Fix login bug", CWDDisplay: "~/projects/auth"}, } - m := newModel(sessions, false, nil, nil) + m := newModel(sessions, false, nil, nil, nil) m.width, m.height = 120, 40 m.query = "login" m.applyFilter() @@ -346,7 +346,7 @@ func TestRenderRow_SelectedDirHighlighted(t *testing.T) { sessions := []source.Session{ {ID: "abc", Title: "some title", CWDDisplay: "~/projects/auth"}, } - m := newModel(sessions, false, nil, nil) + m := newModel(sessions, false, nil, nil, nil) m.width, m.height = 120, 40 // "auth" matches the dir; "a" is enough to ensure a match in cwd. m.query = "auth" @@ -376,7 +376,7 @@ func TestRenderRowDirUsesCyanNotMuted(t *testing.T) { Title: "test", CWDDisplay: "~/projects/aps", } - m := newModel([]source.Session{s}, false, nil, nil) + m := newModel([]source.Session{s}, false, nil, nil, nil) m.width, m.height = 120, 40 row := m.renderRow(s, false) // ColorDir = lipgloss.Color("6") → ANSI foreground 36 (cyan) @@ -396,7 +396,7 @@ func TestRenderRowDimDirFaint(t *testing.T) { Title: "test", CWDDisplay: "~/projects/aps", } - m := newModel([]source.Session{s}, false, nil, nil) + m := newModel([]source.Session{s}, false, nil, nil, nil) m.width, m.height = 120, 40 row := m.renderRow(s, false) dimRow := m.renderRowDim(s, false) @@ -431,7 +431,7 @@ func stripANSI(s string) string { } func TestRenderColumnHeader_ContainsExpectedLabels(t *testing.T) { - m := newModel(makeSessions(), false, nil, nil) + m := newModel(makeSessions(), false, nil, nil, nil) m.width, m.height = 120, 40 h := stripANSI(m.renderColumnHeader()) for _, label := range []string{"TIME", "TITLE", "ID", "TURNS", "DIRECTORY"} { @@ -442,7 +442,7 @@ func TestRenderColumnHeader_ContainsExpectedLabels(t *testing.T) { } func TestRenderColumnHeader_CombinedIncludesSRC(t *testing.T) { - m := newModel(makeSessions(), true, nil, nil) + m := newModel(makeSessions(), true, nil, nil, nil) m.width, m.height = 120, 40 h := stripANSI(m.renderColumnHeader()) if !strings.Contains(h, "SRC") { @@ -451,7 +451,7 @@ func TestRenderColumnHeader_CombinedIncludesSRC(t *testing.T) { } func TestRenderColumnHeader_NoSRCWhenNotCombined(t *testing.T) { - m := newModel(makeSessions(), false, nil, nil) + m := newModel(makeSessions(), false, nil, nil, nil) m.width, m.height = 120, 40 h := stripANSI(m.renderColumnHeader()) if strings.Contains(h, "SRC") { @@ -462,7 +462,7 @@ func TestRenderColumnHeader_NoSRCWhenNotCombined(t *testing.T) { // TestRenderColumnHeader_PreviewExcludesID verifies that in preview mode the // header omits the ID column — the preview pane already shows the session ID. func TestRenderColumnHeader_PreviewExcludesID(t *testing.T) { - m := newModel(makeSessions(), false, nil, nil) + m := newModel(makeSessions(), false, nil, nil, nil) m.width, m.height = 120, 40 m.state = stateListPreview h := stripANSI(m.renderColumnHeader()) @@ -477,7 +477,7 @@ func TestRenderColumnHeader_PreviewSingleLine(t *testing.T) { s := source.Session{ Client: source.ClientClaude, ID: "abc", Title: "test", MsgCount: 1, } - m := newModel([]source.Session{s}, false, nil, nil) + m := newModel([]source.Session{s}, false, nil, nil, nil) m.width, m.height = 105, 40 m.state = stateListPreview header := m.renderColumnHeader() @@ -492,7 +492,7 @@ func TestRenderRow_PreviewOmitsIDCell(t *testing.T) { s := source.Session{ Client: source.ClientClaude, ID: "abc-123", Title: "test", MsgCount: 1, } - m := newModel([]source.Session{s}, false, nil, nil) + m := newModel([]source.Session{s}, false, nil, nil, nil) m.width, m.height = 120, 40 m.state = stateListPreview row := stripANSI(m.renderRow(s, false)) @@ -508,7 +508,7 @@ func TestListTitleWidth_PreviewGrowsWhenIDRemoved(t *testing.T) { s := source.Session{ Client: source.ClientClaude, ID: "abc", Title: "test", MsgCount: 1, } - m := newModel([]source.Session{s}, false, nil, nil) + m := newModel([]source.Session{s}, false, nil, nil, nil) m.width, m.height = 105, 40 m.state = stateListPreview @@ -531,7 +531,7 @@ func TestListTitleWidth_PreviewGrowsWhenIDRemoved(t *testing.T) { // TestEscInPreviewClosesPreview verifies that pressing esc while in // stateListPreview collapses the preview pane instead of quitting. func TestEscInPreviewClosesPreview(t *testing.T) { - m := newModel(makeSessions(), false, nil, nil) + m := newModel(makeSessions(), false, nil, nil, nil) m.state = stateListPreview next, _ := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) @@ -546,7 +546,7 @@ func TestEscInPreviewClosesPreview(t *testing.T) { // TestEscInListExits verifies that pressing esc in stateList with no query triggers quit. func TestEscInListExits(t *testing.T) { - m := newModel(makeSessions(), false, nil, nil) + m := newModel(makeSessions(), false, nil, nil, nil) m.state = stateList _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) @@ -558,7 +558,7 @@ func TestEscInListExits(t *testing.T) { // TestEscClearsQueryBeforeExiting verifies that pressing esc while there is a // non-empty query clears the query instead of quitting. func TestEscClearsQueryBeforeExiting(t *testing.T) { - m := newModel(makeSessions(), false, nil, nil) + m := newModel(makeSessions(), false, nil, nil, nil) m.state = stateList m.search.SetValue("hello") m.query = "hello" @@ -580,7 +580,7 @@ func TestEscClearsQueryBeforeExiting(t *testing.T) { // TestEscExitsWhenQueryAlreadyEmpty verifies that pressing esc with an empty // query triggers quit (second press behaviour). func TestEscExitsWhenQueryAlreadyEmpty(t *testing.T) { - m := newModel(makeSessions(), false, nil, nil) + m := newModel(makeSessions(), false, nil, nil, nil) m.state = stateList // ensure query is empty m.search.SetValue("") @@ -599,7 +599,7 @@ func TestEscExitsWhenQueryAlreadyEmpty(t *testing.T) { // Regression: Init() used a value receiver, so Focus() mutated a copy and // the real model's search.focus stayed false — all keystrokes were silently dropped. func TestSearchFocusedOnInit(t *testing.T) { - m := newModel(makeSessions(), false, nil, nil) + m := newModel(makeSessions(), false, nil, nil, nil) if !m.search.Focused() { t.Error("search textinput must be focused immediately after newModel") } @@ -609,7 +609,7 @@ func TestSearchFocusedOnInit(t *testing.T) { func TestUpdatePreviewHeights_NoMsgs(t *testing.T) { // height=30: info(8) + statusBar(1) + sep+dir_header(2) + dir_content = 30 -> vpDir.Height = 19 - m := newModel(makeSessions(), false, nil, nil) + m := newModel(makeSessions(), false, nil, nil, nil) m.width = 100 m.height = 30 m.hasMsgs = false @@ -628,7 +628,7 @@ func TestUpdatePreviewHeights_NoMsgs(t *testing.T) { func TestUpdatePreviewHeights_WithMsgs(t *testing.T) { // height=40: available_after_info=40-8-1=31, after_sep+msgs_header=31-2=29, msgsH=29/3=9, after_sep+dir_header=29-9-2=18 - m := newModel(makeSessions(), false, nil, nil) + m := newModel(makeSessions(), false, nil, nil, nil) m.width = 100 m.height = 40 m.hasMsgs = true @@ -647,7 +647,7 @@ func TestUpdatePreviewHeights_WithMsgs(t *testing.T) { func TestUpdatePreviewHeights_WidthSet(t *testing.T) { // pw = 100*4/10 - 2 = 38 - m := newModel(makeSessions(), false, nil, nil) + m := newModel(makeSessions(), false, nil, nil, nil) m.width = 100 m.height = 30 m.hasMsgs = false @@ -666,7 +666,7 @@ func TestUpdatePreviewHeights_ClampMsgsToOne(t *testing.T) { // height so small that available/3 rounds to 0 → clamp to 1 // infoTotalHeight=8, sep+sectionHeaderLines=2; available = height-8-2 = height-10 // need available/3 < 1 -> available < 3 -> height < 13 - m := newModel(makeSessions(), false, nil, nil) + m := newModel(makeSessions(), false, nil, nil, nil) m.width = 100 m.height = 10 m.hasMsgs = true @@ -693,7 +693,7 @@ func TestRenderRowOpencodeIDSingleLine(t *testing.T) { ID: longID, Title: "test session", } - m := newModel([]source.Session{s}, false, nil, nil) + m := newModel([]source.Session{s}, false, nil, nil, nil) m.width, m.height = 120, 40 row := m.renderRow(s, false) @@ -710,7 +710,7 @@ func TestRenderRowClaudeIDSingleLine(t *testing.T) { ID: uuid, Title: "test session", } - m := newModel([]source.Session{s}, false, nil, nil) + m := newModel([]source.Session{s}, false, nil, nil, nil) m.width, m.height = 120, 40 row := m.renderRow(s, false) @@ -737,7 +737,7 @@ func lineCount(s string) int { // highlighted regardless of the terminal color theme. func TestRenderRowSelectedHasReverseVideo(t *testing.T) { s := source.Session{Client: source.ClientClaude, ID: "abc", Title: "test"} - m := newModel([]source.Session{s}, false, nil, nil) + m := newModel([]source.Session{s}, false, nil, nil, nil) m.width, m.height = 120, 40 selected := m.renderRow(s, true) @@ -790,7 +790,7 @@ func TestRenderRowFitsInPreviewListWidth_Combined(t *testing.T) { } termW := 105 lw := termW * 6 / 10 // 63 - m := newModel([]source.Session{s}, true, nil, nil) // combined=true + m := newModel([]source.Session{s}, true, nil, nil, nil) // combined=true m.width, m.height = termW, 40 m.state = stateListPreview @@ -814,7 +814,7 @@ func TestRenderRowFitsInPreviewListWidth(t *testing.T) { } termW := 105 lw := termW * 6 / 10 // 63 - m := newModel([]source.Session{s}, false, nil, nil) + m := newModel([]source.Session{s}, false, nil, nil, nil) m.width, m.height = termW, 40 m.state = stateListPreview @@ -828,7 +828,7 @@ func TestRenderRowFitsInPreviewListWidth(t *testing.T) { func TestUpdatePreviewHeights_ClampDirToOne(t *testing.T) { // height so small that dir available <= 0 → clamp to 1 // infoTotalHeight=6, sectionHeaderLines=2; height=8 → available=0 → clamp - m := newModel(makeSessions(), false, nil, nil) + m := newModel(makeSessions(), false, nil, nil, nil) m.width = 100 m.height = 8 m.hasMsgs = false @@ -849,7 +849,7 @@ func TestRenderRowSelected_SepColorsMatchAdjacentCells(t *testing.T) { ID: "1ab683ce-f9fc-4799-a67e-48211866f4de", Title: "test", } - m := newModel([]source.Session{s}, false, nil, nil) + m := newModel([]source.Session{s}, false, nil, nil, nil) m.width, m.height = 120, 40 row := m.renderRow(s, true) @@ -900,7 +900,7 @@ func TestApplyRefresh_CursorAnchor(t *testing.T) { sessA := source.Session{Client: source.ClientClaude, ID: "sess-a", Title: "Session A", CWD: "/tmp/proj", Time: time.Now().Add(-2 * time.Second)} sessB := source.Session{Client: source.ClientClaude, ID: "sess-b", Title: "Session B", CWD: "/tmp/proj", Time: time.Now().Add(-1 * time.Second)} - m := newModel([]source.Session{sessA, sessB}, false, nil, nil) + m := newModel([]source.Session{sessA, sessB}, false, nil, nil, nil) m.cursor = 1 // pointing at sess-b // Simulate a refresh that updates both files. @@ -921,7 +921,7 @@ func TestApplyRefresh_PendingInPreview(t *testing.T) { pathA := makeJSONLFile(t, base, "proj-c", "sess-c", "Session C") sessC := source.Session{Client: source.ClientClaude, ID: "sess-c", Title: "Session C", CWD: "/tmp/proj", Time: time.Now()} - m := newModel([]source.Session{sessC}, false, nil, nil) + m := newModel([]source.Session{sessC}, false, nil, nil, nil) m.state = stateListPreview // Send a RefreshMsg while in preview mode. @@ -958,7 +958,7 @@ func TestApplyRefresh_PendingInPreview(t *testing.T) { func TestProcsPollMsg_ClearsActiveConfsWhenNoProcs(t *testing.T) { sessions := makeSessions() - m := newModel(sessions, false, nil, nil) + m := newModel(sessions, false, nil, nil, nil) // Mark session 0 as confirmed active. m.activeConfs = map[string]activeConf{sessions[0].ID: activeConfirmed} @@ -973,7 +973,7 @@ func TestProcsPollMsg_ClearsActiveConfsWhenNoProcs(t *testing.T) { } func TestProcsPollMsg_ReturnsNextPollCmd(t *testing.T) { - m := newModel(makeSessions(), false, nil, nil) + m := newModel(makeSessions(), false, nil, nil, nil) _, cmd := m.Update(procsPollMsg{procs: nil}) if cmd == nil { t.Error("Update(procsPollMsg) should return a non-nil cmd to continue the poll loop") @@ -985,7 +985,7 @@ func TestProcsPollMsg_ReturnsNextPollCmd(t *testing.T) { // the main goroutine concurrently modifies m.sessions. Run with -race. func TestScheduleProcsPollCmd_NoSharedStateWithMainGoroutine(t *testing.T) { sessions := makeSessions() - m := newModel(sessions, false, nil, nil) + m := newModel(sessions, false, nil, nil, nil) // Capture the cmd (this is what Init/Update schedule). _, cmd := m.Update(procsPollMsg{procs: nil}) @@ -1008,7 +1008,7 @@ func TestScheduleProcsPollCmd_NoSharedStateWithMainGoroutine(t *testing.T) { } func TestTickMsg_AdvancesSlowFrameEvery5Ticks(t *testing.T) { - m := newModel(makeSessions(), false, nil, nil) + m := newModel(makeSessions(), false, nil, nil, nil) m.tickCount = 0 m.slowFrame = 0 @@ -1043,7 +1043,7 @@ func TestTickMsg_AdvancesSlowFrameEvery5Ticks(t *testing.T) { func TestEvictGuessedSiblings_EvictsWhenAllProcsConfirmed(t *testing.T) { s1 := source.Session{ID: "s1", CWD: "/foo"} s2 := source.Session{ID: "s2", CWD: "/foo"} - m := newModel([]source.Session{s1, s2}, false, nil, nil) + m := newModel([]source.Session{s1, s2}, false, nil, nil, nil) m.activeConfs = map[string]activeConf{"s1": activeGuessed, "s2": activeGuessed} procA := source.ProcInfo{PID: "1", LStart: "ts1", CWD: "/foo"} @@ -1065,7 +1065,7 @@ func TestEvictGuessedSiblings_EvictsWhenAllProcsConfirmed(t *testing.T) { func TestEvictGuessedSiblings_DoesNotEvictWhenUnconfirmedProcRemains(t *testing.T) { s1 := source.Session{ID: "s1", CWD: "/foo"} s2 := source.Session{ID: "s2", CWD: "/foo"} - m := newModel([]source.Session{s1, s2}, false, nil, nil) + m := newModel([]source.Session{s1, s2}, false, nil, nil, nil) m.activeConfs = map[string]activeConf{"s1": activeGuessed, "s2": activeGuessed} procA := source.ProcInfo{PID: "1", LStart: "ts1", CWD: "/foo"} @@ -1094,7 +1094,7 @@ func TestApplyRefresh_EvictsGuessedSiblingOnConfirm(t *testing.T) { sessS1 := source.Session{Client: source.ClientClaude, ID: "sess-s1", Title: "Session S1", CWD: sharedCWD, Time: time.Now().Add(-2 * time.Second)} sessS2 := source.Session{Client: source.ClientClaude, ID: "sess-s2", Title: "Session S2", CWD: sharedCWD, Time: time.Now().Add(-1 * time.Second)} - m := newModel([]source.Session{sessS1, sessS2}, false, nil, nil) + m := newModel([]source.Session{sessS1, sessS2}, false, nil, nil, nil) // Both sessions are initially guessed. m.activeConfs = map[string]activeConf{ @@ -1138,7 +1138,7 @@ func TestSplitPaths_HotMatchesCurrentSession(t *testing.T) { ID: "sess-b", ProjectPath: filepath.Join(base, "proj-b"), } - m := newModel([]source.Session{sessA, sessB}, false, nil, nil) + m := newModel([]source.Session{sessA, sessB}, false, nil, nil, nil) m.cursor = 0 // cursor on sess-a hot, cold := m.splitPaths([]string{pathA, pathB}) @@ -1154,7 +1154,7 @@ func TestSplitPaths_HotMatchesCurrentSession(t *testing.T) { // TestSplitPaths_EmptyFilteredReturnsAllCold verifies that when filtered is // empty, splitPaths returns all paths as cold (no hot paths). func TestSplitPaths_EmptyFilteredReturnsAllCold(t *testing.T) { - m := newModel([]source.Session{}, false, nil, nil) + m := newModel([]source.Session{}, false, nil, nil, nil) hot, cold := m.splitPaths([]string{"/some/path.jsonl"}) if len(hot) != 0 { t.Errorf("expected no hot paths when filtered is empty, got %v", hot) @@ -1179,7 +1179,7 @@ func TestLiveRefreshUpdatesPreview(t *testing.T) { CWD: "/tmp/proj", Time: time.Now(), } - m := newModel([]source.Session{sessA}, false, nil, nil) + m := newModel([]source.Session{sessA}, false, nil, nil, nil) m.state = stateListPreview m.width, m.height = 120, 40 m.loadPreview() @@ -1230,7 +1230,7 @@ func TestLiveRefreshBuffersOtherPaths(t *testing.T) { CWD: "/tmp/proj", Time: time.Now(), } - m := newModel([]source.Session{sessA, sessB}, false, nil, nil) + m := newModel([]source.Session{sessA, sessB}, false, nil, nil, nil) m.state = stateListPreview m.width, m.height = 120, 40 // cursor on sess-a (index 0 after sort by time: sessB is newer so index 0) @@ -1274,7 +1274,7 @@ func TestApplyRefresh_ReguessesOnTimeChange(t *testing.T) { sessS1 := source.Session{Client: source.ClientClaude, ID: "sess-s1", Title: "Session S1", CWD: sharedCWD, Time: older} sessS2 := source.Session{Client: source.ClientClaude, ID: "sess-s2", Title: "Session S2", CWD: sharedCWD, Time: newer} - m := newModel([]source.Session{sessS1, sessS2}, false, nil, nil) + m := newModel([]source.Session{sessS1, sessS2}, false, nil, nil, nil) // Only S2 is guessed initially (it was the most-recently-active). m.activeConfs = map[string]activeConf{ @@ -1309,7 +1309,7 @@ func TestApplyRefresh_ReguessesOnTimeChange(t *testing.T) { // TestColOffset_InitiallyZero verifies that a new model has no horizontal offset. func TestColOffset_InitiallyZero(t *testing.T) { - m := newModel(makeSessions(), false, nil, nil) + m := newModel(makeSessions(), false, nil, nil, nil) if m.colOffset != 0 { t.Errorf("colOffset = %d, want 0", m.colOffset) } @@ -1318,7 +1318,7 @@ func TestColOffset_InitiallyZero(t *testing.T) { // TestColOffset_RightKeyIncrements verifies that pressing right increases colOffset // when the content is wider than the terminal. func TestColOffset_RightKeyIncrements(t *testing.T) { - m := newModel(makeSessions(), false, nil, nil) + m := newModel(makeSessions(), false, nil, nil, nil) m.width, m.height = 40, 40 // narrow: row content overflows m.updateMaxColOffset() updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRight}) @@ -1330,7 +1330,7 @@ func TestColOffset_RightKeyIncrements(t *testing.T) { // TestColOffset_LeftKeyDecrements verifies that pressing left decreases colOffset. func TestColOffset_LeftKeyDecrements(t *testing.T) { - m := newModel(makeSessions(), false, nil, nil) + m := newModel(makeSessions(), false, nil, nil, nil) m.width, m.height = 120, 40 m.colOffset = 10 updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyLeft}) @@ -1342,7 +1342,7 @@ func TestColOffset_LeftKeyDecrements(t *testing.T) { // TestColOffset_ClampAtZero verifies that pressing left when colOffset=0 keeps it at 0. func TestColOffset_ClampAtZero(t *testing.T) { - m := newModel(makeSessions(), false, nil, nil) + m := newModel(makeSessions(), false, nil, nil, nil) m.width, m.height = 120, 40 m.colOffset = 0 updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyLeft}) @@ -1354,7 +1354,7 @@ func TestColOffset_ClampAtZero(t *testing.T) { // TestColOffset_ClampAtMax verifies that colOffset never exceeds maxColOffset. func TestColOffset_ClampAtMax(t *testing.T) { - m := newModel(makeSessions(), false, nil, nil) + m := newModel(makeSessions(), false, nil, nil, nil) m.width, m.height = 80, 40 // narrow terminal to have a finite max m.updateMaxColOffset() // Scroll right repeatedly until we reach max. @@ -1375,7 +1375,7 @@ func TestColOffset_ClampAtMax(t *testing.T) { // TestColOffset_ResetOnFilter verifies that changing the search query resets colOffset to 0. func TestColOffset_ResetOnFilter(t *testing.T) { - m := newModel(makeSessions(), false, nil, nil) + m := newModel(makeSessions(), false, nil, nil, nil) m.width, m.height = 120, 40 m.colOffset = 15 updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}}) @@ -1388,7 +1388,7 @@ func TestColOffset_ResetOnFilter(t *testing.T) { // TestColOffset_HeaderScrolls verifies that applying an offset causes the column header // to produce different output (i.e., the leftmost portion is hidden). func TestColOffset_HeaderScrolls(t *testing.T) { - m := newModel(makeSessions(), false, nil, nil) + m := newModel(makeSessions(), false, nil, nil, nil) m.width, m.height = 120, 40 headerAt0 := m.renderColumnHeader() m.colOffset = 5 @@ -1400,7 +1400,7 @@ func TestColOffset_HeaderScrolls(t *testing.T) { // TestColOffset_ShiftWheelDown scrolls right (Shift+WheelDown = horizontal scroll right). func TestColOffset_ShiftWheelDown(t *testing.T) { - m := newModel(makeSessions(), false, nil, nil) + m := newModel(makeSessions(), false, nil, nil, nil) m.width, m.height = 40, 40 m.updateMaxColOffset() updated, _ := m.Update(tea.MouseMsg{Button: tea.MouseButtonWheelDown, Shift: true}) @@ -1412,7 +1412,7 @@ func TestColOffset_ShiftWheelDown(t *testing.T) { // TestColOffset_ShiftWheelUp scrolls left (Shift+WheelUp = horizontal scroll left). func TestColOffset_ShiftWheelUp(t *testing.T) { - m := newModel(makeSessions(), false, nil, nil) + m := newModel(makeSessions(), false, nil, nil, nil) m.width, m.height = 40, 40 m.colOffset = 10 updated, _ := m.Update(tea.MouseMsg{Button: tea.MouseButtonWheelUp, Shift: true}) @@ -1424,7 +1424,7 @@ func TestColOffset_ShiftWheelUp(t *testing.T) { // TestColOffset_WheelRight scrolls right via native horizontal wheel. func TestColOffset_WheelRight(t *testing.T) { - m := newModel(makeSessions(), false, nil, nil) + m := newModel(makeSessions(), false, nil, nil, nil) m.width, m.height = 40, 40 m.updateMaxColOffset() updated, _ := m.Update(tea.MouseMsg{Button: tea.MouseButtonWheelRight}) @@ -1436,7 +1436,7 @@ func TestColOffset_WheelRight(t *testing.T) { // TestColOffset_WheelLeft scrolls left via native horizontal wheel. func TestColOffset_WheelLeft(t *testing.T) { - m := newModel(makeSessions(), false, nil, nil) + m := newModel(makeSessions(), false, nil, nil, nil) m.width, m.height = 40, 40 m.colOffset = 10 updated, _ := m.Update(tea.MouseMsg{Button: tea.MouseButtonWheelLeft}) @@ -1448,7 +1448,7 @@ func TestColOffset_WheelLeft(t *testing.T) { // TestColOffset_WheelDownMovesCursor verifies plain WheelDown moves cursor down in list mode. func TestColOffset_WheelDownMovesCursor(t *testing.T) { - m := newModel(makeSessions(), false, nil, nil) + m := newModel(makeSessions(), false, nil, nil, nil) m.width, m.height = 120, 40 m.cursor = 0 updated, _ := m.Update(tea.MouseMsg{Button: tea.MouseButtonWheelDown}) @@ -1460,7 +1460,7 @@ func TestColOffset_WheelDownMovesCursor(t *testing.T) { // TestColOffset_WheelUpMovesCursor verifies plain WheelUp moves cursor up in list mode. func TestColOffset_WheelUpMovesCursor(t *testing.T) { - m := newModel(makeSessions(), false, nil, nil) + m := newModel(makeSessions(), false, nil, nil, nil) m.width, m.height = 120, 40 m.cursor = 2 updated, _ := m.Update(tea.MouseMsg{Button: tea.MouseButtonWheelUp}) @@ -1476,7 +1476,7 @@ func TestColOffset_WheelUpMovesCursor(t *testing.T) { func TestColOffset_MaxFromWidestVisibleRow(t *testing.T) { short := source.Session{ID: "abc", Title: "Short", CWDDisplay: "~/x"} long := source.Session{ID: "def", Title: "Short", CWDDisplay: "/very/long/path/that/overflows/the/narrow/terminal/width/definitely"} - m := newModel([]source.Session{short, long}, false, nil, nil) + m := newModel([]source.Session{short, long}, false, nil, nil, nil) m.width, m.height = 60, 40 // narrow enough that the long row overflows m.cursor = 0 // cursor is on the short row @@ -1502,7 +1502,7 @@ func TestColOffset_NoScrollWhenContentFits(t *testing.T) { {ID: "abc", Title: "Short title", CWDDisplay: "~/x"}, {ID: "def", Title: "Also short", CWDDisplay: "~/y"}, } - m := newModel(sessions, false, nil, nil) + m := newModel(sessions, false, nil, nil, nil) m.width, m.height = 200, 40 // very wide terminal; content easily fits updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRight}) m2 := updated.(Model) @@ -1515,7 +1515,7 @@ func TestColOffset_NoScrollWhenContentFits(t *testing.T) { // regardless of colOffset (it must not be scrolled away). func TestColOffset_SpinnerAlwaysVisible(t *testing.T) { s := source.Session{Client: source.ClientClaude, ID: "abc", Title: "test"} - m := newModel([]source.Session{s}, false, nil, nil) + m := newModel([]source.Session{s}, false, nil, nil, nil) m.width, m.height = 120, 40 m.colOffset = 5 @@ -1559,7 +1559,7 @@ func TestMouseSGRFragment_DroppedFromSearch(t *testing.T) { {"[<71;49;6M[<71;49;6M[<71;49;6M[<71;49;6M[<71;49;6M[<71;49;6M[", "<71;49;6M"}, } for _, parts := range cases { - m := newModel(makeSessions(), false, nil, nil) + m := newModel(makeSessions(), false, nil, nil, nil) m.width, m.height = 120, 40 for _, part := range parts { updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(part)}) @@ -1576,7 +1576,7 @@ func TestMouseSGRFragment_DroppedFromSearch(t *testing.T) { // is still recognised and dropped rather than passed to the search box. func TestMouseSGRFragment_AltFlag(t *testing.T) { // Simulate ESC consumed → Alt=true, runes = "[<71;49;6M" - m := newModel(makeSessions(), false, nil, nil) + m := newModel(makeSessions(), false, nil, nil, nil) m.width, m.height = 120, 40 msg := tea.KeyMsg{Type: tea.KeyRunes, Alt: true, Runes: []rune("[<71;49;6M")} updated, _ := m.Update(msg) @@ -1593,7 +1593,7 @@ func TestMouseSGRFragment_AltFlag(t *testing.T) { // spinner ANSI sequence, causing fragments like "36m..." to appear as literal text. func TestColOffset_ActiveSpinnerNoCorruption(t *testing.T) { s := source.Session{Client: source.ClientClaude, ID: "abc", Title: "test session"} - m := newModel([]source.Session{s}, false, nil, nil) + m := newModel([]source.Session{s}, false, nil, nil, nil) m.width, m.height = 40, 40 m.activeConfs = map[string]activeConf{s.ID: activeConfirmed} m.spinFrame = 1 // spinnerFrames[1] = "✢" — 3-byte UTF-8 @@ -1666,7 +1666,7 @@ func TestProcsPollInterval_Is3Seconds(t *testing.T) { // cursor position (1-indexed). func TestRenderStatusBar_CursorPosition(t *testing.T) { sessions := makeSessions() // 3 sessions - m := newModel(sessions, false, nil, nil) + m := newModel(sessions, false, nil, nil, nil) m.width, m.height = 120, 40 m.cursor = 1 @@ -1680,7 +1680,7 @@ func TestRenderStatusBar_CursorPosition(t *testing.T) { // TestRenderStatusBar_CursorPositionFirst verifies cursor=0 shows "1/N". func TestRenderStatusBar_CursorPositionFirst(t *testing.T) { sessions := makeSessions() - m := newModel(sessions, false, nil, nil) + m := newModel(sessions, false, nil, nil, nil) m.width, m.height = 120, 40 m.cursor = 0 @@ -1694,7 +1694,7 @@ func TestRenderStatusBar_CursorPositionFirst(t *testing.T) { // TestRenderStatusBar_Error verifies that a non-fatal error status renders // concise error text. func TestRenderStatusBar_Error(t *testing.T) { - m := newModel(makeSessions(), false, nil, nil) + m := newModel(makeSessions(), false, nil, nil, nil) m.width, m.height = 120, 40 m.statusText = "Claude load failed; showing Opencode sessions" m.statusIsErr = true @@ -1709,7 +1709,7 @@ func TestRenderStatusBar_Error(t *testing.T) { // TestRenderStatusBar_ErrorUsesErrStyle verifies that statusIsErr=true causes // the status text to render with error color (ColorError), not muted color. func TestRenderStatusBar_ErrorUsesErrStyle(t *testing.T) { - m := newModel(makeSessions(), false, nil, nil) + m := newModel(makeSessions(), false, nil, nil, nil) m.width, m.height = 120, 40 m.statusText = "Claude load failed" m.statusIsErr = true @@ -1729,7 +1729,7 @@ func TestRenderStatusBar_ErrorUsesErrStyle(t *testing.T) { // trailing separator spaces are rendered as a single Render call to produce // a single ANSI segment (no spurious reset between text and spaces). func TestRenderStatusBar_StatusTextMerged(t *testing.T) { - m := newModel(makeSessions(), false, nil, nil) + m := newModel(makeSessions(), false, nil, nil, nil) m.width, m.height = 120, 40 m.statusText = "status" @@ -1744,7 +1744,7 @@ func TestRenderStatusBar_StatusTextMerged(t *testing.T) { // TestRenderStatusBar_EmptyResult verifies that the empty-result state renders // in the status bar when loading completes with no sessions. func TestRenderStatusBar_EmptyResult(t *testing.T) { - m := newModel([]source.Session{}, false, nil, nil) + m := newModel([]source.Session{}, false, nil, nil, nil) m.width, m.height = 120, 40 m.statusText = "No sessions found." @@ -1758,7 +1758,7 @@ func TestRenderStatusBar_EmptyResult(t *testing.T) { // TestRenderStatusBar_HiddenWhenNoSessions verifies that when there are no // sessions and no statusText, renderStatusBar returns an empty string. func TestRenderStatusBar_HiddenWhenNoSessions(t *testing.T) { - m := newModel([]source.Session{}, false, nil, nil) + m := newModel([]source.Session{}, false, nil, nil, nil) m.width, m.height = 120, 40 m.statusText = "" @@ -1772,7 +1772,7 @@ func TestRenderStatusBar_HiddenWhenNoSessions(t *testing.T) { // accounts for the bottom status row (always reserved, not conditional). func TestRenderList_ReservesStatusRow(t *testing.T) { sessions := makeSessions() - m := newModel(sessions, false, nil, nil) + m := newModel(sessions, false, nil, nil, nil) m.width, m.height = 120, 40 list := m.renderList() @@ -1792,7 +1792,7 @@ func TestRenderList_ReservesStatusRow(t *testing.T) { // TestRenderPreview_ReservesStatusRow verifies that preview layout accounts // for the bottom status row (always reserved, not conditional on statusText). func TestRenderPreview_ReservesStatusRow(t *testing.T) { - m := newModel(makeSessions(), false, nil, nil) + m := newModel(makeSessions(), false, nil, nil, nil) m.width, m.height = 120, 40 m.state = stateListPreview m.hasMsgs = false @@ -1810,7 +1810,7 @@ func TestRenderPreview_ReservesStatusRow(t *testing.T) { // TestRenderStatusBar_TruncatesToWidth verifies that long status text does // not overflow the terminal width. func TestRenderStatusBar_TruncatesToWidth(t *testing.T) { - m := newModel(makeSessions(), false, nil, nil) + m := newModel(makeSessions(), false, nil, nil, nil) m.width, m.height = 40, 40 long := strings.Repeat("x", 200) m.statusText = long @@ -1825,7 +1825,7 @@ func TestRenderStatusBar_TruncatesToWidth(t *testing.T) { // TestRenderStatusBar_FitsTerminalWidth verifies that the status bar does not // exceed the terminal width (but may use the full width for right-alignment). func TestRenderStatusBar_FitsTerminalWidth(t *testing.T) { - m := newModel(makeSessions(), false, nil, nil) + m := newModel(makeSessions(), false, nil, nil, nil) m.width, m.height = 40, 40 m.statusText = "status" @@ -1839,7 +1839,7 @@ func TestRenderStatusBar_FitsTerminalWidth(t *testing.T) { // TestRenderStatusBar_KeyHints verifies that the status bar contains key hints. func TestRenderStatusBar_KeyHints(t *testing.T) { sessions := makeSessions() - m := newModel(sessions, false, nil, nil) + m := newModel(sessions, false, nil, nil, nil) m.width, m.height = 120, 40 bar := m.renderStatusBar() @@ -1855,7 +1855,7 @@ func TestRenderStatusBar_KeyHints(t *testing.T) { // TestRenderStatusBar_KeyHintsPreview verifies preview-mode key hints. func TestRenderStatusBar_KeyHintsPreview(t *testing.T) { sessions := makeSessions() - m := newModel(sessions, false, nil, nil) + m := newModel(sessions, false, nil, nil, nil) m.width, m.height = 120, 40 m.state = stateListPreview @@ -1870,7 +1870,7 @@ func TestRenderStatusBar_KeyHintsPreview(t *testing.T) { // position in the status bar. func TestView_ContainsStatusBar(t *testing.T) { sessions := makeSessions() // 3 sessions - m := newModel(sessions, false, nil, nil) + m := newModel(sessions, false, nil, nil, nil) m.width, m.height = 120, 40 m.statusText = "status msg" @@ -1898,7 +1898,7 @@ func TestView_StatusBarOccupiesLastRow(t *testing.T) { } } - m := newModel(sessions, false, nil, nil) + m := newModel(sessions, false, nil, nil, nil) m.width, m.height = 120, 40 view := m.View() lines := strings.Split(view, "\n") @@ -1926,7 +1926,7 @@ func TestView_PreviewModeOccupiesExactHeight(t *testing.T) { } } - m := newModel(sessions, false, nil, nil) + m := newModel(sessions, false, nil, nil, nil) m.width, m.height = 120, 40 m.state = stateListPreview m.hasMsgs = false @@ -1943,7 +1943,7 @@ func TestView_PreviewModeOccupiesExactHeight(t *testing.T) { // sessions than listHeight, the status bar still occupies the last terminal row. func TestView_StatusBarOnLastRowWithFewSessions(t *testing.T) { // makeSessions() returns 3 sessions — far fewer than a 40-row terminal - m := newModel(makeSessions(), false, nil, nil) + m := newModel(makeSessions(), false, nil, nil, nil) m.width, m.height = 120, 40 m.statusText = "loading" view := m.View() @@ -1975,7 +1975,7 @@ func TestScrollableWidth_UsesStatusAdjustedHeight(t *testing.T) { // Make the last session have a very long directory so it dominates width. sessions[len(sessions)-1].CWDDisplay = "/tmp/this-is-a-very-long-directory-name-that-should-produce-a-much-wider-row" - m := newModel(sessions, false, nil, nil) + m := newModel(sessions, false, nil, nil, nil) m.width, m.height = 120, 25 // listHeight = 25 - 2 - 1 = 22 visible rows. @@ -2026,7 +2026,7 @@ func TestApplySessionBatch_UpsertsByClientID(t *testing.T) { base := time.Now() s1 := makeSession("aaa", "/tmp/a", base) s2 := makeSession("bbb", "/tmp/b", base.Add(-time.Second)) - m := newModel([]source.Session{s1, s2}, false, nil, nil) + m := newModel([]source.Session{s1, s2}, false, nil, nil, nil) // Upsert with updated title for s1 and a new session s3. s1updated := s1 @@ -2069,7 +2069,7 @@ func TestApplySessionBatch_MergeSortsAndClampsCursor(t *testing.T) { for i := range sessions { sessions[i] = makeSession(fmt.Sprintf("id%d", i), "/tmp/x", base.Add(time.Duration(-i)*time.Second)) } - m := newModel(sessions, false, nil, nil) + m := newModel(sessions, false, nil, nil, nil) m.width = 120 m.height = 20 m.cursor = 4 // point at last @@ -2093,7 +2093,7 @@ func TestApplySessionBatch_MergeSortsAndClampsCursor(t *testing.T) { func TestApplySessionBatch_RecomputesWidthsAndFilter(t *testing.T) { base := time.Now() s1 := makeSession("short", "/tmp/a", base) - m := newModel([]source.Session{s1}, false, nil, nil) + m := newModel([]source.Session{s1}, false, nil, nil, nil) m.width = 120 m.height = 20 m.query = "long" @@ -2111,7 +2111,7 @@ func TestApplySessionBatch_RecomputesWidthsAndFilter(t *testing.T) { } func TestLoadingEmptyState_ShowsLoadingWhilePending(t *testing.T) { - m := newModel(nil, false, nil, nil) + m := newModel(nil, false, nil, nil, nil) m.width = 120 m.height = 20 m.loading = true @@ -2126,7 +2126,7 @@ func TestLoadingEmptyState_ShowsLoadingWhilePending(t *testing.T) { } func TestLoadingEmptyState_ShowsNoSessionsAfterDone(t *testing.T) { - m := newModel(nil, false, nil, nil) + m := newModel(nil, false, nil, nil, nil) m.width = 120 m.height = 20 m.loading = false @@ -2221,7 +2221,7 @@ func TestStreamCmd_CoalescesSetssDoneOnClosedMidDrain(t *testing.T) { // --- Non-fatal load error status --- func TestNonFatalLoadError_SetsStatusText(t *testing.T) { - m := newModel(nil, false, nil, nil) + m := newModel(nil, false, nil, nil, nil) m.width = 120 m.height = 20 m.loading = true @@ -2247,7 +2247,7 @@ func TestApplySessionBatch_ReguessesActiveAfterBatch(t *testing.T) { // Pre-populate activeConfs with a stale guessed entry not in the batch. // reguessActive clears stale guessed entries; if it's not called the stale // entry will remain after applySessionBatch. - m := newModel(nil, false, nil, nil) + m := newModel(nil, false, nil, nil, nil) m.loading = true m.activeConfs["stale-id"] = activeGuessed @@ -2261,7 +2261,7 @@ func TestApplySessionBatch_ReguessesActiveAfterBatch(t *testing.T) { func TestEnterOnEmptyFiltered_IsNoOp(t *testing.T) { // Pressing Enter while no sessions are loaded must not quit the picker. - m := newModel(nil, false, nil, nil) + m := newModel(nil, false, nil, nil, nil) m.loading = true m.width = 80 m.height = 24 @@ -2284,7 +2284,7 @@ func TestEnterWithoutNavigation_SelectsCursor0WhenLoadingDone(t *testing.T) { sessions := []source.Session{ {Client: source.ClientClaude, ID: "s1", Title: "Session 1", Time: time.Now()}, } - m := newModel(sessions, false, nil, nil) + m := newModel(sessions, false, nil, nil, nil) m.width = 80 m.height = 24 m.applyFilter() @@ -2310,7 +2310,7 @@ func TestEnterWithoutNavigation_QuitsWithNilChosenDuringLoading(t *testing.T) { sessions := []source.Session{ {Client: source.ClientClaude, ID: "s1", Title: "Session 1", Time: time.Now()}, } - m := newModel(sessions, false, nil, nil) + m := newModel(sessions, false, nil, nil, nil) m.loading = true m.width = 80 m.height = 24 @@ -2334,7 +2334,7 @@ func TestApplyRefresh_ReanchorsWithCompositeKey(t *testing.T) { claude := source.Session{Client: source.ClientClaude, ID: "shared-id", Title: "Claude", Time: time.Now()} opencode := source.Session{Client: source.ClientOpencode, ID: "shared-id", Title: "Opencode", Time: time.Now().Add(-time.Second)} - m := newModel([]source.Session{claude, opencode}, true, nil, nil) + m := newModel([]source.Session{claude, opencode}, true, nil, nil, nil) m.width = 80 m.height = 24 m.applyFilter() @@ -2373,7 +2373,7 @@ func TestFirstDownKey_HighlightsRow0(t *testing.T) { {Client: source.ClientClaude, ID: "s1", Title: "First", Time: time.Now()}, {Client: source.ClientClaude, ID: "s2", Title: "Second", Time: time.Now().Add(-time.Second)}, } - m := newModel(sessions, false, nil, nil) + m := newModel(sessions, false, nil, nil, nil) m.width = 80 m.height = 24 m.applyFilter() diff --git a/source/claude.go b/source/claude.go index 189b77b..f7e5a7a 100644 --- a/source/claude.go +++ b/source/claude.go @@ -195,7 +195,9 @@ func parseOne(jsonlFile, dirName, home, pathFilter string, strictMatch, verbose // ReloadSession re-parses a single JSONL file and returns an updated Session. // The caller is responsible for providing the correct projectPath (parent dir of jsonlFile). -func ReloadSession(jsonlFile string, verbose bool) (Session, error) { +// If cache is non-nil, the parsed metadata is written back so a subsequent cold-start +// will return the fresh title and CWD instead of stale cached values. +func ReloadSession(jsonlFile string, verbose bool, cache *MetaCache) (Session, error) { home, err := os.UserHomeDir() if err != nil { return Session{}, err @@ -219,6 +221,17 @@ func ReloadSession(jsonlFile string, verbose bool) (Session, error) { meta.CWD = decoded } + if cache != nil { + cache.Store(jsonlFile, MetaEntry{ + Mtime: info.ModTime(), + Size: info.Size(), + Title: meta.Title, + CWD: meta.CWD, + MsgCount: meta.MsgCount, + SessionTime: meta.SessionTime, + }) + } + effectiveTime := meta.SessionTime if effectiveTime.IsZero() { effectiveTime = info.ModTime() diff --git a/source/claude_test.go b/source/claude_test.go index 236caeb..8ac0c19 100644 --- a/source/claude_test.go +++ b/source/claude_test.go @@ -724,7 +724,7 @@ func TestReloadSession_UpdatesTitleAndCount(t *testing.T) { t.Fatal(err) } - s1, err := ReloadSession(jsonlPath, false) + s1, err := ReloadSession(jsonlPath, false, nil) if err != nil { t.Fatalf("ReloadSession initial: %v", err) } @@ -744,7 +744,7 @@ func TestReloadSession_UpdatesTitleAndCount(t *testing.T) { f.WriteString("\n" + strings.Join(extra, "\n")) f.Close() - s2, err := ReloadSession(jsonlPath, false) + s2, err := ReloadSession(jsonlPath, false, nil) if err != nil { t.Fatalf("ReloadSession updated: %v", err) } @@ -1265,7 +1265,7 @@ func TestReloadSession_UsesJSONLTimestamp(t *testing.T) { if err := os.WriteFile(jsonlPath, []byte(strings.Join(lines, "\n")), 0o644); err != nil { t.Fatal(err) } - s, err := ReloadSession(jsonlPath, false) + s, err := ReloadSession(jsonlPath, false, nil) if err != nil { t.Fatalf("ReloadSession: %v", err) } @@ -1293,7 +1293,7 @@ func TestReloadSession_FallsBackToMtimeWhenNoTimestamp(t *testing.T) { if err != nil { t.Fatal(err) } - s, err := ReloadSession(jsonlPath, false) + s, err := ReloadSession(jsonlPath, false, nil) if err != nil { t.Fatalf("ReloadSession: %v", err) } @@ -1302,6 +1302,46 @@ func TestReloadSession_FallsBackToMtimeWhenNoTimestamp(t *testing.T) { } } +func TestReloadSession_WritesMetaCache(t *testing.T) { + dir := t.TempDir() + projectDir := filepath.Join(dir, "-tmp-wmc") + if err := os.MkdirAll(projectDir, 0o755); err != nil { + t.Fatal(err) + } + jsonlPath := filepath.Join(projectDir, "wmc123.jsonl") + lines := []string{ + `{"type":"summary","cwd":"/tmp/wmc"}`, + `{"type":"user","message":{"content":"cache me"}}`, + } + if err := os.WriteFile(jsonlPath, []byte(strings.Join(lines, "\n")), 0o644); err != nil { + t.Fatal(err) + } + + cache := newMetaCacheWithPath(filepath.Join(dir, "meta.gob")) + s, err := ReloadSession(jsonlPath, false, cache) + if err != nil { + t.Fatalf("ReloadSession: %v", err) + } + if s.Title != "cache me" { + t.Errorf("Title = %q, want \"cache me\"", s.Title) + } + + info, err := os.Stat(jsonlPath) + if err != nil { + t.Fatal(err) + } + entry, hit := cache.Lookup(jsonlPath, info.ModTime(), info.Size()) + if !hit { + t.Fatal("expected cache hit after ReloadSession, got miss") + } + if entry.Title != "cache me" { + t.Errorf("cached Title = %q, want \"cache me\"", entry.Title) + } + if entry.CWD != "/tmp/wmc" { + t.Errorf("cached CWD = %q, want \"/tmp/wmc\"", entry.CWD) + } +} + // BenchmarkLoadClaude exercises LoadClaude against a temp directory with 20 JSONL files. // Run with: go test -bench=BenchmarkLoadClaude -benchtime=5s ./source/ func BenchmarkLoadClaude(b *testing.B) { From facc32f16925f40cc840329e3a7b4920f7c633a6 Mon Sep 17 00:00:00 2001 From: gadflysu Date: Tue, 16 Jun 2026 17:23:22 +0800 Subject: [PATCH 2/2] fix(source): share MetaCache instance between loader and picker refresh Prevents two independent MetaCache snapshots from clobbering each other when applyRefresh saves new metadata while LoadClaudeStream is still running and saves its own snapshot afterwards. --- main.go | 2 +- source/claude.go | 21 +++++++++++++++++---- source/claude_test.go | 31 +++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 5 deletions(-) diff --git a/main.go b/main.go index 4621872..92ae72d 100644 --- a/main.go +++ b/main.go @@ -111,7 +111,7 @@ func runInteractiveStreaming(cfg cmd.Config, from, until *time.Time) { loadWg.Add(1) go func() { defer loadWg.Done() - err := source.LoadClaudeStream(cfg.PathFilter, strictMatch, cfg.Verbose, emitSession) + err := source.LoadClaudeStreamWithCache(cfg.PathFilter, strictMatch, cfg.Verbose, emitSession, metaCache) if err != nil { emitError("Claude", err) } diff --git a/source/claude.go b/source/claude.go index f7e5a7a..65e2432 100644 --- a/source/claude.go +++ b/source/claude.go @@ -43,21 +43,32 @@ var turnSkipPrefixes = []string{ // LoadClaude returns all Claude Code sessions, optionally filtered by path. func LoadClaude(pathFilter string, strictMatch bool, verbose bool) ([]Session, error) { - return loadClaude(pathFilter, strictMatch, verbose, nil) + return loadClaude(pathFilter, strictMatch, verbose, nil, nil) } // LoadClaudeStream loads Claude sessions, calling emit for each accepted session // as soon as it is parsed. The returned error covers fatal discovery failures only; // individual file parse errors are silently skipped (same as LoadClaude). func LoadClaudeStream(pathFilter string, strictMatch bool, verbose bool, emit func(Session)) error { - _, err := loadClaude(pathFilter, strictMatch, verbose, emit) + _, err := loadClaude(pathFilter, strictMatch, verbose, emit, nil) + return err +} + +// LoadClaudeStreamWithCache is like LoadClaudeStream but uses the provided MetaCache +// instead of creating a fresh one. The caller must call cache.Save() after all +// concurrent writers (loader + picker refresh) are done, or rely on each writer's +// own Save() call — since they share the same in-memory map there is no snapshot +// divergence regardless of call order. +func LoadClaudeStreamWithCache(pathFilter string, strictMatch bool, verbose bool, emit func(Session), cache *MetaCache) error { + _, err := loadClaude(pathFilter, strictMatch, verbose, emit, cache) return err } // loadClaude is the shared implementation for LoadClaude and LoadClaudeStream. // When emit is non-nil, each accepted session is passed to emit from the worker // goroutine immediately after parsing. The returned slice is sorted by Time desc. -func loadClaude(pathFilter string, strictMatch bool, verbose bool, emit func(Session)) ([]Session, error) { +// When cache is nil, a fresh MetaCache is loaded from disk. +func loadClaude(pathFilter string, strictMatch bool, verbose bool, emit func(Session), cache *MetaCache) ([]Session, error) { home, err := os.UserHomeDir() if err != nil { return nil, err @@ -72,7 +83,9 @@ func loadClaude(pathFilter string, strictMatch bool, verbose bool, emit func(Ses return nil, err } - cache := LoadMetaCache() + if cache == nil { + cache = LoadMetaCache() + } // Collect all (jsonlFile, projectDirName) pairs first. type fileEntry struct { diff --git a/source/claude_test.go b/source/claude_test.go index 8ac0c19..f1f06ee 100644 --- a/source/claude_test.go +++ b/source/claude_test.go @@ -972,6 +972,37 @@ func TestLoadClaudeStream_EmitsSameSessionsAsLoadClaude(t *testing.T) { } } +func TestLoadClaudeStreamWithCache_UsesSharedInstance(t *testing.T) { + lines := []string{ + `{"type":"summary","cwd":"/tmp/shared"}`, + `{"type":"user","message":{"content":"shared cache test"}}`, + } + home, _, jsonlPath := makeClaudeProjectsDir(t, lines) + t.Setenv("HOME", home) + + cache := newMetaCacheWithPath(filepath.Join(t.TempDir(), "meta.gob")) + var emitted []Session + err := LoadClaudeStreamWithCache("", false, false, func(s Session) { emitted = append(emitted, s) }, cache) + if err != nil { + t.Fatalf("LoadClaudeStreamWithCache: %v", err) + } + if len(emitted) != 1 { + t.Fatalf("expected 1 session, got %d", len(emitted)) + } + + info, err := os.Stat(jsonlPath) + if err != nil { + t.Fatal(err) + } + entry, hit := cache.Lookup(jsonlPath, info.ModTime(), info.Size()) + if !hit { + t.Fatal("expected cache hit in shared instance after LoadClaudeStreamWithCache") + } + if entry.Title != "shared cache test" { + t.Errorf("cached Title = %q, want \"shared cache test\"", entry.Title) + } +} + func TestLoadClaude_BlockingAPIUnchanged(t *testing.T) { lines := []string{ `{"type":"summary","cwd":"/tmp/test"}`,