diff --git a/AGENTS.md b/AGENTS.md index bf4921d..5ba2d70 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,6 +89,8 @@ go test -v -cover - `Update()` - Handles keyboard/mouse input, including delete confirmation - `View()` - Renders the TUI with delete confirmation prompt - `renderPreview()` - Renders conversation preview with highlights +- `previewLines()` - Memoised `buildPreviewLines` for the selected conversation (rebuilt only when selection/query changes), avoids per-frame rescans of huge conversations +- `hitCount()` / `countHits()` - Memoised per-query HITS count (messages containing the query), keyed by SessionID, so `formatListItem` doesn't rescan every visible row each frame - `formatListItem()` - Formats a single list row - `deleteConversation()` - Removes conversation file and updates UI state - `pruneConversation()` - Prunes the selected conversation in place (Ctrl+R) and refreshes its size diff --git a/main.go b/main.go index cbc546f..4b04429 100644 --- a/main.go +++ b/main.go @@ -91,7 +91,60 @@ type model struct { confirmPrune bool // Are we in prune confirmation mode? pruneIndex int // Index of item to prune pruneSaved int64 // Bytes the pending prune would reclaim (measured on Ctrl+R) - errorMsg string // Show deletion/prune errors + errorMsg string // Show deletion/prune errors + preview *previewCache // memoised preview lines for the selected conversation + hits *hitCounter // memoised per-query hit counts, keyed by SessionID + lastFilterQuery string // lowercased query the current m.filtered was built from +} + +// previewCache memoises buildPreviewLines for the selected conversation so the +// preview isn't rebuilt (scanning every message) on every frame. It lives behind +// a pointer so it survives the value-receiver copies of model that View makes. +type previewCache struct { + key string + lines []string +} + +// hitCounter memoises HITS (messages containing the query) per conversation for +// the current query, so formatListItem doesn't rescan every visible row's +// messages on every frame. Pointer-held so it survives model value copies. +type hitCounter struct { + query string + byID map[string]int +} + +// countHits is the number of a conversation's messages containing query. +func countHits(conv Conversation, query string) int { + queryLower := strings.ToLower(query) + n := 0 + for _, msg := range conv.Messages { + if strings.Contains(strings.ToLower(msg.Text), queryLower) { + n++ + } + } + return n +} + +// hitCount returns the memoised hit count for item under the current query. +func (m model) hitCount(item listItem) int { + query := m.textInput.Value() + if query == "" { + return 0 + } + if m.hits == nil { // model built without initialModel (e.g. tests) + return countHits(item.conv, query) + } + if m.hits.query != query { + m.hits.query = query + m.hits.byID = make(map[string]int) + } + id := item.conv.SessionID + if h, ok := m.hits.byID[id]; ok { + return h + } + h := countHits(item.conv, query) + m.hits.byID[id] = h + return h } func initialModel(items []listItem, filterQuery string, claudeFlags []string) model { @@ -106,27 +159,56 @@ func initialModel(items []listItem, filterQuery string, claudeFlags []string) mo items: items, textInput: ti, claudeFlags: claudeFlags, + preview: &previewCache{}, + hits: &hitCounter{byID: make(map[string]int)}, } m.updateFilter() return m } -func (m *model) updateFilter() { +// previewLines returns the preview lines for the selected conversation, +// rebuilding only when the selection or query changes. Keyed by SessionID (not +// cursor index) so it stays correct when the filtered list shifts. +func (m model) previewLines() []string { + if len(m.filtered) == 0 { + return nil + } + conv := m.filtered[m.cursor].conv query := m.textInput.Value() - if query == "" { + if m.preview == nil { // model built without initialModel (e.g. tests) + return buildPreviewLines(conv, query) + } + key := conv.SessionID + "\x00" + query + if m.preview.key != key { + m.preview.key = key + m.preview.lines = buildPreviewLines(conv, query) + } + return m.preview.lines +} + +func (m *model) updateFilter() { + queryLower := strings.ToLower(m.textInput.Value()) + if queryLower == "" { // Make a copy to avoid sharing backing array with m.items m.filtered = make([]listItem, len(m.items)) copy(m.filtered, m.items) } else { - // Exact substring matching (case-insensitive) - queryLower := strings.ToLower(query) - m.filtered = make([]listItem, 0) - for _, item := range m.items { + // Incremental narrowing: if the new query contains the previous one, every + // item matching the new query already matched the old one, so filter the + // previous (smaller) result set instead of rescanning every conversation. + source := m.items + if m.lastFilterQuery != "" && strings.Contains(queryLower, m.lastFilterQuery) { + source = m.filtered + } + next := make([]listItem, 0, len(source)) + for _, item := range source { if strings.Contains(item.searchLower, queryLower) { - m.filtered = append(m.filtered, item) + next = append(next, item) } } + m.filtered = next } + m.lastFilterQuery = queryLower // Keep cursor in bounds if m.cursor >= len(m.filtered) { m.cursor = max(0, len(m.filtered)-1) @@ -408,17 +490,8 @@ func (m model) formatListItem(item listItem, selected bool) string { // Message count msgs := len(item.conv.Messages) - // Count messages containing the query - query := m.textInput.Value() - hits := 0 - if query != "" { - queryLower := strings.ToLower(query) - for _, msg := range item.conv.Messages { - if strings.Contains(strings.ToLower(msg.Text), queryLower) { - hits++ - } - } - } + // Number of messages containing the query (memoised per query). + hits := m.hitCount(item) size := formatBytes(item.conv.Size) @@ -533,8 +606,7 @@ func (m model) maxPreviewScroll() int { if len(m.filtered) == 0 { return 0 } - lines := buildPreviewLines(m.filtered[m.cursor].conv, m.textInput.Value()) - return max(0, len(lines)-1) + return max(0, len(m.previewLines())-1) } func (m model) renderPreview(item listItem, height int) string { @@ -550,7 +622,7 @@ func (m model) renderPreview(item listItem, height int) string { header = append(header, "\033[1;33mSession:\033[0m "+highlight(conv.SessionID, query)) header = append(header, "") - msgLines := buildPreviewLines(conv, query) + msgLines := m.previewLines() // memoised; item is always the selected conversation // Apply scroll to messages only (header stays fixed). Clamp locally for this // render; the persisted m.previewScroll is bounded in Update via diff --git a/main_test.go b/main_test.go index 4cd34b5..7587a93 100644 --- a/main_test.go +++ b/main_test.go @@ -678,6 +678,32 @@ func TestDeleteConversationErrorHandling(t *testing.T) { } } +func TestUpdateFilterIncrementalNarrowing(t *testing.T) { + items := []listItem{ + {conv: Conversation{SessionID: "1"}, searchText: "auth gateway", searchLower: "auth gateway"}, + {conv: Conversation{SessionID: "2"}, searchText: "auth service", searchLower: "auth service"}, + {conv: Conversation{SessionID: "3"}, searchText: "billing", searchLower: "billing"}, + } + m := initialModel(items, "", nil) + + check := func(q string, want int) { + t.Helper() + m.textInput.SetValue(q) + m.updateFilter() + if len(m.filtered) != want { + t.Fatalf("query %q: got %d results, want %d", q, len(m.filtered), want) + } + } + check("auth", 2) // full scan + check("auth gateway", 1) // narrowing: filters the previous 2, not all 3 + if m.filtered[0].conv.SessionID != "1" { + t.Errorf("narrowed result should be session 1, got %s", m.filtered[0].conv.SessionID) + } + check("auth", 2) // broadening: "auth gateway" not in "auth" -> full rescan + check("billing", 1) + check("", 3) // cleared +} + func TestUpdateFilter(t *testing.T) { items := []listItem{ {conv: Conversation{SessionID: "test-1"}, searchText: "Hello World foo", searchLower: "hello world foo"}, @@ -1050,6 +1076,59 @@ func TestGetProjectsDir(t *testing.T) { } } +func TestPreviewLinesCachedUntilSelectionOrQueryChanges(t *testing.T) { + items := []listItem{ + {conv: Conversation{SessionID: "s1", Messages: []Message{{Role: "user", Text: "alpha"}}}, searchText: "alpha"}, + {conv: Conversation{SessionID: "s2", Messages: []Message{{Role: "user", Text: "beta"}}}, searchText: "beta"}, + } + m := initialModel(items, "", nil) + + _ = m.previewLines() // build + cache for s1 + // Poison the cache; a cached call must return it without rebuilding. + m.preview.lines = []string{"CACHED"} + if got := m.previewLines(); len(got) != 1 || got[0] != "CACHED" { + t.Errorf("expected cached value, got %v", got) + } + + // Changing the query invalidates the cache → rebuild (not the poison). + m.textInput.SetValue("alpha") + if got := m.previewLines(); len(got) == 1 && got[0] == "CACHED" { + t.Error("changing query should rebuild the preview, not return stale cache") + } + + // Moving the cursor to a different conversation also rebuilds. + m.textInput.SetValue("") + _ = m.previewLines() + m.preview.lines = []string{"CACHED"} + m.cursor = 1 + if got := m.previewLines(); len(got) == 1 && got[0] == "CACHED" { + t.Error("moving the cursor should rebuild the preview") + } +} + +func TestHitCountCachedPerQuery(t *testing.T) { + conv := Conversation{SessionID: "s1", Messages: []Message{ + {Role: "user", Text: "alpha beta"}, + {Role: "assistant", Text: "beta gamma"}, + }} + item := listItem{conv: conv} + m := initialModel([]listItem{item}, "beta", nil) + + if got := m.hitCount(item); got != 2 { + t.Fatalf("hitCount = %d, want 2", got) + } + // Poison the cache; a cached call must return it (no rescan). + m.hits.byID["s1"] = 99 + if got := m.hitCount(item); got != 99 { + t.Errorf("expected cached value 99, got %d", got) + } + // Changing the query invalidates the cache → recompute. + m.textInput.SetValue("gamma") + if got := m.hitCount(item); got != 1 { + t.Errorf("query change should recompute hits: got %d, want 1", got) + } +} + func TestRenderPreview(t *testing.T) { conv := Conversation{ SessionID: "test-123",