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
15 changes: 12 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<encoded-cwd>/*.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).
Expand All @@ -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 <id>`; FTS5 index syncs in the background at startup (list header shows `indexing…`); search accepts `project:<name>` and `branch:<name>` 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.

Expand Down Expand Up @@ -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 <id>`).
- `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.
Expand Down Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
>
Expand Down Expand Up @@ -294,6 +294,7 @@ Nothing else. Stay lean.
| v0.8 | **Search prefix syntax** — `project:<name>` and `branch:<name>` 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
Expand Down
12 changes: 3 additions & 9 deletions detail.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
18 changes: 18 additions & 0 deletions detail_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
29 changes: 29 additions & 0 deletions index_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion lore.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
11 changes: 11 additions & 0 deletions lore_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
66 changes: 66 additions & 0 deletions model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,72 @@ 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 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"))
Expand Down
13 changes: 7 additions & 6 deletions render.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
11 changes: 3 additions & 8 deletions render_detail.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
6 changes: 6 additions & 0 deletions render_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down
72 changes: 72 additions & 0 deletions render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1163,3 +1163,75 @@ 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")
}
}

// ----- 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 is exact fit → no truncation
result := padTrunc("日本", 2)
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, "日…")
}
}

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 ")
}
}
Loading
Loading