From 369c0947f0ee4299a7f4c850ddb65ed0fd6e235d Mon Sep 17 00:00:00 2001 From: Zack Penka Date: Sat, 9 May 2026 11:13:52 -0600 Subject: [PATCH 01/12] test(red): add coverage for latestDay, truncate max<=1, and truncatePromptLine edge branch Co-Authored-By: Claude Sonnet 4.6 --- detail_test.go | 18 ++++++++++++++++++ render_test.go | 10 ++++++++++ timeline_test.go | 17 +++++++++++++++++ 3 files changed, 45 insertions(+) diff --git a/detail_test.go b/detail_test.go index f240df8..a0cba4d 100644 --- a/detail_test.go +++ b/detail_test.go @@ -1025,6 +1025,24 @@ func timeFromString(s string) time.Time { return t } +// ----- truncate edge branches ----- + +func TestTruncate_MaxOne(t *testing.T) { + // max=1: only one rune fits, no room for ellipsis + got := truncate("abc", 1) + if got != "a" { + t.Errorf("truncate(%q, 1) = %q, want %q", "abc", got, "a") + } +} + +func TestTruncate_ExactFit(t *testing.T) { + // exact fit: no truncation needed, no ellipsis + got := truncate("ab", 2) + if got != "ab" { + t.Errorf("truncate(%q, 2) = %q, want %q", "ab", got, "ab") + } +} + // FuzzParseTurnsFromJSONL fuzz-tests the JSONL turn parser. // Seeds cover valid turns and common malformed inputs. func FuzzParseTurnsFromJSONL(f *testing.F) { diff --git a/render_test.go b/render_test.go index 4cb3997..06b423d 100644 --- a/render_test.go +++ b/render_test.go @@ -1163,3 +1163,13 @@ func TestListHeader_OmitsSkippedWhenNoWarnings(t *testing.T) { t.Errorf("list header without warnings should not mention 'skipped', got: %q", out) } } + +// ----- truncatePromptLine edge branch (Task 1) ----- + +func TestTruncatePromptLine_MaxOne(t *testing.T) { + // max=1: only one rune fits, no room for ellipsis + got := truncatePromptLine("hello", 1) + if got != "h" { + t.Errorf("truncatePromptLine(%q, 1) = %q, want %q", "hello", got, "h") + } +} diff --git a/timeline_test.go b/timeline_test.go index a74def4..790f622 100644 --- a/timeline_test.go +++ b/timeline_test.go @@ -243,3 +243,20 @@ func TestRenderTimelineView_FooterShowsHighlightedDate(t *testing.T) { t.Errorf("timeline footer should show highlighted date %q:\n%s", want, out) } } + +// ----- latestDay ----- + +func TestHeatmap_LatestDay(t *testing.T) { + // now = 2026-05-15 (Friday) + // mondayOf(2026-05-15) = 2026-05-11 + // earliest = 2026-05-11 - 49 days = 2026-03-23 + // rightmost column Monday = 2026-05-11 + // row 6 (Sunday) of rightmost column = 2026-05-11 + 6 = 2026-05-17 + now := time.Date(2026, 5, 15, 0, 0, 0, 0, time.UTC) + hm := buildHeatmap(nil, now) + got := hm.latestDay() + want := time.Date(2026, 5, 17, 0, 0, 0, 0, time.UTC) + if !got.Equal(want) { + t.Errorf("latestDay() = %v, want %v", got, want) + } +} From 0d8cf6c4c5005aa760b4616e52699376c9142461 Mon Sep 17 00:00:00 2001 From: Zack Penka Date: Sat, 9 May 2026 11:14:33 -0600 Subject: [PATCH 02/12] refactor: deduplicate truncate/truncatePromptLine into truncateRunes helper Co-Authored-By: Claude Sonnet 4.6 --- detail.go | 12 +++--------- render_detail.go | 11 +++-------- wrap.go | 15 +++++++++++++++ 3 files changed, 21 insertions(+), 17 deletions(-) diff --git a/detail.go b/detail.go index 69f755b..2671332 100644 --- a/detail.go +++ b/detail.go @@ -321,14 +321,8 @@ func quote(s string) string { return `"` + s + `"` } -// truncate limits s to max runes, adding "…" if truncated +// truncate limits s to max runes, adding "…" if truncated. +// Delegates to truncateRunes (wrap.go) which is the canonical implementation. func truncate(s string, max int) string { - runes := []rune(s) - if len(runes) <= max { - return s - } - if max <= 1 { - return string(runes[:max]) - } - return string(runes[:max-1]) + "…" + return truncateRunes(s, max) } diff --git a/render_detail.go b/render_detail.go index 92c9a7b..845d158 100644 --- a/render_detail.go +++ b/render_detail.go @@ -233,13 +233,8 @@ func renderWriteDiff(input map[string]interface{}, cont string, avail int) []str return out } +// truncatePromptLine limits s to maxLen runes with "…" if truncated. +// Delegates to truncateRunes (wrap.go) which is the canonical implementation. func truncatePromptLine(s string, maxLen int) string { - runes := []rune(s) - if len(runes) <= maxLen { - return s - } - if maxLen <= 1 { - return string(runes[:maxLen]) - } - return string(runes[:maxLen-1]) + "…" + return truncateRunes(s, maxLen) } diff --git a/wrap.go b/wrap.go index 9525aab..7167b39 100644 --- a/wrap.go +++ b/wrap.go @@ -81,6 +81,21 @@ func wrapParagraph(p string, w int) []string { return out } +// truncateRunes limits s to max runes, appending "…" if truncated. +// When max <= 1 there is no room for the ellipsis, so the first max runes are +// returned as-is. Identical logic to the former detail.go::truncate and +// render_detail.go::truncatePromptLine (merged here in v0.9). +func truncateRunes(s string, max int) string { + runes := []rune(s) + if len(runes) <= max { + return s + } + if max <= 1 { + return string(runes[:max]) + } + return string(runes[:max-1]) + "…" +} + // runePrefix returns the first n runes of s. func runePrefix(s string, n int) string { if n <= 0 { From 7927aff17b256697a18f5b3de9277dd5a1552baa Mon Sep 17 00:00:00 2001 From: Zack Penka Date: Sat, 9 May 2026 11:15:12 -0600 Subject: [PATCH 03/12] test(red): direct tests for computeStatsRows and loadSessionDetailCmd Co-Authored-By: Claude Sonnet 4.6 --- model_test.go | 27 +++++++++++++++++++++++++++ stats_test.go | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/model_test.go b/model_test.go index 233d96a..bd131ee 100644 --- a/model_test.go +++ b/model_test.go @@ -165,6 +165,33 @@ func TestLoadSessionsCmd(t *testing.T) { } } +func TestLoadSessionDetailCmd_ReturnsSessionDetailLoadedMsg(t *testing.T) { + // Write a minimal valid JSONL to a temp file + jsonl := `{"type":"user","sessionId":"abc123","timestamp":"2026-05-01T10:00:00Z","cwd":"/test","gitBranch":"main","slug":"test-session","message":{"content":"hello world"}} +` + tmp := t.TempDir() + fpath := filepath.Join(tmp, "sess.jsonl") + if err := os.WriteFile(fpath, []byte(jsonl), 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + + cmd := loadSessionDetailCmd(fpath) + if cmd == nil { + t.Fatal("loadSessionDetailCmd returned nil cmd") + } + msg := cmd() + result, ok := msg.(sessionDetailLoadedMsg) + if !ok { + t.Fatalf("loadSessionDetailCmd produced %T, want sessionDetailLoadedMsg", msg) + } + if result.err != nil { + t.Fatalf("loadSessionDetailCmd error: %v", result.err) + } + if result.turns == nil { + t.Error("turns should be non-nil for a valid session") + } +} + func TestModel_ProjectFilterEntry_PressP(t *testing.T) { m := loadedModel("a", "b") next, _ := m.Update(keyMsg("p")) diff --git a/stats_test.go b/stats_test.go index 02f8628..dfd3d68 100644 --- a/stats_test.go +++ b/stats_test.go @@ -224,6 +224,45 @@ func TestFormatTokenCount(t *testing.T) { } } +// ----- computeStatsRows tests ----- + +func TestComputeStatsRows_SumsTokensFromFile(t *testing.T) { + jsonl := `{"type":"assistant","message":{"model":"claude-sonnet-4-6","content":[],"usage":{"input_tokens":100,"output_tokens":50,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}} +{"type":"assistant","message":{"model":"claude-sonnet-4-6","content":[],"usage":{"input_tokens":200,"output_tokens":75,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}} +` + tmp := t.TempDir() + fpath := tmp + "/sess.jsonl" + if err := os.WriteFile(fpath, []byte(jsonl), 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + rows := computeStatsRows([]Session{{Path: fpath}}) + if len(rows) != 1 { + t.Fatalf("computeStatsRows returned %d rows, want 1", len(rows)) + } + if rows[0].Stats.InputTokens != 300 { + t.Errorf("InputTokens = %d, want 300", rows[0].Stats.InputTokens) + } + if rows[0].Stats.OutputTokens != 125 { + t.Errorf("OutputTokens = %d, want 125", rows[0].Stats.OutputTokens) + } + if rows[0].Stats.EstimatedCostUSD <= 0 { + t.Errorf("EstimatedCostUSD = %f, want > 0", rows[0].Stats.EstimatedCostUSD) + } +} + +func TestComputeStatsRows_MissingFileProducesEmptyStats(t *testing.T) { + rows := computeStatsRows([]Session{{Path: "/nonexistent/path/sess.jsonl"}}) + if len(rows) != 1 { + t.Fatalf("computeStatsRows returned %d rows, want 1", len(rows)) + } + if rows[0].Stats.InputTokens != 0 { + t.Errorf("InputTokens = %d, want 0 for missing file", rows[0].Stats.InputTokens) + } + if rows[0].Stats.OutputTokens != 0 { + t.Errorf("OutputTokens = %d, want 0 for missing file", rows[0].Stats.OutputTokens) + } +} + // ----- LORE_PRICING_FILE override tests ----- func TestEstimateCost_PricingFileOverride(t *testing.T) { From 960f54444dd9b6b778e22042915791f61bebddf0 Mon Sep 17 00:00:00 2001 From: Zack Penka Date: Sat, 9 May 2026 11:16:03 -0600 Subject: [PATCH 04/12] test(red): ensureIndex happy path and FTS5 search branch in handleSearchEntryKey Co-Authored-By: Claude Sonnet 4.6 --- model_test.go | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/model_test.go b/model_test.go index bd131ee..43bd406 100644 --- a/model_test.go +++ b/model_test.go @@ -192,6 +192,45 @@ func TestLoadSessionDetailCmd_ReturnsSessionDetailLoadedMsg(t *testing.T) { } } +func TestEnsureIndex_OpensIndexWhenNilAndDirIsSet(t *testing.T) { + // Override cache dir so we don't pollute the real cache + t.Setenv("LORE_CACHE_DIR", t.TempDir()) + + m := newModel(t.TempDir()) // projectsDir is set + m.loading = false + m.index = nil + m.indexing = false // override: not in background sync + + got := m.ensureIndex() + if got.index == nil { + t.Error("ensureIndex() should have opened the index when index==nil and indexing==false") + } + if got.index != nil { + got.index.Close() + } +} + +func TestHandleSearchEntryKey_UsesFTS5WhenIndexPresent(t *testing.T) { + // Open a real in-memory-backed index (empty db) + idx, err := OpenIndex(t.TempDir()) + if err != nil { + t.Fatalf("OpenIndex: %v", err) + } + defer idx.Close() + + m := loadedModel("a", "b") + m.mode = modeSearch + m.searchMode = searchModeEntry + m.index = idx + m.searchQuery = "hello" + + next, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + nm := next.(model) + if nm.searchMode != searchModeResults { + t.Errorf("after enter with FTS5 index present, searchMode = %d, want searchModeResults (%d)", nm.searchMode, searchModeResults) + } +} + func TestModel_ProjectFilterEntry_PressP(t *testing.T) { m := loadedModel("a", "b") next, _ := m.Update(keyMsg("p")) From afa80f5f7a298443a6283afbb4394ff7905bd831 Mon Sep 17 00:00:00 2001 From: Zack Penka Date: Sat, 9 May 2026 11:16:47 -0600 Subject: [PATCH 05/12] test(red): searchSessionsFiltered branch-only and empty-query edge cases Co-Authored-By: Claude Sonnet 4.6 --- search_test.go | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/search_test.go b/search_test.go index f1c522c..d6e58bf 100644 --- a/search_test.go +++ b/search_test.go @@ -537,3 +537,37 @@ func writeSearchFixture(t *testing.T, dir, name, project, branch, text string) { t.Fatalf("writeSearchFixture: %v", err) } } + +// ----- Task 4: searchSessionsFiltered branch-only and empty edge cases ----- + +func TestSearchSessionsFiltered_BranchOnly(t *testing.T) { + // Two sessions on different branches; no text query — branch filter only. + sessions := []Session{ + {ID: "1", Branch: "main"}, + {ID: "2", Branch: "feat/other"}, + } + results := searchSessionsFiltered(sessions, "", searchFilters{branch: "main"}) + if len(results) != 1 { + t.Fatalf("branch-only filter: got %d results, want 1", len(results)) + } + if results[0].Session.ID != "1" { + t.Errorf("branch-only filter: got session %q, want %q", results[0].Session.ID, "1") + } + if results[0].HitCount != 1 { + t.Errorf("branch-only filter: HitCount = %d, want 1", results[0].HitCount) + } +} + +func TestSearchSessionsFiltered_EmptyEverything(t *testing.T) { + // No text, no filters: with empty text and empty filters, candidates = all + // sessions. The implementation returns all candidates as filter-only hits + // (HitCount=1 each). Verify no panic and consistent output. + sessions := []Session{ + {ID: "1", Branch: "main"}, + } + results := searchSessionsFiltered(sessions, "", searchFilters{}) + // Current behavior: empty text + no filters → returns all candidates as hits. + if len(results) != 1 { + t.Errorf("empty everything: got %d results, want 1 (all sessions returned as hits)", len(results)) + } +} From 0064bf8ab9d9067db40f253e54cda065dce3480a Mon Sep 17 00:00:00 2001 From: Zack Penka Date: Sat, 9 May 2026 11:17:32 -0600 Subject: [PATCH 06/12] test(red): list footer shows active hint for bookmarkOnly and dateFilter; padTrunc panics or misaligns with multibyte runes Co-Authored-By: Claude Sonnet 4.6 --- render_test.go | 57 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/render_test.go b/render_test.go index 06b423d..26fa6e7 100644 --- a/render_test.go +++ b/render_test.go @@ -1173,3 +1173,60 @@ func TestTruncatePromptLine_MaxOne(t *testing.T) { t.Errorf("truncatePromptLine(%q, 1) = %q, want %q", "hello", got, "h") } } + +// ----- Task 5: list footer active-filter hints ----- + +func TestListFooter_ShowsBookmarkOnlyActiveHint(t *testing.T) { + m := loadedModelWith(Session{Project: "p", Slug: "s", Timestamp: time.Now()}) + m.bookmarkOnly = true + out := renderListFooter(m) + if !strings.Contains(out, "bookmarks") { + t.Errorf("bookmarkOnly footer should contain 'bookmarks', got: %q", out) + } + if !strings.Contains(out, "esc") { + t.Errorf("bookmarkOnly footer should contain 'esc', got: %q", out) + } +} + +func TestListFooter_ShowsDateFilterActiveHint(t *testing.T) { + m := loadedModelWith(Session{Project: "p", Slug: "s", Timestamp: time.Now()}) + m.dateFilter = time.Date(2026, 5, 9, 0, 0, 0, 0, time.UTC) + out := renderListFooter(m) + if !strings.Contains(out, "2026-05-09") { + t.Errorf("dateFilter footer should contain '2026-05-09', got: %q", out) + } + if !strings.Contains(out, "esc") { + t.Errorf("dateFilter footer should contain 'esc', got: %q", out) + } +} + +// ----- Task 6: padTrunc rune-safety ----- + +func TestPadTrunc_MultibyteTruncate(t *testing.T) { + // "日本語テスト" = 6 runes, each 3 bytes = 18 bytes total + // padTrunc with max=4 should give 3 visible runes + "…" = 4 total rune-length + result := padTrunc("日本語テスト", 4) + runes := []rune(result) + if len(runes) != 4 { + t.Fatalf("padTrunc multibyte truncate: rune length = %d, want 4 (got %q)", len(runes), result) + } + if string(runes[3]) != "…" { + t.Errorf("padTrunc multibyte truncate: last rune should be '…', got %q", string(runes[3])) + } +} + +func TestPadTrunc_MultibyteExact(t *testing.T) { + // "日本" = 2 runes, 6 bytes; padTrunc with max=2 should return "日…" (1 + ellipsis) + result := padTrunc("日本", 2) + if result != "日…" { + t.Errorf("padTrunc(%q, 2) = %q, want %q", "日本", result, "日…") + } +} + +func TestPadTrunc_ASCII(t *testing.T) { + // ASCII "hi" with max=6 should pad to "hi " (4 spaces) + result := padTrunc("hi", 6) + if result != "hi " { + t.Errorf("padTrunc(%q, 6) = %q, want %q", "hi", result, "hi ") + } +} From 24932d4a065b874364a674456d3d9227be280a30 Mon Sep 17 00:00:00 2001 From: Zack Penka Date: Sat, 9 May 2026 11:18:00 -0600 Subject: [PATCH 07/12] fix(green): render bookmarkOnly and dateFilter active-filter hints in list footer Co-Authored-By: Claude Sonnet 4.6 --- render_list.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/render_list.go b/render_list.go index 1cfdbdc..4cd4e32 100644 --- a/render_list.go +++ b/render_list.go @@ -80,6 +80,12 @@ func renderListFooter(m model) string { if m.flashMsg != "" { return flashStyle.Render(" " + m.flashMsg) } + if m.bookmarkOnly { + return footerStyle.Render(" bookmarks only esc clear q quit") + } + if !m.dateFilter.IsZero() { + return footerStyle.Render(" date: " + m.dateFilter.Format("2006-01-02") + " esc clear q quit") + } if m.filterMode == filterModeProject { return footerStyle.Render(fmt.Sprintf(" project filter: %s_ [enter] apply [esc] cancel", m.filterText)) } From 991e8052106a1ba931a10a27027589da83f55e47 Mon Sep 17 00:00:00 2001 From: Zack Penka Date: Sat, 9 May 2026 11:18:42 -0600 Subject: [PATCH 08/12] fix(green): make padTrunc rune-safe Co-Authored-By: Claude Sonnet 4.6 --- render.go | 13 +++++++------ render_test.go | 11 ++++++++--- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/render.go b/render.go index dd2dcb7..1b86399 100644 --- a/render.go +++ b/render.go @@ -135,13 +135,14 @@ func plural(n int) string { } func padTrunc(s string, max int) string { - if len(s) > max { - if max <= 1 { - return s[:max] - } - return s[:max-1] + "…" + r := []rune(s) + if len(r) <= max { + return s + strings.Repeat(" ", max-len(r)) } - return s + strings.Repeat(" ", max-len(s)) + if max <= 1 { + return string(r[:max]) + } + return string(r[:max-1]) + "…" } func renderHelpOverlay(m model) string { diff --git a/render_test.go b/render_test.go index 26fa6e7..97f4ebe 100644 --- a/render_test.go +++ b/render_test.go @@ -1216,10 +1216,15 @@ func TestPadTrunc_MultibyteTruncate(t *testing.T) { } func TestPadTrunc_MultibyteExact(t *testing.T) { - // "日本" = 2 runes, 6 bytes; padTrunc with max=2 should return "日…" (1 + ellipsis) + // "日本" = 2 runes, 6 bytes; padTrunc with max=2 is exact fit → no truncation result := padTrunc("日本", 2) - if result != "日…" { - t.Errorf("padTrunc(%q, 2) = %q, want %q", "日本", result, "日…") + if result != "日本" { + t.Errorf("padTrunc(%q, 2) = %q, want %q", "日本", result, "日本") + } + // "日本語" = 3 runes, max=2 → should truncate to "日…" + result2 := padTrunc("日本語", 2) + if result2 != "日…" { + t.Errorf("padTrunc(%q, 2) = %q, want %q", "日本語", result2, "日…") } } From 46eff542d3941a6e06b11ed71420de3d5dbb7900 Mon Sep 17 00:00:00 2001 From: Zack Penka Date: Sat, 9 May 2026 11:19:16 -0600 Subject: [PATCH 09/12] test(red): extractSessionText assistant text block and skip-non-text paths Co-Authored-By: Claude Sonnet 4.6 --- index_test.go | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/index_test.go b/index_test.go index 3a4cc7e..3bd4a4c 100644 --- a/index_test.go +++ b/index_test.go @@ -449,6 +449,35 @@ func TestExtractSessionText_SkipsThinking(t *testing.T) { } } +// ----- Task 7: extractSessionText assistant text block path ----- + +func TestExtractSessionText_AssistantTextBlock(t *testing.T) { + content := `{"type":"assistant","message":{"content":[{"type":"text","text":"hello world"}]}} +` + text, err := extractSessionText([]byte(content)) + if err != nil { + t.Fatalf("extractSessionText: %v", err) + } + if !containsSubstr(text, "hello world") { + t.Errorf("extractSessionText should contain 'hello world', got: %q", text) + } +} + +func TestExtractSessionText_AssistantSkipsThinkingAndToolUse(t *testing.T) { + content := `{"type":"assistant","message":{"content":[{"type":"thinking","thinking":"private"},{"type":"tool_use","name":"Bash","input":{}},{"type":"text","text":"visible"}]}} +` + text, err := extractSessionText([]byte(content)) + if err != nil { + t.Fatalf("extractSessionText: %v", err) + } + if !containsSubstr(text, "visible") { + t.Errorf("extractSessionText should contain 'visible', got: %q", text) + } + if containsSubstr(text, "private") { + t.Errorf("extractSessionText should NOT contain thinking block 'private', got: %q", text) + } +} + // containsSubstr is a simple case-insensitive substring check helper. func containsSubstr(s, sub string) bool { if len(sub) == 0 { From 67e79655a3672454b6ec06e4f9bee1aa0a11b5d5 Mon Sep 17 00:00:00 2001 From: Zack Penka Date: Sat, 9 May 2026 11:19:29 -0600 Subject: [PATCH 10/12] test(red): version test asserts 0.9.0 Co-Authored-By: Claude Sonnet 4.6 --- lore_test.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/lore_test.go b/lore_test.go index a92d2d4..953d8f1 100644 --- a/lore_test.go +++ b/lore_test.go @@ -9,6 +9,17 @@ import ( "testing" ) +func TestVersion_IsV090(t *testing.T) { + var buf bytes.Buffer + if err := runWith([]string{"-v"}, &buf); err != nil { + t.Fatalf("runWith(-v): %v", err) + } + got := buf.String() + if !strings.Contains(got, "0.9.0") { + t.Errorf("version output = %q, want it to contain '0.9.0'", got) + } +} + func TestDefaultProjectsDir(t *testing.T) { dir, err := defaultProjectsDir() if err != nil { From 45d6e0c3074bb22e79ec69adbf58ed2afc49a730 Mon Sep 17 00:00:00 2001 From: Zack Penka Date: Sat, 9 May 2026 11:19:49 -0600 Subject: [PATCH 11/12] feat(green): bump version to 0.9.0 Co-Authored-By: Claude Sonnet 4.6 --- lore.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lore.go b/lore.go index c9da7c6..709e04d 100644 --- a/lore.go +++ b/lore.go @@ -14,7 +14,7 @@ import ( // Version is the lore binary version. Set at build time via ldflags by GoReleaser; // falls back to the literal below for local builds. -var Version = "0.8.0" +var Version = "0.9.0" // Run is the entry point used by cmd/lore/main.go. func Run() error { From 9c4bcbcd26e6c379be98c41af9493e9d19670b13 Mon Sep 17 00:00:00 2001 From: Zack Penka Date: Sat, 9 May 2026 11:21:45 -0600 Subject: [PATCH 12/12] =?UTF-8?q?chore:=20pre-PR=20cleanup=20=E2=80=94=20r?= =?UTF-8?q?emove=20planning=20files,=20finalize=20docs=20for=20v0.9.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 15 ++++++++++++--- DESIGN.md | 3 ++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 601fdac..f12aa7f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co **lore** is a keyboard-driven TUI (Terminal User Interface) for browsing Claude Code session history. It reads session transcripts from `~/.claude/projects//*.jsonl` and provides rich navigation, filtering, and search across sessions. -Current status: **v0.8.0 — All planned phases plus the v0.8 playbook complete.** Implemented: +Current status: **v0.9.0 — All planned phases plus the v0.9 quality pass complete.** Implemented: - Session list (3.1) with relative-time bucketing and query preview (first user message). - Inline project (`p`), branch (`b`), and fuzzy (`f`) filters with fuzzy ranking (DRY'd in v0.7 around a single `fuzzyFilterSessions` helper). @@ -27,6 +27,7 @@ Current status: **v0.8.0 — All planned phases plus the v0.8 playbook complete. - **v0.8 env vars**: `LORE_CACHE_DIR` overrides cache location; `LORE_PRICING_FILE` overrides the embedded `pricing.json` rates. - **v0.8 features**: `R` resumes a session via `claude --resume `; FTS5 index syncs in the background at startup (list header shows `indexing…`); search accepts `project:` and `branch:` prefix filters. - **v0.8 quality**: fuzz targets for `parseSessionMetadata` and `parseTurnsFromJSONL` run 30s per push in CI. +- **v0.9 quality**: `truncate`/`truncatePromptLine` merged into `truncateRunes` helper in `wrap.go`; `padTrunc` is now rune-safe (multibyte UTF-8 names no longer corrupt columns); list footer shows active-filter state (`bookmarkOnly` → `"bookmarks only esc clear"`, `dateFilter` → date + `"esc clear"`); direct tests added for `computeStatsRows`, `loadSessionDetailCmd`, `ensureIndex`, `handleSearchEntryKey` FTS5 path, `searchSessionsFiltered` branch-only and empty cases, `extractSessionText` assistant text blocks, and all edge branches of `latestDay`/`truncate`/`truncatePromptLine`. See `DESIGN.md` for the full product vision and phasing roadmap. @@ -168,10 +169,10 @@ The full key map is also surfaced in-app via the `?` overlay. Authoritative refe - `f`: Fuzzy filter across slug, project, and branch simultaneously. - `m`: Bookmark / unbookmark the selected session (persists to disk). - `R`: Resume the selected session (`claude --resume `). -- `M`: Toggle bookmark-only filter (binary; composes with the fuzzy filters). +- `M`: Toggle bookmark-only filter (binary; composes with the fuzzy filters). When active, the footer shows `"bookmarks only esc clear q quit"`. - `P`: Open the project view scoped to the selected session's CWD. - `S`: Open the usage stats panel. -- `T`: Open the timeline activity heatmap. +- `T`: Open the timeline activity heatmap. Pressing `enter` on a day sets a date filter; the footer then shows the filtered date and `"esc clear"`. - `/`: Enter full-text search. - `esc`: Clear any applied filter (fuzzy / bookmark-only / date). - `?`: Show help overlay. @@ -243,6 +244,14 @@ Body math goes through one of `listBodyLines`, `detailBodyLines`, `searchBodyLin | v0.8 — Fuzz targets in CI (T8) | ✅ Complete (`FuzzParseSessionMetadata`, `FuzzParseTurnsFromJSONL`; non-blocking fuzz CI job) | | v0.8 — Regression test nets (T9) | ✅ Complete (`internal_split_test.go`, `internal_render_split_test.go`, `nav_test.go`) | | v0.8 — Docs + version bump (T10) | ✅ Complete (CLAUDE.md, README.md, DESIGN.md updated; Version = "0.8.0") | +| v0.9 — `latestDay`/`truncate`/`truncatePromptLine` edge coverage (T1) | ✅ Complete (tests + refactor: `truncateRunes` in `wrap.go`) | +| v0.9 — `computeStatsRows` + `loadSessionDetailCmd` direct tests (T2) | ✅ Complete | +| v0.9 — `ensureIndex` + `handleSearchEntryKey` FTS5 coverage (T3) | ✅ Complete | +| v0.9 — `searchSessionsFiltered` branch-only + empty paths (T4) | ✅ Complete | +| v0.9 — Active-filter footer hints for `bookmarkOnly` + `dateFilter` (T5) | ✅ Complete (`render_list.go`; footer shows state + `"esc clear"`) | +| v0.9 — `padTrunc` rune-safe (T6) | ✅ Complete (rewritten with `[]rune` in `render.go`) | +| v0.9 — `extractSessionText` assistant text block path (T7) | ✅ Complete | +| v0.9 — Docs + version bump (T8) | ✅ Complete (CLAUDE.md, README.md, DESIGN.md updated; Version = "0.9.0") | ## Repo Layout diff --git a/DESIGN.md b/DESIGN.md index 96beafc..9f3535d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -2,7 +2,7 @@ A keyboard-driven TUI for browsing your Claude Code session history. -> **Status:** v0.8.0 — All planned phases plus the v0.8 playbook complete. +> **Status:** v0.9.0 — All planned phases plus the v0.8 and v0.9 playbooks complete. > The repo split (Phase 6) has happened — this is the standalone > `github.com/zpenka/lore` module. See [Phasing](#phasing) for status. > @@ -294,6 +294,7 @@ Nothing else. Stay lean. | v0.8 | **Search prefix syntax** — `project:` and `branch:` filter FTS5 and linear-scan results | ✅ Done | | v0.8 | **`LORE_PRICING_FILE` env override** — `go:embed pricing.json` with `sync.Once` loader; env path overrides embedded rates | ✅ Done | | v0.8 | **Fuzz targets in CI** — `FuzzParseSessionMetadata` and `FuzzParseTurnsFromJSONL`; non-blocking fuzz CI job (30s each) | ✅ Done | +| v0.9 | **Quality pass** — `truncateRunes` unifies two duplicate helpers; `padTrunc` rune-safe; list footer active-filter hints; direct tests for `computeStatsRows`, `loadSessionDetailCmd`, `ensureIndex`, `handleSearchEntryKey` FTS5 branch, `searchSessionsFiltered` edge cases, `extractSessionText` assistant blocks | ✅ Done | Beyond the phased work, several quality-of-life items also landed: inline fuzzy ranking for the `p` / `b` filters, a `?` help overlay with