Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
3c46c70
test(red): regression net for model.go per-mode handler split
May 9, 2026
27a91f7
refactor(green): split model.go handle*Key functions into per-mode files
May 9, 2026
59d47b0
test(red): regression net for render.go per-mode renderer split
May 9, 2026
c676458
refactor(green): split render.go per-mode render functions into separ…
May 9, 2026
649c862
test(red): exhaustive table test for nav() cursor helper
May 9, 2026
771d70a
refactor(green): extract nav() cursor helper, collapse j/k/d/u/g/G bl…
May 9, 2026
a769d96
test(red): tests for ensureIndex() idempotency
May 9, 2026
be5e8fb
refactor(green): extract ensureIndex() method, collapse lazy FTS5 open
May 9, 2026
3da7c34
test(red): resolveCacheDir env-var precedence and dir-creation tests
May 9, 2026
627de11
feat(green): add LORE_CACHE_DIR env var for cache directory override
May 9, 2026
07684bf
test(red): R key invokes resumeFn with session ID and CWD in list/det…
May 9, 2026
82da093
feat(green): add R key to resume sessions via claude --resume <id>
May 9, 2026
c72219f
test(red): LORE_PRICING_FILE override drives estimateCost with custom…
May 9, 2026
842912d
feat(green): embed pricing.json, add LORE_PRICING_FILE override
May 9, 2026
27142b1
test(red): background FTS5 sync — Init batch, indexReadyMsg, header flag
May 9, 2026
b2b4030
feat(green): background FTS5 sync at startup via Init() tea.Batch
May 9, 2026
67121d7
test(red): parseSearchQuery parser and project:/branch: filter tests
May 9, 2026
61d4230
feat(green): search query syntax for project: and branch: prefixes
May 9, 2026
3a60459
test(red+green): fuzz targets for parseSessionMetadata and parseTurns…
May 9, 2026
5b3c86b
docs: v0.8.0 — phasing table, version bump, docs complete
May 9, 2026
8eaa21e
chore: remove plan.md and narratives.md; update DESIGN.md to v0.8.0
May 9, 2026
de02b87
fix(ci): target single package for fuzz steps — ./... rejected by go …
May 9, 2026
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
19 changes: 19 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,22 @@ jobs:
exit failed
}
' coverage.out

fuzz:
runs-on: ubuntu-latest
# Non-blocking job: fuzz failures don't gate merges until the targets have
# been green for a week; promote to required at that point.
continue-on-error: true

steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v5
with:
go-version: '1.25'

- name: Fuzz parseSessionMetadata (30s)
run: go test -fuzz=FuzzParseSessionMetadata -fuzztime=30s -run=^$ github.com/zpenka/lore

- name: Fuzz parseTurnsFromJSONL (30s)
run: go test -fuzz=FuzzParseTurnsFromJSONL -fuzztime=30s -run=^$ github.com/zpenka/lore
20 changes: 18 additions & 2 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.7.0 — All planned phases plus the v0.7 cleanup and feature pass complete.** Implemented:
Current status: **v0.8.0 — All planned phases plus the v0.8 playbook 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 @@ -23,6 +23,10 @@ Current status: **v0.7.0 — All planned phases plus the v0.7 cleanup and featur
- **Session bookmarks (v0.7, `m` and `M` keys)**: toggle a `★` on any session, persist to `<cacheDir>/lore/bookmarks.json`, filter to bookmarks-only with `M`. Composable with the `f`/`p`/`b` filters.
- **Timeline activity heatmap (v0.7, `T` key)**: 8-week × 7-day grid showing session counts by day; navigate with `h`/`l` (or `←`/`→`), `enter` to filter the list to the highlighted date.
- **Render chrome unification (v0.7)**: every mode goes through dedicated `render*Header` and `render*Footer` functions; back-nav hint is consistent across all sub-views (`q/esc/h/← back`); list shows `q quit`. Skipped sessions surface in the list header as "(N skipped)".
- **v0.8 code split**: `model.go` (handlers → `keys_*.go`) and `render.go` (renderers → `render_*.go`) split into per-mode files; `nav()` helper DRYs cursor movement; `ensureIndex()` extracted.
- **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.

See `DESIGN.md` for the full product vision and phasing roadmap.

Expand All @@ -46,7 +50,7 @@ LORE_PROJECTS_DIR=/path/to/projects ./lore

The binary reads from `~/.claude/projects/` (created by Claude Code) by default, scans for `.jsonl` session transcripts, and displays them in a sortable list grouped by recency (today, yesterday, this week, etc.). The projects directory can be overridden via the `--dir` flag (highest precedence) or the `LORE_PROJECTS_DIR` environment variable; resolution lives in `lore.go::resolveProjectsDir`.

The FTS5 search index is cached at `<os.UserCacheDir>/lore/index.db` (e.g. `~/.cache/lore/index.db` on Linux, `~/Library/Caches/lore/index.db` on macOS) and is populated lazily on first search. Bookmarks are persisted alongside it at `<os.UserCacheDir>/lore/bookmarks.json`.
The FTS5 search index is cached at `<os.UserCacheDir>/lore/index.db` (e.g. `~/.cache/lore/index.db` on Linux, `~/Library/Caches/lore/index.db` on macOS) and is populated lazily on first search. Bookmarks are persisted alongside it at `<os.UserCacheDir>/lore/bookmarks.json`. The cache directory can be overridden via the `LORE_CACHE_DIR` environment variable; resolution lives in `lore.go::resolveCacheDir`. The token pricing table is embedded as `pricing.json` and can be overridden with the `LORE_PRICING_FILE` env var (path to a JSON file with the same schema); useful for enterprise rates.

## Tests

Expand Down Expand Up @@ -163,6 +167,7 @@ The full key map is also surfaced in-app via the `?` overlay. Authoritative refe
- `b`: Inline branch filter.
- `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).
- `P`: Open the project view scoped to the selected session's CWD.
- `S`: Open the usage stats panel.
Expand All @@ -177,6 +182,7 @@ The full key map is also surfaced in-app via the `?` overlay. Authoritative refe
- `space`: Expand or collapse a tool turn (the cursor must be on one). Agent turns with sidechains load the sub-agent conversation inline.
- `y`: Copy the user prompt at-or-before the cursor to the clipboard.
- `r`: Re-run with the current user prompt (enters re-run mode).
- `R`: Resume this session (`claude --resume <id>`).
- `m`: Bookmark / unbookmark this session.
- `/`: Enter full-text search.
- `esc` / `q` / `h` / `←`: Back to list.
Expand Down Expand Up @@ -227,6 +233,16 @@ Body math goes through one of `listBodyLines`, `detailBodyLines`, `searchBodyLin
| v0.7 — Cleanup pass | ✅ Complete (unified footers/headers, DRY filter, layout constants, dead-code removal, missing unit tests, scan warnings) |
| v0.7 — Bookmarks (2A) | ✅ Complete (`bookmark.go`, `m`/`M` keys, ★ markers in list/search/project) |
| v0.7 — Timeline heatmap (2B) | ✅ Complete (`timeline.go`, `T` key, enter filters list to a day) |
| v0.8 — Code split (T1) | ✅ Complete (`model.go` → `keys_*.go`; `render.go` → `render_*.go`; `nav.go` helper) |
| v0.8 — Background FTS5 sync (T2) | ✅ Complete (`Init()` batches session load + index sync; `indexing` flag in header) |
| v0.8 — Resume session `R` (T3) | ✅ Complete (`resumeClaude()` in `rerun.go`; `R` key in list and detail modes) |
| v0.8 — `LORE_CACHE_DIR` env var (T4) | ✅ Complete (`resolveCacheDir()` in `lore.go`; used by `index.go` and `bookmark.go`) |
| v0.8 — Search prefix syntax (T5) | ✅ Complete (`parseSearchQuery()` + `searchSessionsFiltered()` in `search.go`) |
| v0.8 — `LORE_PRICING_FILE` env override (T6) | ✅ Complete (`go:embed pricing.json`; `sync.Once` loader; env override in `stats.go`) |
| v0.8 — `nav()` helper (T7) | ✅ Complete (`nav.go`; all cursor handlers use it; `d`/`u` added to stats mode) |
| 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") |

## Repo Layout

Expand Down
11 changes: 9 additions & 2 deletions 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.7.0 — All planned phases plus the v0.7 cleanup and feature pass complete.
> **Status:** v0.8.0 — All planned phases plus the v0.8 playbook 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 @@ -287,6 +287,13 @@ Nothing else. Stay lean.
| v0.7 | **Cleanup pass** — unified footers/headers, DRY filter logic, layout constants, dead-code removal, missing unit tests, scan warnings surfaced in list header | ✅ Done |
| v0.7 | **Session bookmarks** — `m` toggles a `★`, `M` filters to bookmarks-only; persisted to `<cacheDir>/lore/bookmarks.json` | ✅ Done |
| v0.7 | **Timeline activity heatmap** — `T` opens an 8-week × 7-day grid; `enter` filters list to a day | ✅ Done |
| v0.8 | **Code split** — `model.go` → `keys_*.go`; `render.go` → `render_*.go`; `nav()` cursor helper; `ensureIndex()` method | ✅ Done |
| v0.8 | **Background FTS5 sync** — `Init()` batches session load + index sync; header shows `indexing…` while in progress | ✅ Done |
| v0.8 | **Resume `R` key** — `claude --resume <id>` via `tea.ExecProcess`; available in list and detail modes | ✅ Done |
| v0.8 | **`LORE_CACHE_DIR` env var** — overrides the platform cache dir for index and bookmarks | ✅ Done |
| 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 |

Beyond the phased work, several quality-of-life items also landed:
inline fuzzy ranking for the `p` / `b` filters, a `?` help overlay with
Expand All @@ -302,7 +309,7 @@ search.
`sessions_fts(session_path, content)` plus a `session_meta(path, mtime_ns)`
table for incremental sync.
- Cache DB lives under the platform-appropriate user cache dir
(`os.UserCacheDir()` → `lore/index.db`).
(`os.UserCacheDir()` → `lore/index.db`; override with `LORE_CACHE_DIR`).
- `Index.Sync(projectsDir)` walks the projects dir, compares mtimes
against `session_meta`, and re-indexes only changed files. Deleted
files are pruned.
Expand Down
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,9 @@ go install github.com/zpenka/lore/cmd/lore@latest

Press `?` in any mode for the full keymap. Highlights:

- **List**: `j`/`k` move, `d`/`u` half-page, `g`/`G` jump, `enter` open, `p`/`b` filter project/branch, `f` fuzzy filter, `m` bookmark, `M` bookmark-only filter, `P` project view, `/` search, `S` usage stats, `T` timeline heatmap, `q` quit.
- **Detail**: `d`/`u` half-page, `space` expand a tool turn (Agent turns with sidechains load the sub-conversation inline), `y` copy the nearest user prompt, `r` re-run that prompt, `m` bookmark this session, `/` search, `esc`/`q`/`h`/`←` back.
- **Search**: type → `enter` to run, `j`/`k` or `d`/`u` through hits, `enter` to open, `esc`/`q`/`h`/`←` back.
- **List**: `j`/`k` move, `d`/`u` half-page, `g`/`G` jump, `enter` open, `R` resume, `p`/`b` filter project/branch, `f` fuzzy filter, `m` bookmark, `M` bookmark-only filter, `P` project view, `/` search, `S` usage stats, `T` timeline heatmap, `q` quit.
- **Detail**: `d`/`u` half-page, `space` expand a tool turn (Agent turns with sidechains load the sub-conversation inline), `y` copy the nearest user prompt, `r` re-run that prompt, `R` resume session, `m` bookmark this session, `/` search, `esc`/`q`/`h`/`←` back.
- **Search**: type → `enter` to run, `j`/`k` or `d`/`u` through hits, `enter` to open, `esc`/`q`/`h`/`←` back. Supports `project:<name>` and `branch:<name>` prefix filters, e.g. `project:lore refresh token`.
- **Project**: `j`/`k`, `d`/`u`, `enter` to open, `esc`/`q`/`h`/`←` back. Sessions are grouped by branch.
- **Re-run**: `enter` to spawn `claude` with the chosen prompt and CWD; `esc`/`q`/`h`/`←` to cancel.
- **Stats**: `j`/`k`, `g`/`G` to navigate; `esc`/`q`/`h`/`←` back. Columns: project · branch · model · input/output tokens · estimated cost.
Expand All @@ -46,7 +46,13 @@ Press `?` in any mode for the full keymap. Highlights:
- `--dir <path>` flag (highest precedence)
- `LORE_PROJECTS_DIR` environment variable

The FTS5 search index and bookmarks file are cached under the platform user cache dir (`~/.cache/lore/` on Linux, `~/Library/Caches/lore/` on macOS).
The FTS5 search index and bookmarks file are cached under the platform user cache dir (`~/.cache/lore/` on Linux, `~/Library/Caches/lore/` on macOS). Override with:

- `LORE_CACHE_DIR` environment variable

The token pricing table (used in the stats panel) is embedded as `pricing.json` and can be overridden with:

- `LORE_PRICING_FILE` environment variable (path to a JSON file with the same schema — useful for enterprise rates)

## For contributors

Expand Down
9 changes: 4 additions & 5 deletions bookmark.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package lore
import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
Expand All @@ -14,13 +13,13 @@ import (
// rather than persisting an explicit false. The file lives next to the
// FTS5 search index in the user's cache dir.

// bookmarksFile returns the platform-appropriate path to the bookmarks JSON.
// bookmarksFile returns the path to the bookmarks JSON, respecting LORE_CACHE_DIR.
func bookmarksFile() (string, error) {
cacheDir, err := os.UserCacheDir()
dir, err := resolveCacheDir()
if err != nil {
return "", fmt.Errorf("user cache dir: %w", err)
return "", err
}
return filepath.Join(cacheDir, "lore", "bookmarks.json"), nil
return filepath.Join(dir, "bookmarks.json"), nil
}

// loadBookmarks reads the bookmarks JSON from path. A missing file returns
Expand Down
20 changes: 20 additions & 0 deletions detail_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1024,3 +1024,23 @@ func timeFromString(s string) time.Time {
t, _ := time.Parse(time.RFC3339, s)
return t
}

// FuzzParseTurnsFromJSONL fuzz-tests the JSONL turn parser.
// Seeds cover valid turns and common malformed inputs.
func FuzzParseTurnsFromJSONL(f *testing.F) {
f.Add(`{"type":"user","message":{"content":"hello"}}`)
f.Add(`{"type":"assistant","message":{"content":[{"type":"text","text":"hi"}]}}`)
f.Add(``)
f.Add(`not json`)
f.Add("{\"type\":\"tool_use\",\"message\":{\"content\":[]}}\n{\"type\":\"user\"}")
f.Add(`{"type":"user","message":{"content":["array","of","strings"]}}`)

f.Fuzz(func(t *testing.T, data string) {
defer func() {
if r := recover(); r != nil {
t.Errorf("parseTurnsFromJSONL panicked: %v", r)
}
}()
_, _ = parseTurnsFromJSONL(strings.NewReader(data))
})
}
8 changes: 4 additions & 4 deletions index.go
Original file line number Diff line number Diff line change
Expand Up @@ -261,11 +261,11 @@ func extractSessionText(data []byte) (string, error) {
return strings.Join(parts, "\n"), nil
}

// indexCacheDir returns the platform-appropriate cache directory for the index DB.
// indexCacheDir returns the path to the FTS5 index DB file, respecting LORE_CACHE_DIR.
func indexCacheDir() (string, error) {
cacheDir, err := os.UserCacheDir()
dir, err := resolveCacheDir()
if err != nil {
return "", fmt.Errorf("user cache dir: %w", err)
return "", err
}
return filepath.Join(cacheDir, "lore", "index.db"), nil
return filepath.Join(dir, "index.db"), nil
}
114 changes: 114 additions & 0 deletions internal_render_split_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package lore

import (
"strings"
"testing"
"time"
)

// TestRenderDispatch_AllModes verifies that View() produces non-empty output
// for every mode without panicking. Acts as a regression net for the
// render.go per-mode split in Task 2.
func TestRenderDispatch_AllModes(t *testing.T) {
cases := []struct {
name string
mode int
setup func(m model) model
}{
{
name: "list mode",
mode: modeList,
setup: func(m model) model {
m.sessions = []Session{{ID: "a", Project: "p", Branch: "main", Timestamp: time.Now()}}
m.visibleSessions = m.sessions
return m
},
},
{
name: "detail mode",
mode: modeDetail,
setup: func(m model) model {
m.detailSession = Session{ID: "a", Project: "p", Branch: "main"}
m.turns = []turn{{kind: "user", body: "hello"}}
return m
},
},
{
name: "search entry",
mode: modeSearch,
setup: func(m model) model {
m.searchMode = searchModeEntry
m.searchQuery = "foo"
return m
},
},
{
name: "search results",
mode: modeSearch,
setup: func(m model) model {
m.searchMode = searchModeResults
m.searchResults = []SearchHit{{Session: Session{ID: "x", Project: "p", Branch: "b"}, Snippet: "hi"}}
return m
},
},
{
name: "project mode",
mode: modeProject,
setup: func(m model) model {
m.projectCWD = "/tmp/proj"
m.projectSessions = []Session{{ID: "a", Project: "proj", Branch: "main", Timestamp: time.Now()}}
return m
},
},
{
name: "rerun mode",
mode: modeRerun,
setup: func(m model) model {
m.detailSession = Session{ID: "a", Slug: "test"}
m.rerunPrompt = "do stuff"
m.rerunCWD = "/tmp"
return m
},
},
{
name: "stats mode",
mode: modeStats,
setup: func(m model) model {
m.statsData = []statsRow{{Session: Session{ID: "a", Project: "p", Branch: "main"}}}
return m
},
},
{
name: "timeline mode",
mode: modeTimeline,
setup: func(m model) model {
m.timelineCursor = startOfDay(time.Now())
return m
},
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
m := newModel("/tmp")
m.loading = false
m.mode = tc.mode
m.width = 120
m.height = 40
if tc.setup != nil {
m = tc.setup(m)
}

defer func() {
if r := recover(); r != nil {
t.Errorf("render %s panicked: %v", tc.name, r)
}
}()

out := m.View()
if strings.TrimSpace(out) == "" {
t.Errorf("render %s produced empty output", tc.name)
}
})
}
}
Loading
Loading