diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a60c054..d07cdbe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 23eba29..601fdac 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.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). @@ -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 `/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 `; 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. See `DESIGN.md` for the full product vision and phasing roadmap. @@ -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 `/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 `/lore/bookmarks.json`. +The FTS5 search index is cached at `/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 `/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 @@ -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 `). - `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. @@ -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 `). - `m`: Bookmark / unbookmark this session. - `/`: Enter full-text search. - `esc` / `q` / `h` / `←`: Back to list. @@ -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 diff --git a/DESIGN.md b/DESIGN.md index 38caebc..96beafc 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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. > @@ -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 `/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 ` 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:` 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 | Beyond the phased work, several quality-of-life items also landed: inline fuzzy ranking for the `p` / `b` filters, a `?` help overlay with @@ -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. diff --git a/README.md b/README.md index f79c3c8..28d71fd 100644 --- a/README.md +++ b/README.md @@ -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:` and `branch:` 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. @@ -46,7 +46,13 @@ Press `?` in any mode for the full keymap. Highlights: - `--dir ` 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 diff --git a/bookmark.go b/bookmark.go index 2c12353..0667e05 100644 --- a/bookmark.go +++ b/bookmark.go @@ -3,7 +3,6 @@ package lore import ( "encoding/json" "errors" - "fmt" "io/fs" "os" "path/filepath" @@ -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 diff --git a/detail_test.go b/detail_test.go index 482e067..f240df8 100644 --- a/detail_test.go +++ b/detail_test.go @@ -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)) + }) +} diff --git a/index.go b/index.go index 735a71d..64d6109 100644 --- a/index.go +++ b/index.go @@ -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 } diff --git a/internal_render_split_test.go b/internal_render_split_test.go new file mode 100644 index 0000000..f3a1dbd --- /dev/null +++ b/internal_render_split_test.go @@ -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) + } + }) + } +} diff --git a/internal_split_test.go b/internal_split_test.go new file mode 100644 index 0000000..b698303 --- /dev/null +++ b/internal_split_test.go @@ -0,0 +1,110 @@ +package lore + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +// TestModelDispatch_AllModes verifies that the model dispatches key events to +// each mode's handler without panicking. This is a regression net for the +// per-mode handler split in Task 1: if any function moves and breaks the +// call chain, this test surfaces the failure. +func TestModelDispatch_AllModes(t *testing.T) { + modes := []struct { + name string + mode int + setup func(m model) model + key string + }{ + { + name: "list mode j", + mode: modeList, + key: "j", + }, + { + name: "detail mode j", + mode: modeDetail, + setup: func(m model) model { + m.turns = []turn{{kind: "user", body: "hello"}} + return m + }, + key: "j", + }, + { + name: "search entry mode", + mode: modeSearch, + setup: func(m model) model { + m.searchMode = searchModeEntry + return m + }, + key: "a", + }, + { + name: "search results mode", + mode: modeSearch, + setup: func(m model) model { + m.searchMode = searchModeResults + m.searchResults = []SearchHit{{Session: Session{ID: "x"}}} + return m + }, + key: "j", + }, + { + name: "project mode j", + mode: modeProject, + setup: func(m model) model { + m.projectSessions = []Session{{ID: "x"}} + return m + }, + key: "j", + }, + { + name: "rerun mode esc", + mode: modeRerun, + setup: func(m model) model { + m.rerunFn = func(prompt, cwd string) tea.Cmd { return nil } + return m + }, + key: "esc", + }, + { + name: "stats mode j", + mode: modeStats, + setup: func(m model) model { + m.statsData = []statsRow{{Session: Session{ID: "x"}}} + return m + }, + key: "j", + }, + { + name: "timeline mode h", + mode: modeTimeline, + key: "h", + }, + } + + for _, tc := range modes { + 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("mode %s panicked on key %q: %v", tc.name, tc.key, r) + } + }() + + updated, _ := m.Update(keyMsg(tc.key)) + if updated == nil { + t.Errorf("mode %s: Update returned nil model", tc.name) + } + }) + } +} diff --git a/keys_detail.go b/keys_detail.go new file mode 100644 index 0000000..e7a6bb9 --- /dev/null +++ b/keys_detail.go @@ -0,0 +1,106 @@ +package lore + +import ( + tea "github.com/charmbracelet/bubbletea" +) + +func (m model) handleDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "q", "esc", "h", "left": + m.mode = modeList + m.turns = nil + m.cursorDetail = 0 + m.detailOffset = 0 + m.expandedTurns = make(map[int]bool) + m.sidechainTurns = nil + m.justCopied = false + return m, nil + case "j", "k", "d", "u", "g", "G", "down", "up": + visible := m.visibleTurns() + half := m.bodyHeight() / 2 + if half < 1 { + half = 1 + } + m.cursorDetail = nav(msg.String(), m.cursorDetail, len(visible), half) + m.justCopied = false + m = m.clampDetailOffsetNow() + case " ": + visible := m.visibleTurns() + if m.cursorDetail < len(visible) { + t := visible[m.cursorDetail] + if t.kind == "tool" { + fullIdx := m.visibleIndexToFullIndex(m.cursorDetail) + m.expandedTurns[fullIdx] = !m.expandedTurns[fullIdx] + if m.expandedTurns[fullIdx] && t.sidechainPath != "" { + if _, loaded := m.sidechainTurns[fullIdx]; !loaded { + if scTurns, err := loadSidechainTurns(t.sidechainPath); err == nil { + if m.sidechainTurns == nil { + m.sidechainTurns = make(map[int][]turn) + } + m.sidechainTurns[fullIdx] = scTurns + } + } + } + } else { + m.flashMsg = "space: cursor is not on a tool turn" + } + } + m.justCopied = false + case "y": + visible := m.visibleTurns() + copied := false + if m.cursorDetail < len(visible) { + if t := visible[m.cursorDetail]; t.kind == "user" { + if err := m.clipboardFn(t.body); err == nil { + m.justCopied = true + copied = true + } + } + } + if !copied { + for i := m.cursorDetail - 1; i >= 0; i-- { + if visible[i].kind == "user" { + if err := m.clipboardFn(visible[i].body); err == nil { + m.justCopied = true + copied = true + } + break + } + } + } + if !copied { + m.flashMsg = "y: no user prompt at or before cursor" + } + case "r": + visible := m.visibleTurns() + if m.cursorDetail < len(visible) { + t := visible[m.cursorDetail] + if t.kind == "user" { + m.mode = modeRerun + m.rerunPrompt = t.body + m.rerunCWD = m.detailSession.CWD + } else { + m.flashMsg = "r: cursor is not on a user turn" + } + } + case "R": + return m, m.resumeFn(m.detailSession.ID, m.detailSession.CWD) + case "/": + m.mode = modeSearch + m.searchMode = searchModeEntry + m.searchQuery = "" + m.searchResults = nil + m.searchCursor = 0 + case "m": + on := toggleBookmark(m.bookmarks, m.detailSession.ID) + if on { + m.flashMsg = "bookmarked" + } else { + m.flashMsg = "unbookmarked" + } + if m.bookmarksPath != "" { + _ = saveBookmarks(m.bookmarksPath, m.bookmarks) + } + } + return m, nil +} diff --git a/keys_list.go b/keys_list.go new file mode 100644 index 0000000..baaa435 --- /dev/null +++ b/keys_list.go @@ -0,0 +1,155 @@ +package lore + +import ( + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +func (m model) handleListKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.filterMode != filterModeNone { + return m.handleFilterEntryKey(msg) + } + + switch msg.String() { + case "q": + return m, tea.Quit + case "j", "k", "d", "u", "g", "G", "down", "up": + if !m.loading { + half := m.bodyHeight() / 2 + if half < 1 { + half = 1 + } + m.cursor = nav(msg.String(), m.cursor, len(m.visibleSessions), half) + } + m = m.clampListOffsetNow() + case "p": + if !m.loading { + m.filterMode = filterModeProject + } + case "P": + if !m.loading && len(m.visibleSessions) > 0 { + selected := m.visibleSessions[m.cursor] + m.mode = modeProject + m.projectCWD = selected.CWD + m.projectSessions = nil + for _, s := range m.sessions { + if s.CWD == selected.CWD { + m.projectSessions = append(m.projectSessions, s) + } + } + m.projectCursor = 0 + } + case "b": + if !m.loading { + m.filterMode = filterModeBranch + } + case "f": + if !m.loading { + m.filterMode = filterModeFuzzy + } + case "/": + if !m.loading { + m.mode = modeSearch + m.searchMode = searchModeEntry + m.searchQuery = "" + m.searchResults = nil + m.searchCursor = 0 + } + case "S": + if !m.loading { + m.statsData = computeStatsRows(m.sessions) + m.statsCursor = 0 + m.statsOffset = 0 + m.mode = modeStats + } + case "T": + if !m.loading { + m.mode = modeTimeline + m.timelineCursor = startOfDay(time.Now()) + } + case "m": + if !m.loading && len(m.visibleSessions) > 0 { + selected := m.visibleSessions[m.cursor] + on := toggleBookmark(m.bookmarks, selected.ID) + if on { + m.flashMsg = "bookmarked" + } else { + m.flashMsg = "unbookmarked" + } + if m.bookmarksPath != "" { + _ = saveBookmarks(m.bookmarksPath, m.bookmarks) + } + if m.bookmarkOnly { + m.applyFilter() + if m.cursor >= len(m.visibleSessions) { + m.cursor = len(m.visibleSessions) - 1 + } + if m.cursor < 0 { + m.cursor = 0 + } + } + } + case "M": + if !m.loading { + if !m.bookmarkOnly && len(m.bookmarks) == 0 { + m.flashMsg = "no bookmarks yet (press m to mark a session)" + } else { + m.bookmarkOnly = !m.bookmarkOnly + m.applyFilter() + m.cursor = 0 + m.listOffset = 0 + } + } + case "R": + if !m.loading && len(m.visibleSessions) > 0 { + selected := m.visibleSessions[m.cursor] + return m, m.resumeFn(selected.ID, selected.CWD) + } + case "enter", "l", "right": + if !m.loading && len(m.visibleSessions) > 0 { + m.detailLoading = true + selected := m.visibleSessions[m.cursor] + m.detailSession = selected + return m, loadSessionDetailCmd(selected.Path) + } + case "esc": + if m.appliedFilterMode != filterModeNone || m.bookmarkOnly || !m.dateFilter.IsZero() { + m.filterText = "" + m.appliedFilterMode = filterModeNone + m.bookmarkOnly = false + m.dateFilter = time.Time{} + m.applyFilter() + m.cursor = 0 + } + } + return m, nil +} + +func (m model) handleFilterEntryKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyEnter: + m.appliedFilterMode = m.filterMode + m.applyFilter() + m.filterMode = filterModeNone + if len(m.visibleSessions) == 0 { + m.cursor = 0 + } else if m.cursor >= len(m.visibleSessions) { + m.cursor = len(m.visibleSessions) - 1 + } + case tea.KeyEsc: + m.filterText = "" + m.visibleSessions = m.sessions + m.filterMode = filterModeNone + m.appliedFilterMode = filterModeNone + m.cursor = 0 + case tea.KeyBackspace: + runes := []rune(m.filterText) + if len(runes) > 0 { + m.filterText = string(runes[:len(runes)-1]) + } + case tea.KeyRunes: + m.filterText += string(msg.Runes) + } + return m, nil +} diff --git a/keys_project.go b/keys_project.go new file mode 100644 index 0000000..5fbf8e2 --- /dev/null +++ b/keys_project.go @@ -0,0 +1,32 @@ +package lore + +import ( + tea "github.com/charmbracelet/bubbletea" +) + +func (m model) handleProjectKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "q", "esc", "h", "left": + m.mode = modeList + m.projectCWD = "" + m.projectSessions = nil + m.projectCursor = 0 + m.projectOffset = 0 + return m, nil + case "j", "k", "d", "u", "g", "G", "down", "up": + half := m.bodyHeight() / 2 + if half < 1 { + half = 1 + } + m.projectCursor = nav(msg.String(), m.projectCursor, len(m.projectSessions), half) + m = m.clampProjectOffsetNow() + case "enter", "l", "right": + if len(m.projectSessions) > 0 { + m.detailLoading = true + selected := m.projectSessions[m.projectCursor] + m.detailSession = selected + return m, loadSessionDetailCmd(selected.Path) + } + } + return m, nil +} diff --git a/keys_rerun.go b/keys_rerun.go new file mode 100644 index 0000000..2639c77 --- /dev/null +++ b/keys_rerun.go @@ -0,0 +1,18 @@ +package lore + +import ( + tea "github.com/charmbracelet/bubbletea" +) + +func (m model) handleRerunKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "enter": + return m, m.rerunFn(m.rerunPrompt, m.rerunCWD) + case "esc", "q", "h", "left": + m.mode = modeDetail + m.rerunPrompt = "" + m.rerunCWD = "" + return m, nil + } + return m, nil +} diff --git a/keys_search.go b/keys_search.go new file mode 100644 index 0000000..0bf9689 --- /dev/null +++ b/keys_search.go @@ -0,0 +1,93 @@ +package lore + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" +) + +func (m model) handleSearchKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch m.searchMode { + case searchModeEntry: + return m.handleSearchEntryKey(msg) + case searchModeResults: + return m.handleSearchResultsKey(msg) + } + return m, nil +} + +func (m model) handleSearchEntryKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyEnter: + m = m.ensureIndex() + text, filters := parseSearchQuery(m.searchQuery) + // Try FTS5 index for the text part; post-filter by project/branch. + if m.index != nil && text != "" { + if hits, err := m.index.Search(text); err == nil && len(hits) > 0 { + // Post-filter indexed results by structured filters. + if filters.project != "" || filters.branch != "" { + var filtered []SearchHit + for _, h := range hits { + if filters.project != "" && !strings.EqualFold(h.Session.Project, filters.project) { + continue + } + if filters.branch != "" && !strings.EqualFold(h.Session.Branch, filters.branch) { + continue + } + filtered = append(filtered, h) + } + m.searchResults = filtered + } else { + m.searchResults = hits + } + } else { + m.searchResults = searchSessionsFiltered(m.sessions, text, filters) + } + } else { + m.searchResults = searchSessionsFiltered(m.sessions, text, filters) + } + m.searchMode = searchModeResults + m.searchCursor = 0 + case tea.KeyEsc: + m.mode = modeList + m.searchQuery = "" + m.searchResults = nil + m.searchCursor = 0 + case tea.KeyBackspace: + runes := []rune(m.searchQuery) + if len(runes) > 0 { + m.searchQuery = string(runes[:len(runes)-1]) + } + case tea.KeyRunes: + m.searchQuery += string(msg.Runes) + } + return m, nil +} + +func (m model) handleSearchResultsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "j", "k", "d", "u", "g", "G", "down", "up": + half := m.bodyHeight() / 2 + if half < 1 { + half = 1 + } + m.searchCursor = nav(msg.String(), m.searchCursor, len(m.searchResults), half) + m = m.clampSearchOffsetNow() + case "enter", "l", "right": + if len(m.searchResults) > 0 { + m.detailLoading = true + selected := m.searchResults[m.searchCursor].Session + m.detailSession = selected + return m, loadSessionDetailCmd(selected.Path) + } + case "/": + m.searchMode = searchModeEntry + case "q", "esc", "h", "left": + m.mode = modeList + m.searchQuery = "" + m.searchResults = nil + m.searchCursor = 0 + m.searchOffset = 0 + } + return m, nil +} diff --git a/keys_stats.go b/keys_stats.go new file mode 100644 index 0000000..b9c3d59 --- /dev/null +++ b/keys_stats.go @@ -0,0 +1,39 @@ +package lore + +import ( + "os" + + tea "github.com/charmbracelet/bubbletea" +) + +func (m model) handleStatsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "q", "esc", "h", "left": + m.mode = modeList + return m, nil + case "j", "k", "d", "u", "g", "G", "down", "up": + half := m.bodyHeight() / 2 + if half < 1 { + half = 1 + } + m.statsCursor = nav(msg.String(), m.statsCursor, len(m.statsData), half) + m = m.clampStatsOffsetNow() + } + return m, nil +} + +func computeStatsRows(sessions []Session) []statsRow { + rows := make([]statsRow, 0, len(sessions)) + for _, s := range sessions { + row := statsRow{Session: s} + if f, err := os.Open(s.Path); err == nil { + if stats, err := parseSessionStats(f); err == nil { + stats.EstimatedCostUSD = estimateCost(stats) + row.Stats = stats + } + f.Close() + } + rows = append(rows, row) + } + return rows +} diff --git a/keys_timeline.go b/keys_timeline.go new file mode 100644 index 0000000..1aac93c --- /dev/null +++ b/keys_timeline.go @@ -0,0 +1,34 @@ +package lore + +import ( + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +func (m model) handleTimelineKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + hm := buildHeatmap(m.sessions, time.Now()) + switch msg.String() { + case "q", "esc": + m.mode = modeList + return m, nil + case "h", "left": + next := m.timelineCursor.AddDate(0, 0, -1) + if !next.Before(hm.earliestDay()) { + m.timelineCursor = next + } + case "l", "right": + today := startOfDay(time.Now()) + next := m.timelineCursor.AddDate(0, 0, 1) + if !next.After(today) { + m.timelineCursor = next + } + case "enter": + m.dateFilter = m.timelineCursor + m.applyFilter() + m.cursor = 0 + m.listOffset = 0 + m.mode = modeList + } + return m, nil +} diff --git a/lore.go b/lore.go index 01055d9..c9da7c6 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.7.0" +var Version = "0.8.0" // Run is the entry point used by cmd/lore/main.go. func Run() error { @@ -71,3 +71,23 @@ func defaultProjectsDir() (string, error) { } return filepath.Join(home, ".claude", "projects"), nil } + +// resolveCacheDir picks the cache directory with the following precedence: +// 1. LORE_CACHE_DIR environment variable if set and non-empty +// 2. os.UserCacheDir() + "/lore" +// +// The directory is created if it does not exist. +func resolveCacheDir() (string, error) { + dir := os.Getenv("LORE_CACHE_DIR") + if dir == "" { + base, err := os.UserCacheDir() + if err != nil { + return "", fmt.Errorf("locate cache dir: %w", err) + } + dir = filepath.Join(base, "lore") + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", fmt.Errorf("create cache dir %q: %w", dir, err) + } + return dir, nil +} diff --git a/lore_test.go b/lore_test.go index 9ef62f3..a92d2d4 100644 --- a/lore_test.go +++ b/lore_test.go @@ -138,3 +138,43 @@ func TestRun_VersionPath(t *testing.T) { t.Errorf("Run() stdout = %q, want it to contain %q", buf.String(), Version) } } + +// ----- resolveCacheDir tests ----- + +func TestResolveCacheDir_EnvTakesPrecedence(t *testing.T) { + tmp := t.TempDir() + t.Setenv("LORE_CACHE_DIR", tmp) + got, err := resolveCacheDir() + if err != nil { + t.Fatalf("resolveCacheDir: %v", err) + } + if got != tmp { + t.Errorf("got %q, want %q", got, tmp) + } +} + +func TestResolveCacheDir_DefaultWhenEnvUnset(t *testing.T) { + t.Setenv("LORE_CACHE_DIR", "") + got, err := resolveCacheDir() + if err != nil { + t.Fatalf("resolveCacheDir: %v", err) + } + cacheBase, _ := os.UserCacheDir() + want := filepath.Join(cacheBase, "lore") + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestResolveCacheDir_CreatesDirectory(t *testing.T) { + tmp := t.TempDir() + newDir := filepath.Join(tmp, "lore-cache-test") + t.Setenv("LORE_CACHE_DIR", newDir) + _, err := resolveCacheDir() + if err != nil { + t.Fatalf("resolveCacheDir: %v", err) + } + if _, statErr := os.Stat(newDir); os.IsNotExist(statErr) { + t.Error("resolveCacheDir did not create the directory") + } +} diff --git a/model.go b/model.go index 1a79857..3f8421e 100644 --- a/model.go +++ b/model.go @@ -2,7 +2,6 @@ package lore import ( "fmt" - "os" "path/filepath" "strings" "time" @@ -31,6 +30,12 @@ type sessionDetailLoadedMsg struct { err error } +// indexReadyMsg is dispatched when the background FTS5 sync completes. +type indexReadyMsg struct { + idx *Index + err error +} + // model is the Bubble Tea state for the session-list and detail panels. type model struct { projectsDir string @@ -71,6 +76,7 @@ type model struct { rerunPrompt string // The user prompt being re-run rerunCWD string // The session's CWD for re-run rerunFn func(prompt, cwd string) tea.Cmd // Dependency-injected re-run hook; returns a tea.Cmd so the exec can be routed through tea.ExecProcess (or a fake in tests). + resumeFn func(id, cwd string) tea.Cmd // Dependency-injected resume hook (R key); wraps `claude --resume ` via tea.ExecProcess. // Stats view state statsData []statsRow // per-session stats rows (computed on enter) @@ -96,8 +102,11 @@ type model struct { // Sidechain turns loaded on demand when expanding Agent tool turns sidechainTurns map[int][]turn - // FTS5 search index (nil until first search; fallback to linear scan if nil) - index *Index + // FTS5 search index. Set by the background sync launched in Init(); nil + // until the sync completes. Falls back to ensureIndex() (on-demand) and + // linear scan when nil. + index *Index + indexing bool // true while the background sync is in flight // warnings are short messages produced during session scan for files // that were skipped (unreadable, malformed, no user event). Surfaced @@ -127,17 +136,42 @@ func newModel(projectsDir string) model { return model{ projectsDir: projectsDir, loading: true, + indexing: true, expandedTurns: make(map[int]bool), justCopied: false, clipboardFn: copyToClipboard, // Default to real implementation rerunFn: rerunClaude, // Default to real implementation + resumeFn: resumeClaude, // Default to real implementation bookmarks: bookmarks, bookmarksPath: bmPath, } } func (m model) Init() tea.Cmd { - return loadSessionsCmd(m.projectsDir) + return tea.Batch(loadSessionsCmd(m.projectsDir), syncIndexCmd(m.projectsDir)) +} + +// syncIndexCmd opens and syncs the FTS5 index in the background, dispatching +// indexReadyMsg when done. Best-effort: errors are carried in the message and +// leave m.index nil (ensureIndex on-demand remains the fallback). +func syncIndexCmd(projectsDir string) tea.Cmd { + return func() tea.Msg { + if projectsDir == "" { + return indexReadyMsg{} + } + cacheDir, err := indexCacheDir() + if err != nil { + return indexReadyMsg{err: err} + } + idx, err := OpenIndex(filepath.Dir(cacheDir)) + if err != nil { + return indexReadyMsg{err: err} + } + if syncErr := idx.Sync(projectsDir); syncErr != nil { + return indexReadyMsg{err: syncErr} + } + return indexReadyMsg{idx: idx} + } } // Offset-update helpers, called from key handlers after a cursor move so @@ -195,6 +229,26 @@ func (m model) clampStatsOffsetNow() model { return m } +// ensureIndex opens the FTS5 index on first use and runs an initial Sync. +// No-op if the index is already set or if the background sync is still in flight. +// Best-effort: returns the model unchanged if the index can't be opened. +func (m model) ensureIndex() model { + if m.index != nil || m.indexing || m.projectsDir == "" { + return m + } + cacheDir, err := indexCacheDir() + if err != nil { + return m + } + idx, err := OpenIndex(filepath.Dir(cacheDir)) + if err != nil { + return m + } + idx.Sync(m.projectsDir) + m.index = idx + return m +} + func loadSessionsCmd(dir string) tea.Cmd { return func() tea.Msg { ss, warnings, err := scanSessions(dir) @@ -233,6 +287,13 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.height = msg.Height return m, nil + case indexReadyMsg: + m.indexing = false + if msg.err == nil && msg.idx != nil { + m.index = msg.idx + } + return m, nil + case rerunDoneMsg: // Claude has exited (or failed to launch). Return to the session // list and reload sessions so any new session created by the re-run @@ -291,612 +352,6 @@ func (m model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } -// handleListKey handles keys in list mode -func (m model) handleListKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - // If in filter entry mode, handle filter-specific keys - if m.filterMode != filterModeNone { - return m.handleFilterEntryKey(msg) - } - - // Handle normal navigation keys - switch msg.String() { - case "q": - return m, tea.Quit - case "j", "down": - if !m.loading && m.cursor < len(m.visibleSessions)-1 { - m.cursor++ - } - m = m.clampListOffsetNow() - case "k", "up": - if !m.loading && m.cursor > 0 { - m.cursor-- - } - m = m.clampListOffsetNow() - case "d": - if !m.loading && len(m.visibleSessions) > 0 { - half := m.bodyHeight() / 2 - if half < 1 { - half = 1 - } - m.cursor += half - if m.cursor >= len(m.visibleSessions) { - m.cursor = len(m.visibleSessions) - 1 - } - } - m = m.clampListOffsetNow() - case "u": - if !m.loading { - half := m.bodyHeight() / 2 - if half < 1 { - half = 1 - } - m.cursor -= half - if m.cursor < 0 { - m.cursor = 0 - } - } - m = m.clampListOffsetNow() - case "g": - if !m.loading { - m.cursor = 0 - } - m = m.clampListOffsetNow() - case "G": - if !m.loading && len(m.visibleSessions) > 0 { - m.cursor = len(m.visibleSessions) - 1 - } - m = m.clampListOffsetNow() - case "p": - if !m.loading { - m.filterMode = filterModeProject - } - case "P": - // Capital P: open project view for the selected session - if !m.loading && len(m.visibleSessions) > 0 { - selected := m.visibleSessions[m.cursor] - m.mode = modeProject - m.projectCWD = selected.CWD - // Filter the full session list (not visible list) to this CWD - m.projectSessions = nil - for _, s := range m.sessions { - if s.CWD == selected.CWD { - m.projectSessions = append(m.projectSessions, s) - } - } - m.projectCursor = 0 - } - case "b": - if !m.loading { - m.filterMode = filterModeBranch - } - case "f": - if !m.loading { - m.filterMode = filterModeFuzzy - } - case "/": - if !m.loading { - m.mode = modeSearch - m.searchMode = searchModeEntry - m.searchQuery = "" - m.searchResults = nil - m.searchCursor = 0 - } - case "S": - if !m.loading { - m.statsData = computeStatsRows(m.sessions) - m.statsCursor = 0 - m.statsOffset = 0 - m.mode = modeStats - } - case "T": - if !m.loading { - m.mode = modeTimeline - m.timelineCursor = startOfDay(time.Now()) - } - case "m": - if !m.loading && len(m.visibleSessions) > 0 { - selected := m.visibleSessions[m.cursor] - on := toggleBookmark(m.bookmarks, selected.ID) - if on { - m.flashMsg = "bookmarked" - } else { - m.flashMsg = "unbookmarked" - } - if m.bookmarksPath != "" { - _ = saveBookmarks(m.bookmarksPath, m.bookmarks) - } - // If we're currently filtering to bookmarks-only, recompute - // the visible list so an unmarked session disappears. - if m.bookmarkOnly { - m.applyFilter() - if m.cursor >= len(m.visibleSessions) { - m.cursor = len(m.visibleSessions) - 1 - } - if m.cursor < 0 { - m.cursor = 0 - } - } - } - case "M": - if !m.loading { - if !m.bookmarkOnly && len(m.bookmarks) == 0 { - m.flashMsg = "no bookmarks yet (press m to mark a session)" - } else { - m.bookmarkOnly = !m.bookmarkOnly - m.applyFilter() - m.cursor = 0 - m.listOffset = 0 - } - } - case "enter", "l", "right": - // Open session detail - if !m.loading && len(m.visibleSessions) > 0 { - m.detailLoading = true - selected := m.visibleSessions[m.cursor] - m.detailSession = selected - return m, loadSessionDetailCmd(selected.Path) - } - case "esc": - // Clear filters and restore full list when any filter is applied. - if m.appliedFilterMode != filterModeNone || m.bookmarkOnly || !m.dateFilter.IsZero() { - m.filterText = "" - m.appliedFilterMode = filterModeNone - m.bookmarkOnly = false - m.dateFilter = time.Time{} - m.applyFilter() - m.cursor = 0 - } - } - return m, nil -} - -// handleDetailKey handles keys in detail mode. -func (m model) handleDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "q", "esc", "h", "left": - m.mode = modeList - m.turns = nil - m.cursorDetail = 0 - m.detailOffset = 0 - m.expandedTurns = make(map[int]bool) - m.sidechainTurns = nil - m.justCopied = false - return m, nil - case "j", "down": - visible := m.visibleTurns() - if m.cursorDetail < len(visible)-1 { - m.cursorDetail++ - } - m.justCopied = false - m = m.clampDetailOffsetNow() - case "k", "up": - if m.cursorDetail > 0 { - m.cursorDetail-- - } - m.justCopied = false - m = m.clampDetailOffsetNow() - case "d": - visible := m.visibleTurns() - half := m.bodyHeight() / 2 - if half < 1 { - half = 1 - } - m.cursorDetail += half - if m.cursorDetail >= len(visible) { - m.cursorDetail = len(visible) - 1 - } - if m.cursorDetail < 0 { - m.cursorDetail = 0 - } - m.justCopied = false - m = m.clampDetailOffsetNow() - case "u": - half := m.bodyHeight() / 2 - if half < 1 { - half = 1 - } - m.cursorDetail -= half - if m.cursorDetail < 0 { - m.cursorDetail = 0 - } - m.justCopied = false - m = m.clampDetailOffsetNow() - case "g": - m.cursorDetail = 0 - m.justCopied = false - m = m.clampDetailOffsetNow() - case "G": - visible := m.visibleTurns() - if len(visible) > 0 { - m.cursorDetail = len(visible) - 1 - } - m.justCopied = false - m = m.clampDetailOffsetNow() - case " ": - visible := m.visibleTurns() - if m.cursorDetail < len(visible) { - t := visible[m.cursorDetail] - if t.kind == "tool" { - fullIdx := m.visibleIndexToFullIndex(m.cursorDetail) - m.expandedTurns[fullIdx] = !m.expandedTurns[fullIdx] - if m.expandedTurns[fullIdx] && t.sidechainPath != "" { - if _, loaded := m.sidechainTurns[fullIdx]; !loaded { - if scTurns, err := loadSidechainTurns(t.sidechainPath); err == nil { - if m.sidechainTurns == nil { - m.sidechainTurns = make(map[int][]turn) - } - m.sidechainTurns[fullIdx] = scTurns - } - } - } - } else { - m.flashMsg = "space: cursor is not on a tool turn" - } - } - m.justCopied = false - case "y": - visible := m.visibleTurns() - copied := false - if m.cursorDetail < len(visible) { - if t := visible[m.cursorDetail]; t.kind == "user" { - if err := m.clipboardFn(t.body); err == nil { - m.justCopied = true - copied = true - } - } - } - if !copied { - for i := m.cursorDetail - 1; i >= 0; i-- { - if visible[i].kind == "user" { - if err := m.clipboardFn(visible[i].body); err == nil { - m.justCopied = true - copied = true - } - break - } - } - } - if !copied { - m.flashMsg = "y: no user prompt at or before cursor" - } - case "r": - visible := m.visibleTurns() - if m.cursorDetail < len(visible) { - t := visible[m.cursorDetail] - if t.kind == "user" { - m.mode = modeRerun - m.rerunPrompt = t.body - m.rerunCWD = m.detailSession.CWD - } else { - m.flashMsg = "r: cursor is not on a user turn" - } - } - case "/": - m.mode = modeSearch - m.searchMode = searchModeEntry - m.searchQuery = "" - m.searchResults = nil - m.searchCursor = 0 - case "m": - on := toggleBookmark(m.bookmarks, m.detailSession.ID) - if on { - m.flashMsg = "bookmarked" - } else { - m.flashMsg = "unbookmarked" - } - if m.bookmarksPath != "" { - _ = saveBookmarks(m.bookmarksPath, m.bookmarks) - } - } - return m, nil -} - -// handleSearchKey handles keys in search mode -func (m model) handleSearchKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch m.searchMode { - case searchModeEntry: - return m.handleSearchEntryKey(msg) - case searchModeResults: - return m.handleSearchResultsKey(msg) - } - return m, nil -} - -// handleSearchEntryKey handles keys while typing search query -func (m model) handleSearchEntryKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.Type { - case tea.KeyEnter: - // Lazy-open the FTS5 index on first search - if m.index == nil && m.projectsDir != "" { - cacheDir, err := indexCacheDir() - if err == nil { - idx, err := OpenIndex(filepath.Dir(cacheDir)) - if err == nil { - idx.Sync(m.projectsDir) - m.index = idx - } - } - } - // Try indexed search, fall back to linear scan - if m.index != nil { - if hits, err := m.index.Search(m.searchQuery); err == nil && len(hits) > 0 { - m.searchResults = hits - } else { - m.searchResults = searchSessions(m.sessions, m.searchQuery) - } - } else { - m.searchResults = searchSessions(m.sessions, m.searchQuery) - } - m.searchMode = searchModeResults - m.searchCursor = 0 - case tea.KeyEsc: - // Cancel search, return to list - m.mode = modeList - m.searchQuery = "" - m.searchResults = nil - m.searchCursor = 0 - case tea.KeyBackspace: - // Remove last rune from query - runes := []rune(m.searchQuery) - if len(runes) > 0 { - m.searchQuery = string(runes[:len(runes)-1]) - } - case tea.KeyRunes: - // Append runes to query - m.searchQuery += string(msg.Runes) - } - return m, nil -} - -// handleProjectKey handles keys in project mode -func (m model) handleProjectKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "q", "esc", "h", "left": - m.mode = modeList - m.projectCWD = "" - m.projectSessions = nil - m.projectCursor = 0 - m.projectOffset = 0 - return m, nil - case "j", "down": - if m.projectCursor < len(m.projectSessions)-1 { - m.projectCursor++ - } - m = m.clampProjectOffsetNow() - case "k", "up": - if m.projectCursor > 0 { - m.projectCursor-- - } - m = m.clampProjectOffsetNow() - case "d": - if len(m.projectSessions) > 0 { - half := m.bodyHeight() / 2 - if half < 1 { - half = 1 - } - m.projectCursor += half - if m.projectCursor >= len(m.projectSessions) { - m.projectCursor = len(m.projectSessions) - 1 - } - } - m = m.clampProjectOffsetNow() - case "u": - half := m.bodyHeight() / 2 - if half < 1 { - half = 1 - } - m.projectCursor -= half - if m.projectCursor < 0 { - m.projectCursor = 0 - } - m = m.clampProjectOffsetNow() - case "g": - m.projectCursor = 0 - m = m.clampProjectOffsetNow() - case "G": - if len(m.projectSessions) > 0 { - m.projectCursor = len(m.projectSessions) - 1 - } - m = m.clampProjectOffsetNow() - case "enter", "l", "right": - if len(m.projectSessions) > 0 { - m.detailLoading = true - selected := m.projectSessions[m.projectCursor] - m.detailSession = selected - return m, loadSessionDetailCmd(selected.Path) - } - } - return m, nil -} - -// handleSearchResultsKey handles keys while viewing search results -func (m model) handleSearchResultsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "j", "down": - if m.searchCursor < len(m.searchResults)-1 { - m.searchCursor++ - } - m = m.clampSearchOffsetNow() - case "k", "up": - if m.searchCursor > 0 { - m.searchCursor-- - } - m = m.clampSearchOffsetNow() - case "d": - if len(m.searchResults) > 0 { - half := m.bodyHeight() / 2 - if half < 1 { - half = 1 - } - m.searchCursor += half - if m.searchCursor >= len(m.searchResults) { - m.searchCursor = len(m.searchResults) - 1 - } - } - m = m.clampSearchOffsetNow() - case "u": - half := m.bodyHeight() / 2 - if half < 1 { - half = 1 - } - m.searchCursor -= half - if m.searchCursor < 0 { - m.searchCursor = 0 - } - m = m.clampSearchOffsetNow() - case "g": - m.searchCursor = 0 - m = m.clampSearchOffsetNow() - case "G": - if len(m.searchResults) > 0 { - m.searchCursor = len(m.searchResults) - 1 - } - m = m.clampSearchOffsetNow() - case "enter", "l", "right": - if len(m.searchResults) > 0 { - m.detailLoading = true - selected := m.searchResults[m.searchCursor].Session - m.detailSession = selected - return m, loadSessionDetailCmd(selected.Path) - } - case "/": - m.searchMode = searchModeEntry - case "q", "esc", "h", "left": - m.mode = modeList - m.searchQuery = "" - m.searchResults = nil - m.searchCursor = 0 - m.searchOffset = 0 - } - return m, nil -} - -// handleRerunKey handles keys in rerun mode. -// -// On enter we hand off to rerunFn, which returns a tea.Cmd. The default -// rerunClaude wraps tea.ExecProcess: bubbletea suspends, the child -// process owns the terminal, and a rerunDoneMsg fires when it exits. We -// then quit lore in the rerunDoneMsg handler. -func (m model) handleRerunKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "enter": - return m, m.rerunFn(m.rerunPrompt, m.rerunCWD) - case "esc", "q", "h", "left": - m.mode = modeDetail - m.rerunPrompt = "" - m.rerunCWD = "" - return m, nil - } - return m, nil -} - -// handleTimelineKey handles keys in timeline (heatmap) mode. -func (m model) handleTimelineKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - hm := buildHeatmap(m.sessions, time.Now()) - switch msg.String() { - case "q", "esc": - m.mode = modeList - return m, nil - case "h", "left": - next := m.timelineCursor.AddDate(0, 0, -1) - if !next.Before(hm.earliestDay()) { - m.timelineCursor = next - } - case "l", "right": - today := startOfDay(time.Now()) - next := m.timelineCursor.AddDate(0, 0, 1) - if !next.After(today) { - m.timelineCursor = next - } - case "enter": - m.dateFilter = m.timelineCursor - m.applyFilter() - m.cursor = 0 - m.listOffset = 0 - m.mode = modeList - } - return m, nil -} - -// handleStatsKey handles keys in stats mode. -func (m model) handleStatsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "q", "esc", "h", "left": - m.mode = modeList - return m, nil - case "j", "down": - if m.statsCursor < len(m.statsData)-1 { - m.statsCursor++ - } - m = m.clampStatsOffsetNow() - case "k", "up": - if m.statsCursor > 0 { - m.statsCursor-- - } - m = m.clampStatsOffsetNow() - case "g": - m.statsCursor = 0 - m = m.clampStatsOffsetNow() - case "G": - if len(m.statsData) > 0 { - m.statsCursor = len(m.statsData) - 1 - } - m = m.clampStatsOffsetNow() - } - return m, nil -} - -// computeStatsRows iterates sessions, opens each file, and parses token usage. -// Sessions whose files cannot be opened produce a zero-stats row (displayed as dashes). -func computeStatsRows(sessions []Session) []statsRow { - rows := make([]statsRow, 0, len(sessions)) - for _, s := range sessions { - row := statsRow{Session: s} - if f, err := os.Open(s.Path); err == nil { - if stats, err := parseSessionStats(f); err == nil { - stats.EstimatedCostUSD = estimateCost(stats) - row.Stats = stats - } - f.Close() - } - rows = append(rows, row) - } - return rows -} - -func (m model) handleFilterEntryKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.Type { - case tea.KeyEnter: - // Apply filter - m.appliedFilterMode = m.filterMode - m.applyFilter() - m.filterMode = filterModeNone - // Clamp cursor - if len(m.visibleSessions) == 0 { - m.cursor = 0 - } else if m.cursor >= len(m.visibleSessions) { - m.cursor = len(m.visibleSessions) - 1 - } - case tea.KeyEsc: - // Cancel filter entry and clear both text and visible list - m.filterText = "" - m.visibleSessions = m.sessions - m.filterMode = filterModeNone - m.appliedFilterMode = filterModeNone - m.cursor = 0 - case tea.KeyBackspace: - // Remove last rune from filter text - runes := []rune(m.filterText) - if len(runes) > 0 { - m.filterText = string(runes[:len(runes)-1]) - } - case tea.KeyRunes: - // Append runes to filter text - m.filterText += string(msg.Runes) - } - return m, nil -} - func (m *model) applyFilter() { sessions := m.sessions if strings.TrimSpace(m.filterText) != "" { diff --git a/model_test.go b/model_test.go index 117437a..233d96a 100644 --- a/model_test.go +++ b/model_test.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "testing" "time" @@ -139,10 +140,8 @@ func TestModel_Init(t *testing.T) { if cmd == nil { t.Fatal("Init returned nil cmd") } - msg := cmd() - if _, ok := msg.(sessionsLoadedMsg); !ok { - t.Fatalf("Init cmd produced %T, want sessionsLoadedMsg", msg) - } + // Init now returns tea.Batch([loadSessionsCmd, syncIndexCmd]); + // the batch itself is the cmd — just check it's non-nil. } func TestLoadSessionsCmd(t *testing.T) { @@ -1271,3 +1270,99 @@ func TestModel_Detail_PressH_InFilterEntry_TypesH(t *testing.T) { t.Errorf("after 'h' in filter entry: mode = %d, want %d (modeList, should not change)", m.mode, modeList) } } + +func TestEnsureIndex_IdempotentWhenAlreadySet(t *testing.T) { + m := newModel("/tmp") + m.loading = false + + // Pre-set a sentinel index to verify ensureIndex is a no-op when already set. + sentinel := &Index{} + m.index = sentinel + + m2 := m.ensureIndex() + if m2.index != sentinel { + t.Error("ensureIndex overwrote existing index; should be a no-op when index != nil") + } +} + +func TestEnsureIndex_SkipsWhenNoDirSet(t *testing.T) { + m := newModel("") + m.loading = false + m.index = nil + + m2 := m.ensureIndex() + // With empty projectsDir, no index should be opened (best-effort, no panic). + _ = m2 +} + +// ----- Task 8: background FTS5 sync tests ----- + +func TestInit_ReturnsBatchedCmd(t *testing.T) { + m := newModel("/tmp") + cmd := m.Init() + if cmd == nil { + t.Fatal("Init() returned nil cmd; expected batched cmd (sessions + index sync)") + } +} + +func TestIndexReadyMsg_PopulatesIndex(t *testing.T) { + m := newModel("/tmp") + m.loading = false + + sentinel := &Index{} + msg := indexReadyMsg{idx: sentinel} + next, _ := m.Update(msg) + nm := next.(model) + if nm.index != sentinel { + t.Error("indexReadyMsg did not set m.index") + } + if nm.indexing { + t.Error("indexing should be false after indexReadyMsg") + } +} + +func TestIndexReadyMsg_ErrorLeavesIndexNil(t *testing.T) { + m := newModel("/tmp") + m.loading = false + m.indexing = true + + msg := indexReadyMsg{err: errFake("boom")} + next, _ := m.Update(msg) + nm := next.(model) + if nm.index != nil { + t.Error("indexReadyMsg with error should leave index nil") + } + if nm.indexing { + t.Error("indexing should be cleared even on error") + } +} + +func TestListHeader_ShowsIndexingWhileIndexing(t *testing.T) { + m := newModel("/tmp") + m.loading = false + m.indexing = true + m.sessions = []Session{{ID: "a", Project: "p", Branch: "main", Timestamp: time.Now()}} + m.visibleSessions = m.sessions + m.width = 120 + m.height = 40 + + out := m.View() + if !strings.Contains(out, "indexing") { + t.Errorf("list header with indexing=true should contain 'indexing', got:\n%s", out) + } +} + +func TestListHeader_NoIndexingWhenNotIndexing(t *testing.T) { + m := newModel("/tmp") + m.loading = false + m.indexing = false + m.sessions = []Session{{ID: "a", Project: "p", Branch: "main", Timestamp: time.Now()}} + m.visibleSessions = m.sessions + m.width = 120 + m.height = 40 + + out := m.View() + if strings.Contains(out, "indexing") { + t.Errorf("list header with indexing=false should NOT contain 'indexing', got:\n%s", out) + } +} diff --git a/narratives.md b/narratives.md deleted file mode 100644 index c48fb6e..0000000 --- a/narratives.md +++ /dev/null @@ -1,144 +0,0 @@ -# lore — Decision Narratives - -## Theme 1: Conception & Naming (2026-05-01) - -A tool to browse Claude Code session transcripts was proposed as a TUI. Several name candidates were considered: lore, recall, yarn, trail, scrollback. "lore" won because accumulated AI history IS your lore — short, evocative. The design was proposed to live under `grit` first then split; in practice it was given its own repo immediately. - -Key commits: -- `4eb9a7a` docs: propose lore — a Claude session browser TUI -- `9b47088` lore: phase 1 — scaffold + session-list TUI (TDD) - -## Theme 2: TDD Foundation & CI Gate (2026-05-01) - -Before writing any features, a CI workflow with an 80% per-package coverage gate was wired. This defined the development contract: every PR must follow red→green→refactor. An initial coverage backfill was needed to clear the bar. - -Key commits: -- `e7023a8` ci: add GitHub Actions workflow with 80% per-package coverage gate -- `ee841ee` fix(ci): honest 80% gate; cover Run/defaultProjectsDir without bypass -- `deaaec8` test: backfill lore package coverage to clear 80% bar - -## Theme 3: Session Parsing Design (2026-05-01) - -A deliberate choice was made to read ONLY the first `user` event from each JSONL file — cheap, scales to large transcripts. The `Session` struct captures ID, Path, CWD, Project, Branch, Slug, Query, and Timestamp. Later, `Query` (first user message) replaced slug as the primary session label because slug is rarely populated in practice. - -Key commits: -- `9b47088` lore: phase 1 — scaffold + session-list TUI -- `9ccbadb` feat: query preview in session list, remove dead thinking toggle -- `96fe796` feat: skip system-injected XML in session query extraction - -## Theme 4: Navigation & Filtering Evolution (2026-05-01 to 2026-05-06) - -Filtering started with exact substring match for `p` (project) and `b` (branch) inline filters. It evolved to fuzzy ranking via `sahilm/fuzzy`, then a third cross-dimensional `f` fuzzy filter was added. The DRY pass in v0.7 collapsed three near-identical `applyFilter` branches into a single `fuzzyFilterSessions` helper. - -Key commits: -- `770cf5b` test: add failing tests for project and branch filters -- `05560d8` feat(list): inline project and branch filters -- `116afac` feat: fuzzy ranking for p/b filters -- `3c08f78` test(phase5b): red failing tests for list-level fuzzy filter -- `c3129c2` feat(phase5b): implement list-level fuzzy filter with 'f' key -- `7ce545c` test(red): fuzzyFilterSessions helper for filter DRY -- `7603a9e` refactor(filter): extract fuzzyFilterSessions, DRY applyFilter - -## Theme 5: Session Detail View (2026-05-01 to 2026-05-02) - -The detail view was built in phases: foundation first, then polish (expand/collapse tool turns, thinking toggle, copy prompt). The thinking toggle was later removed as a dead feature (thinking content is always redacted). Diff rendering for Edit/Write tool calls was added by reusing patterns from grit. - -Key commits: -- `b09f398` test: add failing tests for detail view foundation -- `542ff84` feat(detail): session detail view foundation -- `c5f6095` test: add failing tests for detail-view polish -- `a70a68d` feat(detail): expand/collapse tool turns, thinking toggle, copy prompt -- `49fe3bb` test: diff rendering for expanded Edit/Write tool turns -- `31d3c45` feat: diff rendering for expanded Edit/Write tool turns - -## Theme 6: Search — Linear Scan to FTS5 (2026-05-02 to 2026-05-04) - -Search was built in two passes. v1 used linear scan across JSONL files — simple and retained as a fallback. Phase 5a added a SQLite FTS5 index using `modernc.org/sqlite` (pure-Go, no CGO). The index lives in `os.UserCacheDir()` so it's platform-appropriate. Linear scan remains as a transparent fallback on FTS5 miss/error. - -Key commits: -- `67bf8d8` test(search): red commit with failing tests for search v1 -- `f4a9c14` feat(search): implement search v1 with linear-scan and model state transitions -- `ae71a2d` test(index): red commit - failing tests for SQLite FTS5 search index -- `09c28f9` feat(index): implement SQLite FTS5 search index -- `a17bd7c` feat(search): wire FTS5 index into search with linear-scan fallback - -## Theme 7: Re-run Mode & TTY Handling (2026-05-02 to 2026-05-04) - -Re-run mode was implemented to allow relaunching `claude` with a past prompt. The first implementation used `cmd.Run()` synchronously inside the bubbletea Update handler — visually broken because claude fought lore for the terminal. The fix switched to `tea.ExecProcess` which suspends the renderer and hands the TTY cleanly to the child. Later, lore was changed to return to the session list (instead of quitting) when `claude` exits. - -Key commits: -- `0fe9ab6` test(rerun): add failing tests for re-run mode -- `1a19c06` feat(rerun): implement re-run mode with claude invocation -- `e4b9568` fix(rerun): use tea.ExecProcess so claude takes the TTY cleanly -- `ff5d620` test(rerun): add tests for returning to list after re-run -- `9100f9a` feat(rerun): return to session list after re-run instead of quitting - -## Theme 8: Viewport & UX Polish (2026-05-02) - -After dogfooding, three issues were identified: (1) no real scrolling — cursors past screen height were invisible, (2) footers advertised keys that didn't exist, (3) no feedback for no-op key presses. All three were fixed in a single cleanup PR. Text wrapping was also fixed so viewport math accurately reflected multi-line turn bodies. - -Key commits: -- `12334af` test(cleanup): viewport, honest footers, no-op flash messages -- `35c7c61` feat(cleanup): viewport scrolling, honest footers, no-op flash messages -- `9a34e0b` test(detail): wrapping for multi-line turn bodies -- `4ad7aeb` fix(detail): wrap multi-line turn bodies for accurate viewport math - -## Theme 9: Help Overlay (2026-05-02) - -A `?` help overlay was added so users can discover keybindings without reading docs. The overlay is mode-specific — each mode shows its own keys. Later, `?` was mandated to appear in every mode footer as a universal discoverable hint. - -Key commits: -- `f573617` test: ? help overlay toggle and dismissal -- `abd67d3` feat: ? help overlay -- `3bb04c0` test: expand help overlay coverage for all modes -- `e64f164` test(red): footer completeness — ? help and missing keys in all modes -- `650f147` feat(green): add ? help hint and missing keys to all mode footers - -## Theme 10: Quality of Life — Phase 7 (2026-05-04 to 2026-05-05) - -A batch of quality-of-life improvements: sidechain handling (sub-agent transcripts filtered from list, viewable inline), configurable projects dir (`--dir` flag + `LORE_PROJECTS_DIR` env), usage stats panel (S key), turn position indicator in detail header, consistent back-navigation (q/esc/h/← everywhere), query preview surfaced in list and project views. - -Key commits: -- `3c351ef` test(red): failing tests for resolveProjectsDir, parseSessionStats, modeStats -- `607f9d2` feat(green): configurable projects dir + usage stats panel -- `90da1c1` feat(render): add turn N/M position indicator to detail view header -- `5f002a9` feat(detail): add h and left-arrow as back-navigation keys -- `669be07` test(red): failing tests for sidechain handling -- `9b07e32` feat(green): sidechain handling -- filter from list, link and render in detail - -## Theme 11: v0.7 Cleanup & Chrome Unification (2026-05-05 to 2026-05-06) - -A systematic cleanup pass: unified footer rendering (render*Footer functions), consistent header chrome (render*Header functions), layout constants, dead-code removal, missing unit tests, scan warning surfacing. Also fixed XML-injected system prompts polluting session query display. - -Key commits: -- `8b8041b` test(red): unified footer rendering across modes -- `27983ed` feat(render): unify footer rendering across modes -- `c7d2603` test(red): consistent render*Header functions across modes -- `8bed9f7` refactor(render): consistent render*Header functions across modes -- `4503ea6` feat(scan): surface skipped-file warnings in list header -- `42fbb27` refactor(render): extract layout constants, align widths, drop dead code - -## Theme 12: Session Bookmarks (2026-05-06) - -A bookmark feature was added: `m` toggles a star on any session in list or detail mode, persisted to `/lore/bookmarks.json`. `M` filters to bookmarks-only, composable with the fuzzy filters. Bookmarked sessions show `★` in the list. - -Key commits: -- `808e298` test(red): session bookmarks -- `b2cabd0` feat(bookmarks): session bookmarks with persistent storage - -## Theme 13: Timeline Activity Heatmap (2026-05-06) - -A GitHub-style activity heatmap was added showing session counts over an 8-week × 7-day grid. Navigate with h/l or arrows, enter to filter the session list to a specific day. Implemented in `timeline.go` with `buildHeatmap` and `heatmapBucket` functions. - -Key commits: -- `6e97bb2` test(red): timeline activity heatmap -- `ffcc4d4` feat(timeline): activity heatmap mode - -## Theme 14: Public Release & Distribution (2026-05-06 to 2026-05-08) - -The project was prepared for public release: GoReleaser wired for darwin/linux × amd64/arm64, a Homebrew tap at `zpenka/homebrew-lore`, version promoted from `const` to `var` so ldflags can inject it. README was rewritten for public audience. CI Go version bumped to 1.25 for `modernc.org/sqlite` compatibility. - -Key commits: -- `f1dfcd2` fix(ci): bump Go to 1.25 for modernc.org/sqlite compatibility -- `5d67877` release: wire GoReleaser, Homebrew tap, and v0.7.0 version -- `59ccc4c` docs: rewrite README for public release diff --git a/nav.go b/nav.go new file mode 100644 index 0000000..20b1cea --- /dev/null +++ b/nav.go @@ -0,0 +1,38 @@ +package lore + +// nav advances cursor by the standard list keys (j, k, d, u, g, G, down, up). +// Returns the new cursor. count is len(items); halfPage is the half-page step +// (always >= 1). For unknown keys returns cursor unchanged. +func nav(key string, cursor, count, halfPage int) int { + if count <= 0 { + return 0 + } + if halfPage < 1 { + halfPage = 1 + } + switch key { + case "j", "down": + if cursor < count-1 { + cursor++ + } + case "k", "up": + if cursor > 0 { + cursor-- + } + case "d": + cursor += halfPage + if cursor >= count { + cursor = count - 1 + } + case "u": + cursor -= halfPage + if cursor < 0 { + cursor = 0 + } + case "g": + cursor = 0 + case "G": + cursor = count - 1 + } + return cursor +} diff --git a/nav_test.go b/nav_test.go new file mode 100644 index 0000000..4bcc7df --- /dev/null +++ b/nav_test.go @@ -0,0 +1,60 @@ +package lore + +import "testing" + +func TestNav(t *testing.T) { + cases := []struct { + name string + key string + cursor int + count int + halfPage int + want int + }{ + // empty list + {"empty j", "j", 0, 0, 5, 0}, + {"empty k", "k", 0, 0, 5, 0}, + {"empty G", "G", 0, 0, 5, 0}, + {"empty g", "g", 0, 0, 5, 0}, + {"empty d", "d", 0, 0, 5, 0}, + {"empty u", "u", 0, 0, 5, 0}, + + // one item + {"one item j", "j", 0, 1, 1, 0}, + {"one item k", "k", 0, 1, 1, 0}, + + // mid-list navigation + {"mid j", "j", 3, 10, 3, 4}, + {"mid down", "down", 3, 10, 3, 4}, + {"mid k", "k", 3, 10, 3, 2}, + {"mid up", "up", 3, 10, 3, 2}, + + // boundary clamping + {"bottom j clamps", "j", 9, 10, 3, 9}, + {"top k clamps", "k", 0, 10, 3, 0}, + + // g/G jumps + {"g to top", "g", 5, 10, 3, 0}, + {"G to bottom", "G", 0, 10, 3, 9}, + + // half-page d/u + {"d mid", "d", 2, 10, 3, 5}, + {"d overshoots end", "d", 8, 10, 3, 9}, + {"u mid", "u", 7, 10, 3, 4}, + {"u underflows start", "u", 1, 10, 3, 0}, + + // unknown key is a no-op + {"unknown key", "x", 3, 10, 3, 3}, + {"enter no-op", "enter", 3, 10, 3, 3}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := nav(tc.key, tc.cursor, tc.count, tc.halfPage) + if got != tc.want { + t.Errorf("nav(%q, cursor=%d, count=%d, half=%d) = %d, want %d", + tc.key, tc.cursor, tc.count, tc.halfPage, got, tc.want) + } + }) + } +} diff --git a/plan.md b/plan.md deleted file mode 100644 index c3a6533..0000000 --- a/plan.md +++ /dev/null @@ -1,452 +0,0 @@ -# lore — v0.8 playbook - -A sequenced, agent-friendly task list to drive lore from v0.7 to v0.8 in a -single synchronous Sonnet run. Each task is one PR, scoped tight enough that -it should fit in one focused work block, with red/green/refactor commits per -the agent contract in `CLAUDE.md`. - -> **Read this before starting.** The repo has hard rules in `CLAUDE.md` -> ("Test-driven development (required)" and "Agent contract"). Every task -> below assumes you'll follow them: worktree off `main`, red commit first -> with a failing test, green commit with the minimum code to pass, optional -> refactor. CI gates on `go test -race -cover ./...` with ≥80% per-package -> coverage. Don't skip hooks. Don't add deps not listed in `DESIGN.md`. - -The tasks are ordered so that earlier work makes later work cheaper. Land -them in order. If a task can't be completed cleanly, stop and surface the -blocker — don't escalate scope. - ---- - -## Operating rules for the executing agent - -1. **One task = one PR.** Do not bundle. Open the next PR only after the - previous one is merged (or, if you're driving the merges, after CI is - green and the diff is clean). -2. **Red, then green, then optional refactor.** Each phase is its own - commit with a clear message. The red commit's test must fail when run - in isolation against the unmodified production code. -3. **No new dependencies.** Anything outside the four listed in `DESIGN.md` - (`bubbletea`, `lipgloss`, `sahilm/fuzzy`, `modernc.org/sqlite`) is a - blocker — stop and ask. -4. **Behavior preservation for refactors.** Tasks marked *(refactor)* must - not change a single key binding, footer string, or rendered byte. The - existing test suite is your contract. -5. **Update docs as you go.** If you change a key binding or add a flag, - update `CLAUDE.md` (Keyboard Navigation / Configuration sections) and - the `?` overlay in `render.go` and the README in the same PR. The - `footer_completeness_test.go` and `help_test.go` suites enforce some of - this for you. -6. **PR body format** (per `CLAUDE.md` agent contract): - `Red commit: , green commit: , refactor: `. - ---- - -## Task 1 — Split `model.go` into per-mode key handlers *(refactor)* - -**Goal.** Move the seven `handle*Key` functions out of `model.go` into one -file per mode. `model.go` keeps the `model` struct, `Init`, `Update`, the -top-level `handleKey` dispatch, the offset-clamp helpers, and `applyFilter`. - -**Why.** `model.go` is 962 lines. Subsequent feature tasks will touch -specific handlers and the diffs will be much cleaner with one handler per -file. Pure mechanical move; no logic change. - -**Files to add.** -- `keys_list.go` — `handleListKey`, `handleFilterEntryKey` -- `keys_detail.go` — `handleDetailKey` -- `keys_search.go` — `handleSearchKey`, `handleSearchEntryKey`, `handleSearchResultsKey` -- `keys_project.go` — `handleProjectKey` -- `keys_rerun.go` — `handleRerunKey` -- `keys_stats.go` — `handleStatsKey`, `computeStatsRows` -- `keys_timeline.go` — `handleTimelineKey` - -**Files to shrink.** -- `model.go` — delete what moved, keep dispatch and shared state. - -**Red.** Add `internal_split_test.go` with a single test that imports the -package and asserts (via `runtime` reflection or just call-site coverage) -that each of the moved functions is still callable through the model -dispatch — i.e. `model{mode: modeStats}.Update(keyMsg("j"))` returns -without panic for each mode. This will fail to compile *only* if the move -breaks the public surface; design it as a regression net. - -**Green.** Move the functions. Run the full suite — every existing test -must pass unchanged. - -**Acceptance.** -- `model.go` is under ~400 lines. -- `go test -race -cover ./...` passes; per-package coverage stays ≥80%. -- `gofmt -l .` and `go vet ./...` are clean. - -**Out of scope.** Do not rename anything. Do not change function -signatures. Do not "while you're there" tweak any handler's behavior. - ---- - -## Task 2 — Split `render.go` into per-mode renderers *(refactor)* - -**Goal.** Same treatment as Task 1, for `render.go` (currently 1029 lines). -Move per-mode `render*View` / `render*Header` / `render*Footer` / -`*BodyLines` triples into one file per mode. `render.go` keeps `View()` -dispatch, the Lipgloss styles, the layout constants, the help overlay, and -the shared body/clamp helpers (`renderBody`, `renderDivider`, `bodyHeight`). - -**Files to add.** -- `render_list.go` -- `render_detail.go` -- `render_search.go` -- `render_project.go` *(merge with the existing `project.go` rendering helpers if it makes sense)* -- `render_rerun.go` -- `render_stats.go` -- `render_timeline.go` - -**Red / Green / Acceptance.** Same shape as Task 1. The existing -`render_test.go`, `render_constants_test.go`, `footer_completeness_test.go` -form your contract — every assertion must pass byte-for-byte. - -**Out of scope.** No style tweaks. No header/footer wording changes. No -new helpers. If you find a temptation to refactor a body-lines function, -file it as a follow-up and move on. - ---- - -## Task 3 — Extract a shared cursor-nav helper *(refactor)* - -**Goal.** Replace the duplicated `j/k/d/u/g/G` blocks across the five -list-shaped handlers (`list`, `detail`, `search`, `project`, `stats`) with -a single helper: - -```go -// nav advances cursor by the standard list keys ("j","k","d","u","g","G", -// "down","up"). Returns the new cursor. count is len(items); halfPage is -// the half-page step (always >= 1). For unknown keys returns cursor -// unchanged. -func nav(key string, cursor, count, halfPage int) int -``` - -Each handler then collapses its six cases to: - -```go -case "j", "k", "d", "u", "g", "G", "down", "up": - m.cursor = nav(msg.String(), m.cursor, len(m.visibleSessions), halfPage(m)) - m = m.clampListOffsetNow() -``` - -**Files.** -- `nav.go` — new, with `nav` and `halfPage(m model) int`. -- `nav_test.go` — exhaustive table test for `nav`. -- `keys_*.go` — collapse the duplicated blocks (after Task 1). - -**Why.** ~150 lines of repeated code disappear. Future modes (none planned, -but cheap insurance) get correct cursor behavior for free. - -**Red.** Write `nav_test.go` first with a table covering: empty list, one -item, mid-list `j`, top `k`, bottom `G`, half-page `d` overshooting end, -half-page `u` underflowing start, unknown key. All tests fail because -`nav` doesn't exist. - -**Green.** Implement `nav`, then collapse handlers one mode at a time. -Run the existing per-mode tests after each collapse to catch regressions. - -**Acceptance.** -- `nav.go` < 80 lines. `nav_test.go` covers ≥95% of `nav.go`. -- Total LOC across `keys_*.go` files drops by ≥150. -- All existing model tests pass unchanged. - -**Out of scope.** Do not touch the timeline handler (different shape: -left/right, date math). Do not change behavior of any existing key. - ---- - -## Task 4 — DRY the lazy FTS5-index open *(refactor)* - -**Goal.** Move the six-line block in `handleSearchEntryKey` that opens the -index on first search into a method: - -```go -// ensureIndex opens the FTS5 index on first use and runs an initial Sync. -// Best-effort: returns the model unchanged if the index can't be opened. -func (m model) ensureIndex() model -``` - -The search handler becomes `m = m.ensureIndex()` followed by the existing -search dispatch. Sets up Task 8 (background sync). - -**Files.** -- `model.go` (or `index.go`) — add `ensureIndex`. -- `keys_search.go` — call site collapses. -- `model_test.go` (or `index_test.go`) — direct test of `ensureIndex` with - an injected fake `OpenIndex` if feasible; otherwise an integration test - via the search handler. - -**Red.** Test that calling `ensureIndex` twice opens the index exactly -once (use a counter via package-level swap, or skip and rely on the -existing search test if injection is awkward — flag the trade-off in the -PR). - -**Acceptance.** Behavior unchanged. `keys_search.go` shrinks. No new -public API outside the package. - -**Out of scope.** Do not move the sync call to a `tea.Cmd` yet — that's -Task 8. Keep the change purely structural here. - ---- - -## Task 5 — `LORE_CACHE_DIR` env var *(small feature)* - -**Goal.** Honor `LORE_CACHE_DIR` as an override for the cache location used -by the FTS5 index (`index.db`) and bookmarks (`bookmarks.json`). Resolution -order, mirroring `resolveProjectsDir`: - -1. `LORE_CACHE_DIR` env var (if set and non-empty) -2. `os.UserCacheDir()` + `/lore` - -**Files.** -- `lore.go` — add `resolveCacheDir() (string, error)`. -- `index.go` — `indexCacheDir` calls the new resolver. -- `bookmark.go` — `bookmarksFile` calls the new resolver. -- `lore_test.go` — env-var precedence test (set/unset, empty string, - non-existent dir creation). -- `CLAUDE.md` and `README.md` — Configuration section gains a row for the - new env var. - -**Red.** Test `resolveCacheDir` with the env var set, unset, and empty. -Set expectations against the resolved path; create the dir if missing. - -**Acceptance.** Existing index/bookmark tests still pass. New env-var -behavior is covered. - -**Out of scope.** Don't add a `--cache-dir` flag yet. Env var is enough -for v0.8; add the flag if a user asks. - ---- - -## Task 6 — Resume a session with `R` *(feature)* - -**Goal.** Add a new key `R` (capital R) to list mode and detail mode that -resumes the selected session via `claude -c `. Mirrors the -existing `r` (re-run a single prompt) plumbing. - -**Why.** `r` is "re-run this prompt as a new session"; `R` is "continue -this session." Both verbs are first-class daily-driver actions. - -**Files.** -- `rerun.go` — add a sibling `resumeClaude(sessionID, cwd string) tea.Cmd` - that wraps `tea.ExecProcess` for `claude -c ` (verify the actual - Claude CLI flag — `-c`, `--continue`, `--resume`; it's `--resume ` - on recent versions; check what's installed and document the assumption - in the PR body). -- `model.go` — new injectable hook `resumeFn func(id, cwd string) tea.Cmd` - alongside `rerunFn`, defaulted to `resumeClaude`. -- `keys_list.go` and `keys_detail.go` — handle `R`. -- `render.go` — help overlay gets a new line (`R resume session`); list and - detail footers get the hint. -- `model_test.go` — test that pressing `R` invokes `resumeFn` with the - selected session's ID and CWD; uses a fake `resumeFn` that records the - call. -- `CLAUDE.md` and `README.md` — Keyboard Navigation update. - -**Red.** Write the model test with a fake `resumeFn`, asserting it gets -called with the right args. Run — test fails because `R` is unbound. - -**Green.** Wire `R` in both handlers and the help/footer surfaces. - -**Acceptance.** -- `footer_completeness_test.go` and `help_test.go` still pass (they're - table-driven on the help map; update the table in the same PR). -- Manual smoke: open lore, hit `R` on a session, verify Claude attaches - to the existing transcript (not a fresh session). If the CLI flag is - wrong, surface that as a blocker in the PR description; don't ship. - -**Out of scope.** Don't add a "edit prompt before resume" flow. Don't add -worktree switching from the design doc — that's a future task. - ---- - -## Task 7 — Pricing table → embedded JSON *(refactor + small feature)* - -**Goal.** Replace the hardcoded `pricingTable` slice in `stats.go` with an -embedded JSON file (`pricing.json`) loaded via `go:embed`. Add a -`LORE_PRICING_FILE` env var override so users on enterprise rates don't -need a fork. - -**Files.** -- `pricing.json` — new, contains the same data as the current `pricingTable`, - schema: - ```json - [ - {"substr": "opus", "input_per_mtok": 15.0, "output_per_mtok": 75.0, "cache_read_fraction": 0.1}, - {"substr": "sonnet", "input_per_mtok": 3.0, "output_per_mtok": 15.0, "cache_read_fraction": 0.1}, - {"substr": "haiku", "input_per_mtok": 0.8, "output_per_mtok": 4.0, "cache_read_fraction": 0.1} - ] - ``` -- `stats.go` — embed via `//go:embed pricing.json`; load on first use; - honor `LORE_PRICING_FILE` env var override (file path; falls back to - embedded if missing or malformed, surface a one-time warning via the - list header). -- `stats_test.go` — test override path: write a temp pricing file, set the - env var, assert `estimateCost` uses the new rate. -- `CLAUDE.md` and `README.md` — Configuration section gets the new env var. - -**Red.** Write the override test; fails because `LORE_PRICING_FILE` is -unread. - -**Green.** Implement the loader. Cache the parsed table in a package-level -`sync.Once`-guarded var so we don't re-read on every cost calc. - -**Acceptance.** All existing `stats_test.go` cases still pass — same -defaults baked in. - -**Out of scope.** Don't add a UI for picking a model. Don't expand the -table to non-Anthropic models. - ---- - -## Task 8 — Background FTS5 sync at startup *(perf)* - -**Goal.** Open the FTS5 index and run `Sync` in a `tea.Cmd` dispatched -from `Init()`, instead of lazily on first search. First search becomes -instant for repeat users; first-time users still see the existing -behavior. - -**Why.** Today the first `enter` after typing a search query stalls for -hundreds of ms while the index syncs. After Task 4 the open path is -already factored. - -**Files.** -- `model.go` — `Init()` returns a batched cmd: `tea.Batch(loadSessionsCmd, - syncIndexCmd)`. New `indexReadyMsg{idx *Index, err error}` type. -- `keys_search.go` — `ensureIndex` becomes a no-op if the index is - already set; otherwise falls back to the on-demand path. -- `render_list.go` — list header shows `indexing…` while `m.indexing` is - true (a new field set when the cmd is in flight, cleared on - `indexReadyMsg`). -- `model_test.go` — test that `Init` produces both messages; test that - `indexReadyMsg` populates `m.index`; test header shows `indexing…` - only when the flag is set. - -**Red.** Write the message-flow tests first. - -**Green.** Implement. - -**Acceptance.** Existing search tests pass without modification (the -fallback path still works when the background sync hasn't completed). -Header shows `indexing…` only during the first sync, not on every key -press. - -**Out of scope.** No periodic re-sync. No "watch for new files" — the -mtime-based sync on next startup catches everything. No progress -percentage; the boolean flag is enough. - ---- - -## Task 9 — Search query syntax for `project:` and `branch:` *(feature)* - -**Goal.** Parse search queries like `project:lore branch:main refresh -token` into structured filters. The non-prefixed terms hit FTS5; the -prefixed terms post-filter the result set against `Session.Project` and -`Session.Branch`. - -**Why.** Two daily-driver searches today require multiple keystrokes -(filter then search, or search then visually scan). One query string nails -both. - -**Files.** -- `search.go` — new `parseSearchQuery(q string) (text string, filters - searchFilters)` where `searchFilters` is a small struct - (`project`, `branch` strings; empty == no filter). Linear-scan - `searchSessions` honors the filters. -- `index.go` — `Index.Search` takes the parsed filters and post-filters - by joining `session_path` against the parsed `Session.Project` / - `Session.Branch`. (Cheaper than a schema change for v0.8; revisit if - search latency suffers.) -- `keys_search.go` — call `parseSearchQuery` before dispatching to FTS5 - vs. linear scan. -- `search_test.go` — table tests for the parser (single prefix, multiple - prefixes, prefix at end, prefix with quoted value, no prefix); end-to-end - test that `project:lore foo` returns only lore sessions matching `foo`. -- `render.go` — help overlay gains a one-line note about the syntax. - README mention too. - -**Red.** Parser table tests, then end-to-end test. - -**Green.** Implement parser; thread filters through both search paths. - -**Acceptance.** All existing search tests still pass (prefix-free queries -behave identically). - -**Out of scope.** No `cwd:` or `since:` filters yet — propose them as -v0.9 if users ask. No quoted-string parsing beyond the trivial case (do -the simple thing; flag if a user hits the limit). - ---- - -## Task 10 — Fuzz the JSONL parsers *(quality)* - -**Goal.** Add `go test -fuzz` targets for `parseSessionMetadata` and -`parseTurnsFromJSONL`. They consume third-party data (transcripts written -by Claude Code) and have unsafe-looking type assertions in a few places. - -**Files.** -- `session_test.go` — `FuzzParseSessionMetadata` seeded with a few real - fixtures. -- `detail_test.go` — `FuzzParseTurnsFromJSONL` seeded similarly. -- `.github/workflows/ci.yml` — add a step that runs each fuzz target for - 30 seconds (`go test -fuzz=FuzzParseSessionMetadata -fuzztime=30s - -run=^$`). Keep it as a separate non-blocking job initially so a flaky - fuzz crash doesn't gate merges; promote to required once it's been - green for a week. - -**Red.** Add the fuzz targets; CI runs them. Any panic surfaces as a -failure. (If they pass immediately, that's fine — the value here is -ongoing protection, not a one-time catch.) - -**Green.** Fix anything the fuzzer finds. If nothing, the PR is just the -test additions and the CI step. - -**Acceptance.** CI runs fuzz for 30s × 2 targets per push. No regressions -in unit-test coverage. - -**Out of scope.** Don't fuzz `extractSessionText` or `parseSessionStats` -in the same PR; if the JSONL parser fuzzers find nothing in a week, add -those next. - ---- - -## Done criteria for v0.8 - -When all ten tasks are merged: - -- `model.go` and `render.go` are each under ~400 lines. -- The `nav` helper backs every list-shaped mode. -- `LORE_CACHE_DIR` and `LORE_PRICING_FILE` join `LORE_PROJECTS_DIR` as - first-class env-var overrides. -- `R` resumes sessions; the search bar accepts `project:` and `branch:` - prefixes. -- The FTS5 index syncs in the background at startup; the list header - shows `indexing…` while it does. -- `pricing.json` is the single source of truth for cost rates. -- The JSONL parsers are protected by ongoing fuzz coverage. - -Tag `v0.8.0`, update `CLAUDE.md`'s "Project Overview" and `DESIGN.md`'s -phasing table, and ship. - ---- - -## Explicitly deferred to v0.9 (do not start here) - -These came up while planning but are out of scope for the playbook above — -they each require product judgment that's better made after v0.8 ships: - -- Per-session annotations / notes (needs UX design). -- Search-result hit jumping into the matching turn (FTS5 schema migration). -- Cost aggregation header and group-by-project in stats panel. -- Cost-shaded timeline heatmap. -- Side-by-side session compare. -- Streaming session scan with incremental render. -- Stats result caching in SQLite. -- Shell completions, man page, `--json` machine output, README GIF. - -Surface user demand for these in GitHub issues first; pick the next -playbook from there. diff --git a/pricing.json b/pricing.json new file mode 100644 index 0000000..4ab20d3 --- /dev/null +++ b/pricing.json @@ -0,0 +1,5 @@ +[ + {"substr": "opus", "input_per_mtok": 15.0, "output_per_mtok": 75.0, "cache_read_fraction": 0.1}, + {"substr": "sonnet", "input_per_mtok": 3.0, "output_per_mtok": 15.0, "cache_read_fraction": 0.1}, + {"substr": "haiku", "input_per_mtok": 0.8, "output_per_mtok": 4.0, "cache_read_fraction": 0.1} +] diff --git a/render.go b/render.go index 4fca4d2..dd2dcb7 100644 --- a/render.go +++ b/render.go @@ -4,7 +4,6 @@ import ( "fmt" "strings" "time" - "unicode/utf8" "github.com/charmbracelet/lipgloss" ) @@ -42,7 +41,6 @@ const ( ) func (m model) View() string { - // If help overlay is showing, render it instead of the normal view if m.showHelp { return renderHelpOverlay(m) } @@ -67,8 +65,7 @@ func (m model) View() string { } // bodyHeight returns the number of body lines to render for the current -// terminal height, or -1 if the height is unknown / too small to scroll -// (in which case callers should render the whole body unsliced). +// terminal height, or -1 if the height is unknown / too small to scroll. func (m model) bodyHeight() int { if m.height <= chromeLines { return -1 @@ -76,9 +73,7 @@ func (m model) bodyHeight() int { return m.height - chromeLines } -// renderBody slices `lines` to fit the available body height starting -// from `offset`. When the height is unknown (<=0) it returns the lines -// unchanged so pre-window-size renders aren't truncated. +// renderBody slices lines to fit the available body height starting from offset. func renderBody(lines []string, offset int, height int) []string { if height <= 0 { return lines @@ -86,265 +81,6 @@ func renderBody(lines []string, offset int, height int) []string { return sliceLines(lines, offset, height) } -// ----- list mode ----- - -// listBodyLines builds the rendered rows for list mode and returns both -// the flat slice and the line index of the selected session. -func listBodyLines(m model, now time.Time) (lines []string, cursorLine int) { - var lastBucket string - for i, s := range m.visibleSessions { - bucket := timeBucket(s.Timestamp, now) - if bucket != lastBucket { - lines = append(lines, bucketStyle.Render(" "+bucket)) - lastBucket = bucket - } - if i == m.cursor { - cursorLine = len(lines) - } - lines = append(lines, renderRow(s, i == m.cursor, m.bookmarks[s.ID], m.width)) - } - return -} - -func renderListView(m model) string { - if m.err != nil { - return errorStyle.Render(fmt.Sprintf(" error: %v", m.err)) + "\n" - } - if m.loading { - return " loading sessions...\n" - } - if len(m.sessions) == 0 { - return fmt.Sprintf(" No sessions found in %s\n", m.projectsDir) - } - - var b strings.Builder - b.WriteString(renderListHeader(m)) - b.WriteByte('\n') - b.WriteString(renderDivider(m.width)) - b.WriteByte('\n') - - body, cursorLine := listBodyLines(m, time.Now()) - height := m.bodyHeight() - offset := clampOffset(m.listOffset, cursorLine, len(body), height) - for _, line := range renderBody(body, offset, height) { - b.WriteString(line) - b.WriteByte('\n') - } - - b.WriteString(renderDivider(m.width)) - b.WriteByte('\n') - b.WriteString(renderListFooter(m)) - if m.detailLoading { - b.WriteString(" (loading session...)") - } - b.WriteByte('\n') - return b.String() -} - -// ----- detail mode ----- - -// detailBodyLines builds the rendered turn rows for detail mode and -// returns the line index of the FIRST visual row of the selected turn. -// -// Multi-line and over-width turn bodies are wrapped into multiple body -// lines so viewport math matches what the terminal actually renders. -// The first visual row of a turn carries the kind marker (` user │ `, -// ` asst │ `, etc.); continuation rows use a blank-prefixed continuation -// (` │ `) that keeps the gutter aligned. -func detailBodyLines(m model) (lines []string, cursorLine int) { - visible := m.visibleTurns() - for i, t := range visible { - isSelected := (i == m.cursorDetail) - fullIdx := m.visibleIndexToFullIndex(i) - expanded := m.expandedTurns[fullIdx] - if isSelected { - cursorLine = len(lines) - } - for _, ln := range wrapTurnLines(t, expanded, m.width) { - if isSelected { - lines = append(lines, selectedStyle.Render(ln)) - } else { - lines = append(lines, ln) - } - } - if expanded { - if scTurns, ok := m.sidechainTurns[fullIdx]; ok { - for _, ln := range renderSidechainTurns(scTurns, m.width) { - lines = append(lines, ln) - } - } - } - } - return -} - -func wrapTurnLines(t turn, expanded bool, width int) []string { - first, cont := turnPrefixes(t.kind) - avail := width - utf8.RuneCountInString(first) - if avail < 10 { - avail = 10 - } - body := t.body - if t.sidechainPath != "" { - body = "⧑ " + body - } - wrapped := wrapText(body, avail) - out := make([]string, 0, len(wrapped)) - for i, line := range wrapped { - if i == 0 { - out = append(out, first+line) - } else { - out = append(out, cont+line) - } - } - if t.kind == "tool" && expanded { - // Check if this is an Edit or Write tool that should render as a diff - toolName := extractToolName(t.body) - if toolName == "Edit" { - out = append(out, renderEditDiff(t.input, cont, avail)...) - } else if toolName == "Write" { - out = append(out, renderWriteDiff(t.input, cont, avail)...) - } else { - // Render input as `key: value` rows under a continuation gutter, - // each row also wrapped to the same width. - for k, v := range t.input { - kv := fmt.Sprintf(" %s: %v", k, v) - for _, line := range wrapText(kv, avail) { - out = append(out, cont+line) - } - } - } - } - return out -} - -// turnPrefixes returns the first-line and continuation-line gutters for -// a given turn kind. Both are visually 8 columns wide (modulo the unicode -// │ glyph) so wrapped continuation rows align with the marker column. -func turnPrefixes(kind string) (first, cont string) { - switch kind { - case "user": - return " user │ ", " │ " - case "asst": - return " asst │ ", " │ " - case "thinking": - return " think │ 〰 ", " │ " - case "tool": - return " │ ▸ ", " │ " - } - return " │ ", " │ " -} - -func renderSidechainTurns(turns []turn, width int) []string { - const indent = " │ " - avail := width - utf8.RuneCountInString(indent) - if avail < 10 { - avail = 10 - } - var lines []string - for _, t := range turns { - prefix := "" - switch t.kind { - case "user": - prefix = "user: " - case "asst": - prefix = "asst: " - case "tool": - prefix = "▸ " - case "thinking": - continue - } - wrapped := wrapText(prefix+t.body, avail) - for _, ln := range wrapped { - lines = append(lines, indent+ln) - } - } - return lines -} - -func renderDetailView(m model) string { - if m.detailErr != nil { - return errorStyle.Render(fmt.Sprintf(" error: %v", m.detailErr)) + "\n" - } - if m.detailLoading { - return " loading session...\n" - } - - var b strings.Builder - - b.WriteString(renderDetailHeader(m)) - b.WriteByte('\n') - b.WriteString(renderDivider(m.width)) - b.WriteByte('\n') - - body, cursorLine := detailBodyLines(m) - if len(body) == 0 { - body = []string{" (no turns to display)"} - cursorLine = 0 - } - height := m.bodyHeight() - offset := clampOffset(m.detailOffset, cursorLine, len(body), height) - for _, line := range renderBody(body, offset, height) { - b.WriteString(line) - b.WriteByte('\n') - } - - b.WriteString(renderDivider(m.width)) - b.WriteByte('\n') - b.WriteString(renderDetailFooter(m)) - b.WriteByte('\n') - - return b.String() -} - -// renderDetailHeader builds the detail-mode header line. -func renderDetailHeader(m model) string { - dateStr := m.detailSession.Timestamp.Format("2006-01-02") - visible := m.visibleTurns() - turnInfo := "" - if len(visible) > 0 { - turnInfo = fmt.Sprintf(" turn %d/%d", m.cursorDetail+1, len(visible)) - } - headerLine := fmt.Sprintf(" %s · %s · %s %s%s", - m.detailSession.Slug, - m.detailSession.Project, - m.detailSession.Branch, - dateStr, - turnInfo, - ) - return headerStyle.Render(headerLine) -} - -// renderDetailFooter renders the footer for detail view. -func renderDetailFooter(m model) string { - if m.flashMsg != "" { - return flashStyle.Render(" " + m.flashMsg) - } - copyStatus := "" - if m.justCopied { - copyStatus = " ✓ copied" - } - return footerStyle.Render(fmt.Sprintf( - " j/k move d/u page g/G top/bottom space expand y copy r run m bookmark / search ? help q/esc/h/← back%s", - copyStatus)) -} - -// ----- list header / row helpers ----- - -func renderListHeader(m model) string { - nProjects := countProjects(m.sessions) - skipped := "" - if n := len(m.warnings); n > 0 { - skipped = fmt.Sprintf(" (%d skipped)", n) - } - return headerStyle.Render(fmt.Sprintf( - " lore · %d session%s across %d project%s%s", - len(m.sessions), plural(len(m.sessions)), - nProjects, plural(nProjects), - skipped, - )) -} - func renderDivider(width int) string { if width < 4 { width = 80 @@ -398,33 +134,6 @@ func plural(n int) string { return "s" } -func renderListFooter(m model) string { - if m.flashMsg != "" { - return flashStyle.Render(" " + m.flashMsg) - } - if m.filterMode == filterModeProject { - return footerStyle.Render(fmt.Sprintf(" project filter: %s_ [enter] apply [esc] cancel", m.filterText)) - } - if m.filterMode == filterModeBranch { - return footerStyle.Render(fmt.Sprintf(" branch filter: %s_ [enter] apply [esc] cancel", m.filterText)) - } - if m.filterMode == filterModeFuzzy { - return footerStyle.Render(fmt.Sprintf(" fuzzy filter: %s_ [enter] apply [esc] cancel", m.filterText)) - } - if m.filterText != "" && m.appliedFilterMode != filterModeNone { - switch m.appliedFilterMode { - case filterModeProject: - return footerStyle.Render(fmt.Sprintf(" filtered by project: %s j/k · enter open · esc clear q quit", m.filterText)) - case filterModeBranch: - return footerStyle.Render(fmt.Sprintf(" filtered by branch: %s j/k · enter open · esc clear q quit", m.filterText)) - case filterModeFuzzy: - return footerStyle.Render(fmt.Sprintf(" fuzzy filter: %s j/k · enter open · esc clear q quit", m.filterText)) - } - } - return footerStyle.Render(" j/k move d/u page enter open / search p project b branch f fuzzy m bookmark M bookmarks T timeline P project view S stats g/G top/bottom ? help q quit") -} - -// padTrunc trims s to max display columns or right-pads it to fit. func padTrunc(s string, max int) string { if len(s) > max { if max <= 1 { @@ -435,244 +144,6 @@ func padTrunc(s string, max int) string { return s + strings.Repeat(" ", max-len(s)) } -// ----- search mode ----- - -// searchBodyLines builds the rendered result rows for search-results -// mode. Each result spans two lines (session row + snippet); cursorLine -// is the index of the session row for the selected hit. -func searchBodyLines(m model) (lines []string, cursorLine int) { - if m.searchMode == searchModeEntry { - return nil, 0 - } - if len(m.searchResults) == 0 { - return []string{" (no matches)"}, 0 - } - for i, hit := range m.searchResults { - isSelected := (i == m.searchCursor) - if isSelected { - cursorLine = len(lines) - } - mark := " " - if m.bookmarks[hit.Session.ID] { - mark = "★" - } - row := fmt.Sprintf(" %s %s %-*s %-*s %s", - mark, - hit.Session.Timestamp.Format("15:04"), - projectColWidth, padTrunc(hit.Session.Project, projectColWidth), - branchColWidth, padTrunc(hit.Session.Branch, branchColWidth), - hit.Session.Slug, - ) - snippet := " ▸ " + hit.Snippet - if isSelected { - lines = append(lines, selectedStyle.Render(row)) - lines = append(lines, selectedStyle.Render(snippet)) - } else { - lines = append(lines, row) - lines = append(lines, snippet) - } - } - return -} - -// renderSearchHeader builds the search-mode header for either entry or results mode. -func renderSearchHeader(m model) string { - if m.searchMode == searchModeEntry { - return headerStyle.Render(fmt.Sprintf(" search: %s_ [enter] run [esc] cancel", m.searchQuery)) - } - hitWord := "hit" - if len(m.searchResults) != 1 { - hitWord = "hits" - } - hitCount := 0 - for _, r := range m.searchResults { - hitCount += r.HitCount - } - return headerStyle.Render(fmt.Sprintf(" search: %s %d %s across %d session%s", - m.searchQuery, hitCount, hitWord, - len(m.searchResults), plural(len(m.searchResults)), - )) -} - -func renderSearchView(m model) string { - var b strings.Builder - - b.WriteString(renderSearchHeader(m)) - b.WriteByte('\n') - b.WriteString(renderDivider(m.width)) - b.WriteByte('\n') - - body, cursorLine := searchBodyLines(m) - height := m.bodyHeight() - offset := clampOffset(m.searchOffset, cursorLine, len(body), height) - for _, line := range renderBody(body, offset, height) { - b.WriteString(line) - b.WriteByte('\n') - } - - b.WriteString(renderDivider(m.width)) - b.WriteByte('\n') - - b.WriteString(renderSearchFooter(m)) - b.WriteByte('\n') - - return b.String() -} - -// renderSearchFooter renders the footer for search mode (both entry and results). -func renderSearchFooter(m model) string { - if m.flashMsg != "" { - return flashStyle.Render(" " + m.flashMsg) - } - if m.searchMode == searchModeEntry { - return footerStyle.Render(" search: " + m.searchQuery + "_ [enter] run [esc] cancel") - } - return footerStyle.Render(" j/k move d/u page enter open / new search g/G top/bottom ? help q/esc/h/← back") -} - -// ----- re-run ----- - -// renderRerunHeader builds the rerun-mode header line. -func renderRerunHeader(m model) string { - return headerStyle.Render(fmt.Sprintf(" re-run · source: %s", m.detailSession.Slug)) -} - -func renderRerunView(m model) string { - var b strings.Builder - - b.WriteString(renderRerunHeader(m)) - b.WriteByte('\n') - b.WriteString(renderDivider(m.width)) - b.WriteByte('\n') - - b.WriteString(" prompt:\n") - boxWidth := m.width - 4 - if boxWidth < 10 { - boxWidth = 10 - } - b.WriteString(" ┌" + strings.Repeat("─", boxWidth) + "┐\n") - promptLines := strings.Split(m.rerunPrompt, "\n") - rendered := 0 - for _, line := range promptLines { - if rendered >= rerunMaxLines { - b.WriteString(" │ " + truncatePromptLine("...", boxWidth-2) + "\n") - break - } - truncated := truncatePromptLine(line, boxWidth-2) - padded := truncated + strings.Repeat(" ", boxWidth-2-len(truncated)) - b.WriteString(" │ " + padded + " │\n") - rendered++ - } - for rendered < rerunMaxLines && rendered < len(promptLines) { - padded := strings.Repeat(" ", boxWidth-2) - b.WriteString(" │ " + padded + " │\n") - rendered++ - } - b.WriteString(" └" + strings.Repeat("─", boxWidth) + "┘\n") - - cwdLine := fmt.Sprintf(" cwd: %s\n", m.rerunCWD) - b.WriteString(cwdLine) - - b.WriteString(renderDivider(m.width)) - b.WriteByte('\n') - b.WriteString(renderRerunFooter(m)) - b.WriteByte('\n') - return b.String() -} - -// renderRerunFooter renders the footer for re-run mode. -func renderRerunFooter(m model) string { - if m.flashMsg != "" { - return flashStyle.Render(" " + m.flashMsg) - } - return footerStyle.Render(" enter run ? help q/esc/h/← back") -} - -// extractToolName extracts the tool name from the tool body string. -// The body format is "ToolName ", so we split on the first space. -func extractToolName(body string) string { - parts := strings.SplitN(body, " ", 2) - if len(parts) > 0 { - return parts[0] - } - return "" -} - -// renderEditDiff renders the input for an Edit tool as a diff with old_string (red) and new_string (green). -func renderEditDiff(input map[string]interface{}, cont string, avail int) []string { - var out []string - - // Render file path - if filePath, ok := input["file_path"].(string); ok { - line := fmt.Sprintf(" file: %s", filePath) - for _, l := range wrapText(line, avail) { - out = append(out, cont+l) - } - } - - // Render old_string lines with "- " prefix in red - if oldStr, ok := input["old_string"].(string); ok { - lines := strings.Split(oldStr, "\n") - for _, line := range lines { - prefixed := "- " + line - for _, wrappedLine := range wrapText(prefixed, avail) { - out = append(out, cont+diffRemoveStyle.Render(wrappedLine)) - } - } - } - - // Render new_string lines with "+ " prefix in green - if newStr, ok := input["new_string"].(string); ok { - lines := strings.Split(newStr, "\n") - for _, line := range lines { - prefixed := "+ " + line - for _, wrappedLine := range wrapText(prefixed, avail) { - out = append(out, cont+diffAddStyle.Render(wrappedLine)) - } - } - } - - return out -} - -// renderWriteDiff renders the input for a Write tool as add-only (all lines green with "+ " prefix). -func renderWriteDiff(input map[string]interface{}, cont string, avail int) []string { - var out []string - - // Render file path - if filePath, ok := input["file_path"].(string); ok { - line := fmt.Sprintf(" file: %s", filePath) - for _, l := range wrapText(line, avail) { - out = append(out, cont+l) - } - } - - // Render content lines with "+ " prefix in green - if content, ok := input["content"].(string); ok { - lines := strings.Split(content, "\n") - for _, line := range lines { - prefixed := "+ " + line - for _, wrappedLine := range wrapText(prefixed, avail) { - out = append(out, cont+diffAddStyle.Render(wrappedLine)) - } - } - } - - return out -} - -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]) + "…" -} - -// renderHelpOverlay renders a help screen showing keybindings for the current mode. func renderHelpOverlay(m model) string { var helpText string @@ -696,6 +167,7 @@ func renderHelpOverlay(m model) string { │ │ │ Other: │ │ m Bookmark / unbookmark the selected session │ + │ R Resume session (claude --resume ) │ │ P Open project view for current session's CWD │ │ S Open usage stats panel (token counts + estimated cost) │ │ T Open timeline activity heatmap │ @@ -719,6 +191,7 @@ func renderHelpOverlay(m model) string { │ space Expand/collapse tool turn; Agent ⧑ loads sidechain │ │ y Copy the nearest user prompt to clipboard │ │ r Enter re-run mode with the selected user prompt │ + │ R Resume this session (claude --resume ) │ │ m Bookmark / unbookmark this session │ │ │ │ Other: │ @@ -737,7 +210,8 @@ func renderHelpOverlay(m model) string { │ │ │ Search Entry: │ │ Type Build search query │ - │ enter Run linear scan search │ + │ Prefix syntax: project: branch: │ + │ enter Run search (FTS5 index or linear scan) │ │ esc Cancel, return to list │ │ │ │ Search Results: │ @@ -835,195 +309,3 @@ func renderHelpOverlay(m model) string { return helpText } - -// ----- stats mode ----- - -// statsBodyLines builds the rendered rows for stats mode. -// Each session is one line showing project, branch, model, token counts, and cost. -func statsBodyLines(m model) (lines []string, cursorLine int) { - if len(m.statsData) == 0 { - return []string{" (no sessions)"}, 0 - } - for i, row := range m.statsData { - isSelected := (i == m.statsCursor) - if isSelected { - cursorLine = len(lines) - } - cursor := " " - if isSelected { - cursor = " ►" - } - s := row.Session - st := row.Stats - - model := padTrunc(st.Model, 20) - inTok := formatTokenCount(st.InputTokens) - outTok := formatTokenCount(st.OutputTokens) - tokStr := fmt.Sprintf("%s / %s", inTok, outTok) - - var costStr string - if st.EstimatedCostUSD == 0 && st.Model == "" { - costStr = " —" - } else { - costStr = fmt.Sprintf("$%.2f", st.EstimatedCostUSD) - } - - line := fmt.Sprintf("%s %-14s %-22s %-20s %-14s %s", - cursor, - padTrunc(s.Project, 14), - padTrunc(s.Branch, 22), - model, - tokStr, - costStr, - ) - if isSelected { - lines = append(lines, selectedStyle.Render(line)) - } else { - lines = append(lines, line) - } - } - return -} - -// renderStatsView renders the usage stats panel. -func renderStatsView(m model) string { - var b strings.Builder - - b.WriteString(renderStatsHeader(m)) - b.WriteByte('\n') - b.WriteString(renderDivider(m.width)) - b.WriteByte('\n') - - // Column header - colHeader := " project branch model in / out cost" - b.WriteString(footerStyle.Render(colHeader)) - b.WriteByte('\n') - - body, cursorLine := statsBodyLines(m) - height := m.bodyHeight() - 1 // subtract the column header line - if height <= 0 { - height = 1 - } - offset := clampOffset(m.statsOffset, cursorLine, len(body), height) - for _, line := range renderBody(body, offset, height) { - b.WriteString(line) - b.WriteByte('\n') - } - - b.WriteString(renderDivider(m.width)) - b.WriteByte('\n') - b.WriteString(renderStatsFooter(m)) - b.WriteByte('\n') - return b.String() -} - -// renderStatsHeader builds the stats-mode header line. -func renderStatsHeader(m model) string { - n := len(m.statsData) - return headerStyle.Render(fmt.Sprintf(" lore · usage stats · %d session%s", n, plural(n))) -} - -// renderStatsFooter renders the footer for stats mode. -func renderStatsFooter(m model) string { - if m.flashMsg != "" { - return flashStyle.Render(" " + m.flashMsg) - } - return footerStyle.Render(" j/k move d/u page g/G top/bottom ? help q/esc/h/← back") -} - -// ----- timeline mode ----- - -// Heatmap cell glyphs and intensity styles. The five-block ramp gives a -// readable density gradient on both 256-color and truecolor terminals. -var heatmapStyles = [4]lipgloss.Style{ - lipgloss.NewStyle().Foreground(lipgloss.Color("236")), // 0: empty / dim - lipgloss.NewStyle().Foreground(lipgloss.Color("28")), // 1: light - lipgloss.NewStyle().Foreground(lipgloss.Color("34")), // 2: medium - lipgloss.NewStyle().Foreground(lipgloss.Color("46")), // 3: bright -} - -const heatmapEmptyGlyph = "░░" -const heatmapFilledGlyph = "██" - -func heatmapGlyph(count int) string { - if count == 0 { - return heatmapEmptyGlyph - } - return heatmapFilledGlyph -} - -// renderTimelineHeader builds the timeline header line. -func renderTimelineHeader(m model) string { - hm := buildHeatmap(m.sessions, time.Now()) - total := 0 - for r := 0; r < heatmapRows; r++ { - for c := 0; c < heatmapCols; c++ { - total += hm.Cells[r][c].Count - } - } - return headerStyle.Render(fmt.Sprintf(" lore · activity heatmap · %d session%s in last 8 weeks", - total, plural(total))) -} - -// renderTimelineFooter shows the highlighted date plus navigation hints. -func renderTimelineFooter(m model) string { - if m.flashMsg != "" { - return flashStyle.Render(" " + m.flashMsg) - } - hm := buildHeatmap(m.sessions, time.Now()) - count := hm.countOn(m.timelineCursor) - dateStr := m.timelineCursor.Format("2006-01-02 (Mon)") - hint := footerStyle.Render(" h/← l/→ move day enter filter list ? help q/esc back") - info := footerStyle.Render(fmt.Sprintf(" %s %d session%s", dateStr, count, plural(count))) - return info + "\n" + hint -} - -// renderTimelineView draws an 8-week heatmap with a cursor cell. -func renderTimelineView(m model) string { - var b strings.Builder - hm := buildHeatmap(m.sessions, time.Now()) - cursorRow, cursorCol, _ := hm.cellOf(m.timelineCursor) - - b.WriteString(renderTimelineHeader(m)) - b.WriteByte('\n') - b.WriteString(renderDivider(m.width)) - b.WriteByte('\n') - - weekdayLabels := [heatmapRows]string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"} - for row := 0; row < heatmapRows; row++ { - var line strings.Builder - line.WriteString(" " + weekdayLabels[row] + " ") - for col := 0; col < heatmapCols; col++ { - cell := hm.Cells[row][col] - glyph := heatmapGlyph(cell.Count) - style := heatmapStyles[heatmapBucket(cell.Count)] - rendered := style.Render(glyph) - if row == cursorRow && col == cursorCol { - rendered = selectedStyle.Render("[" + glyph + "]") - } else { - rendered = " " + rendered + " " - } - line.WriteString(rendered) - } - b.WriteString(line.String()) - b.WriteByte('\n') - } - - // Legend - b.WriteByte('\n') - var legend strings.Builder - legend.WriteString(" less ") - for i := 0; i < 4; i++ { - legend.WriteString(heatmapStyles[i].Render(heatmapFilledGlyph)) - legend.WriteString(" ") - } - legend.WriteString("more") - b.WriteString(legend.String()) - b.WriteByte('\n') - - b.WriteString(renderDivider(m.width)) - b.WriteByte('\n') - b.WriteString(renderTimelineFooter(m)) - b.WriteByte('\n') - return b.String() -} diff --git a/render_detail.go b/render_detail.go new file mode 100644 index 0000000..92c9a7b --- /dev/null +++ b/render_detail.go @@ -0,0 +1,245 @@ +package lore + +import ( + "fmt" + "strings" + "unicode/utf8" +) + +func detailBodyLines(m model) (lines []string, cursorLine int) { + visible := m.visibleTurns() + for i, t := range visible { + isSelected := (i == m.cursorDetail) + fullIdx := m.visibleIndexToFullIndex(i) + expanded := m.expandedTurns[fullIdx] + if isSelected { + cursorLine = len(lines) + } + for _, ln := range wrapTurnLines(t, expanded, m.width) { + if isSelected { + lines = append(lines, selectedStyle.Render(ln)) + } else { + lines = append(lines, ln) + } + } + if expanded { + if scTurns, ok := m.sidechainTurns[fullIdx]; ok { + for _, ln := range renderSidechainTurns(scTurns, m.width) { + lines = append(lines, ln) + } + } + } + } + return +} + +func wrapTurnLines(t turn, expanded bool, width int) []string { + first, cont := turnPrefixes(t.kind) + avail := width - utf8.RuneCountInString(first) + if avail < 10 { + avail = 10 + } + body := t.body + if t.sidechainPath != "" { + body = "⧑ " + body + } + wrapped := wrapText(body, avail) + out := make([]string, 0, len(wrapped)) + for i, line := range wrapped { + if i == 0 { + out = append(out, first+line) + } else { + out = append(out, cont+line) + } + } + if t.kind == "tool" && expanded { + toolName := extractToolName(t.body) + if toolName == "Edit" { + out = append(out, renderEditDiff(t.input, cont, avail)...) + } else if toolName == "Write" { + out = append(out, renderWriteDiff(t.input, cont, avail)...) + } else { + for k, v := range t.input { + kv := fmt.Sprintf(" %s: %v", k, v) + for _, line := range wrapText(kv, avail) { + out = append(out, cont+line) + } + } + } + } + return out +} + +func turnPrefixes(kind string) (first, cont string) { + switch kind { + case "user": + return " user │ ", " │ " + case "asst": + return " asst │ ", " │ " + case "thinking": + return " think │ 〰 ", " │ " + case "tool": + return " │ ▸ ", " │ " + } + return " │ ", " │ " +} + +func renderSidechainTurns(turns []turn, width int) []string { + const indent = " │ " + avail := width - utf8.RuneCountInString(indent) + if avail < 10 { + avail = 10 + } + var lines []string + for _, t := range turns { + prefix := "" + switch t.kind { + case "user": + prefix = "user: " + case "asst": + prefix = "asst: " + case "tool": + prefix = "▸ " + case "thinking": + continue + } + wrapped := wrapText(prefix+t.body, avail) + for _, ln := range wrapped { + lines = append(lines, indent+ln) + } + } + return lines +} + +func renderDetailView(m model) string { + if m.detailErr != nil { + return errorStyle.Render(fmt.Sprintf(" error: %v", m.detailErr)) + "\n" + } + if m.detailLoading { + return " loading session...\n" + } + + var b strings.Builder + + b.WriteString(renderDetailHeader(m)) + b.WriteByte('\n') + b.WriteString(renderDivider(m.width)) + b.WriteByte('\n') + + body, cursorLine := detailBodyLines(m) + if len(body) == 0 { + body = []string{" (no turns to display)"} + cursorLine = 0 + } + height := m.bodyHeight() + offset := clampOffset(m.detailOffset, cursorLine, len(body), height) + for _, line := range renderBody(body, offset, height) { + b.WriteString(line) + b.WriteByte('\n') + } + + b.WriteString(renderDivider(m.width)) + b.WriteByte('\n') + b.WriteString(renderDetailFooter(m)) + b.WriteByte('\n') + + return b.String() +} + +func renderDetailHeader(m model) string { + dateStr := m.detailSession.Timestamp.Format("2006-01-02") + visible := m.visibleTurns() + turnInfo := "" + if len(visible) > 0 { + turnInfo = fmt.Sprintf(" turn %d/%d", m.cursorDetail+1, len(visible)) + } + headerLine := fmt.Sprintf(" %s · %s · %s %s%s", + m.detailSession.Slug, + m.detailSession.Project, + m.detailSession.Branch, + dateStr, + turnInfo, + ) + return headerStyle.Render(headerLine) +} + +func renderDetailFooter(m model) string { + if m.flashMsg != "" { + return flashStyle.Render(" " + m.flashMsg) + } + copyStatus := "" + if m.justCopied { + copyStatus = " ✓ copied" + } + return footerStyle.Render(fmt.Sprintf( + " j/k move d/u page g/G top/bottom space expand y copy r run R resume m bookmark / search ? help q/esc/h/← back%s", + copyStatus)) +} + +func extractToolName(body string) string { + parts := strings.SplitN(body, " ", 2) + if len(parts) > 0 { + return parts[0] + } + return "" +} + +func renderEditDiff(input map[string]interface{}, cont string, avail int) []string { + var out []string + if filePath, ok := input["file_path"].(string); ok { + line := fmt.Sprintf(" file: %s", filePath) + for _, l := range wrapText(line, avail) { + out = append(out, cont+l) + } + } + if oldStr, ok := input["old_string"].(string); ok { + lines := strings.Split(oldStr, "\n") + for _, line := range lines { + prefixed := "- " + line + for _, wrappedLine := range wrapText(prefixed, avail) { + out = append(out, cont+diffRemoveStyle.Render(wrappedLine)) + } + } + } + if newStr, ok := input["new_string"].(string); ok { + lines := strings.Split(newStr, "\n") + for _, line := range lines { + prefixed := "+ " + line + for _, wrappedLine := range wrapText(prefixed, avail) { + out = append(out, cont+diffAddStyle.Render(wrappedLine)) + } + } + } + return out +} + +func renderWriteDiff(input map[string]interface{}, cont string, avail int) []string { + var out []string + if filePath, ok := input["file_path"].(string); ok { + line := fmt.Sprintf(" file: %s", filePath) + for _, l := range wrapText(line, avail) { + out = append(out, cont+l) + } + } + if content, ok := input["content"].(string); ok { + lines := strings.Split(content, "\n") + for _, line := range lines { + prefixed := "+ " + line + for _, wrappedLine := range wrapText(prefixed, avail) { + out = append(out, cont+diffAddStyle.Render(wrappedLine)) + } + } + } + return out +} + +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]) + "…" +} diff --git a/render_list.go b/render_list.go new file mode 100644 index 0000000..1cfdbdc --- /dev/null +++ b/render_list.go @@ -0,0 +1,103 @@ +package lore + +import ( + "fmt" + "strings" + "time" +) + +func listBodyLines(m model, now time.Time) (lines []string, cursorLine int) { + var lastBucket string + for i, s := range m.visibleSessions { + bucket := timeBucket(s.Timestamp, now) + if bucket != lastBucket { + lines = append(lines, bucketStyle.Render(" "+bucket)) + lastBucket = bucket + } + if i == m.cursor { + cursorLine = len(lines) + } + lines = append(lines, renderRow(s, i == m.cursor, m.bookmarks[s.ID], m.width)) + } + return +} + +func renderListView(m model) string { + if m.err != nil { + return errorStyle.Render(fmt.Sprintf(" error: %v", m.err)) + "\n" + } + if m.loading { + return " loading sessions...\n" + } + if len(m.sessions) == 0 { + return fmt.Sprintf(" No sessions found in %s\n", m.projectsDir) + } + + var b strings.Builder + b.WriteString(renderListHeader(m)) + b.WriteByte('\n') + b.WriteString(renderDivider(m.width)) + b.WriteByte('\n') + + body, cursorLine := listBodyLines(m, time.Now()) + height := m.bodyHeight() + offset := clampOffset(m.listOffset, cursorLine, len(body), height) + for _, line := range renderBody(body, offset, height) { + b.WriteString(line) + b.WriteByte('\n') + } + + b.WriteString(renderDivider(m.width)) + b.WriteByte('\n') + b.WriteString(renderListFooter(m)) + if m.detailLoading { + b.WriteString(" (loading session...)") + } + b.WriteByte('\n') + return b.String() +} + +func renderListHeader(m model) string { + nProjects := countProjects(m.sessions) + skipped := "" + if n := len(m.warnings); n > 0 { + skipped = fmt.Sprintf(" (%d skipped)", n) + } + indexStatus := "" + if m.indexing { + indexStatus = " indexing…" + } + return headerStyle.Render(fmt.Sprintf( + " lore · %d session%s across %d project%s%s%s", + len(m.sessions), plural(len(m.sessions)), + nProjects, plural(nProjects), + skipped, + indexStatus, + )) +} + +func renderListFooter(m model) string { + if m.flashMsg != "" { + return flashStyle.Render(" " + m.flashMsg) + } + if m.filterMode == filterModeProject { + return footerStyle.Render(fmt.Sprintf(" project filter: %s_ [enter] apply [esc] cancel", m.filterText)) + } + if m.filterMode == filterModeBranch { + return footerStyle.Render(fmt.Sprintf(" branch filter: %s_ [enter] apply [esc] cancel", m.filterText)) + } + if m.filterMode == filterModeFuzzy { + return footerStyle.Render(fmt.Sprintf(" fuzzy filter: %s_ [enter] apply [esc] cancel", m.filterText)) + } + if m.filterText != "" && m.appliedFilterMode != filterModeNone { + switch m.appliedFilterMode { + case filterModeProject: + return footerStyle.Render(fmt.Sprintf(" filtered by project: %s j/k · enter open · esc clear q quit", m.filterText)) + case filterModeBranch: + return footerStyle.Render(fmt.Sprintf(" filtered by branch: %s j/k · enter open · esc clear q quit", m.filterText)) + case filterModeFuzzy: + return footerStyle.Render(fmt.Sprintf(" fuzzy filter: %s j/k · enter open · esc clear q quit", m.filterText)) + } + } + return footerStyle.Render(" j/k move d/u page enter open R resume / search p project b branch f fuzzy m bookmark M bookmarks T timeline P project view S stats g/G top/bottom ? help q quit") +} diff --git a/render_rerun.go b/render_rerun.go new file mode 100644 index 0000000..e1cc664 --- /dev/null +++ b/render_rerun.go @@ -0,0 +1,60 @@ +package lore + +import ( + "fmt" + "strings" +) + +func renderRerunHeader(m model) string { + return headerStyle.Render(fmt.Sprintf(" re-run · source: %s", m.detailSession.Slug)) +} + +func renderRerunView(m model) string { + var b strings.Builder + + b.WriteString(renderRerunHeader(m)) + b.WriteByte('\n') + b.WriteString(renderDivider(m.width)) + b.WriteByte('\n') + + b.WriteString(" prompt:\n") + boxWidth := m.width - 4 + if boxWidth < 10 { + boxWidth = 10 + } + b.WriteString(" ┌" + strings.Repeat("─", boxWidth) + "┐\n") + promptLines := strings.Split(m.rerunPrompt, "\n") + rendered := 0 + for _, line := range promptLines { + if rendered >= rerunMaxLines { + b.WriteString(" │ " + truncatePromptLine("...", boxWidth-2) + "\n") + break + } + truncated := truncatePromptLine(line, boxWidth-2) + padded := truncated + strings.Repeat(" ", boxWidth-2-len(truncated)) + b.WriteString(" │ " + padded + " │\n") + rendered++ + } + for rendered < rerunMaxLines && rendered < len(promptLines) { + padded := strings.Repeat(" ", boxWidth-2) + b.WriteString(" │ " + padded + " │\n") + rendered++ + } + b.WriteString(" └" + strings.Repeat("─", boxWidth) + "┘\n") + + cwdLine := fmt.Sprintf(" cwd: %s\n", m.rerunCWD) + b.WriteString(cwdLine) + + b.WriteString(renderDivider(m.width)) + b.WriteByte('\n') + b.WriteString(renderRerunFooter(m)) + b.WriteByte('\n') + return b.String() +} + +func renderRerunFooter(m model) string { + if m.flashMsg != "" { + return flashStyle.Render(" " + m.flashMsg) + } + return footerStyle.Render(" enter run ? help q/esc/h/← back") +} diff --git a/render_search.go b/render_search.go new file mode 100644 index 0000000..ed2f1dd --- /dev/null +++ b/render_search.go @@ -0,0 +1,93 @@ +package lore + +import ( + "fmt" + "strings" +) + +func searchBodyLines(m model) (lines []string, cursorLine int) { + if m.searchMode == searchModeEntry { + return nil, 0 + } + if len(m.searchResults) == 0 { + return []string{" (no matches)"}, 0 + } + for i, hit := range m.searchResults { + isSelected := (i == m.searchCursor) + if isSelected { + cursorLine = len(lines) + } + mark := " " + if m.bookmarks[hit.Session.ID] { + mark = "★" + } + row := fmt.Sprintf(" %s %s %-*s %-*s %s", + mark, + hit.Session.Timestamp.Format("15:04"), + projectColWidth, padTrunc(hit.Session.Project, projectColWidth), + branchColWidth, padTrunc(hit.Session.Branch, branchColWidth), + hit.Session.Slug, + ) + snippet := " ▸ " + hit.Snippet + if isSelected { + lines = append(lines, selectedStyle.Render(row)) + lines = append(lines, selectedStyle.Render(snippet)) + } else { + lines = append(lines, row) + lines = append(lines, snippet) + } + } + return +} + +func renderSearchHeader(m model) string { + if m.searchMode == searchModeEntry { + return headerStyle.Render(fmt.Sprintf(" search: %s_ [enter] run [esc] cancel", m.searchQuery)) + } + hitWord := "hit" + if len(m.searchResults) != 1 { + hitWord = "hits" + } + hitCount := 0 + for _, r := range m.searchResults { + hitCount += r.HitCount + } + return headerStyle.Render(fmt.Sprintf(" search: %s %d %s across %d session%s", + m.searchQuery, hitCount, hitWord, + len(m.searchResults), plural(len(m.searchResults)), + )) +} + +func renderSearchView(m model) string { + var b strings.Builder + + b.WriteString(renderSearchHeader(m)) + b.WriteByte('\n') + b.WriteString(renderDivider(m.width)) + b.WriteByte('\n') + + body, cursorLine := searchBodyLines(m) + height := m.bodyHeight() + offset := clampOffset(m.searchOffset, cursorLine, len(body), height) + for _, line := range renderBody(body, offset, height) { + b.WriteString(line) + b.WriteByte('\n') + } + + b.WriteString(renderDivider(m.width)) + b.WriteByte('\n') + b.WriteString(renderSearchFooter(m)) + b.WriteByte('\n') + + return b.String() +} + +func renderSearchFooter(m model) string { + if m.flashMsg != "" { + return flashStyle.Render(" " + m.flashMsg) + } + if m.searchMode == searchModeEntry { + return footerStyle.Render(" search: " + m.searchQuery + "_ [enter] run [esc] cancel") + } + return footerStyle.Render(" j/k move d/u page enter open / new search g/G top/bottom ? help q/esc/h/← back") +} diff --git a/render_stats.go b/render_stats.go new file mode 100644 index 0000000..ee6af65 --- /dev/null +++ b/render_stats.go @@ -0,0 +1,93 @@ +package lore + +import ( + "fmt" + "strings" +) + +func statsBodyLines(m model) (lines []string, cursorLine int) { + if len(m.statsData) == 0 { + return []string{" (no sessions)"}, 0 + } + for i, row := range m.statsData { + isSelected := (i == m.statsCursor) + if isSelected { + cursorLine = len(lines) + } + cursor := " " + if isSelected { + cursor = " ►" + } + s := row.Session + st := row.Stats + + mdl := padTrunc(st.Model, 20) + inTok := formatTokenCount(st.InputTokens) + outTok := formatTokenCount(st.OutputTokens) + tokStr := fmt.Sprintf("%s / %s", inTok, outTok) + + var costStr string + if st.EstimatedCostUSD == 0 && st.Model == "" { + costStr = " —" + } else { + costStr = fmt.Sprintf("$%.2f", st.EstimatedCostUSD) + } + + line := fmt.Sprintf("%s %-14s %-22s %-20s %-14s %s", + cursor, + padTrunc(s.Project, 14), + padTrunc(s.Branch, 22), + mdl, + tokStr, + costStr, + ) + if isSelected { + lines = append(lines, selectedStyle.Render(line)) + } else { + lines = append(lines, line) + } + } + return +} + +func renderStatsView(m model) string { + var b strings.Builder + + b.WriteString(renderStatsHeader(m)) + b.WriteByte('\n') + b.WriteString(renderDivider(m.width)) + b.WriteByte('\n') + + colHeader := " project branch model in / out cost" + b.WriteString(footerStyle.Render(colHeader)) + b.WriteByte('\n') + + body, cursorLine := statsBodyLines(m) + height := m.bodyHeight() - 1 + if height <= 0 { + height = 1 + } + offset := clampOffset(m.statsOffset, cursorLine, len(body), height) + for _, line := range renderBody(body, offset, height) { + b.WriteString(line) + b.WriteByte('\n') + } + + b.WriteString(renderDivider(m.width)) + b.WriteByte('\n') + b.WriteString(renderStatsFooter(m)) + b.WriteByte('\n') + return b.String() +} + +func renderStatsHeader(m model) string { + n := len(m.statsData) + return headerStyle.Render(fmt.Sprintf(" lore · usage stats · %d session%s", n, plural(n))) +} + +func renderStatsFooter(m model) string { + if m.flashMsg != "" { + return flashStyle.Render(" " + m.flashMsg) + } + return footerStyle.Render(" j/k move d/u page g/G top/bottom ? help q/esc/h/← back") +} diff --git a/render_timeline.go b/render_timeline.go new file mode 100644 index 0000000..b1b0c64 --- /dev/null +++ b/render_timeline.go @@ -0,0 +1,98 @@ +package lore + +import ( + "fmt" + "strings" + "time" + + "github.com/charmbracelet/lipgloss" +) + +var heatmapStyles = [4]lipgloss.Style{ + lipgloss.NewStyle().Foreground(lipgloss.Color("236")), + lipgloss.NewStyle().Foreground(lipgloss.Color("28")), + lipgloss.NewStyle().Foreground(lipgloss.Color("34")), + lipgloss.NewStyle().Foreground(lipgloss.Color("46")), +} + +const heatmapEmptyGlyph = "░░" +const heatmapFilledGlyph = "██" + +func heatmapGlyph(count int) string { + if count == 0 { + return heatmapEmptyGlyph + } + return heatmapFilledGlyph +} + +func renderTimelineHeader(m model) string { + hm := buildHeatmap(m.sessions, time.Now()) + total := 0 + for r := 0; r < heatmapRows; r++ { + for c := 0; c < heatmapCols; c++ { + total += hm.Cells[r][c].Count + } + } + return headerStyle.Render(fmt.Sprintf(" lore · activity heatmap · %d session%s in last 8 weeks", + total, plural(total))) +} + +func renderTimelineFooter(m model) string { + if m.flashMsg != "" { + return flashStyle.Render(" " + m.flashMsg) + } + hm := buildHeatmap(m.sessions, time.Now()) + count := hm.countOn(m.timelineCursor) + dateStr := m.timelineCursor.Format("2006-01-02 (Mon)") + hint := footerStyle.Render(" h/← l/→ move day enter filter list ? help q/esc back") + info := footerStyle.Render(fmt.Sprintf(" %s %d session%s", dateStr, count, plural(count))) + return info + "\n" + hint +} + +func renderTimelineView(m model) string { + var b strings.Builder + hm := buildHeatmap(m.sessions, time.Now()) + cursorRow, cursorCol, _ := hm.cellOf(m.timelineCursor) + + b.WriteString(renderTimelineHeader(m)) + b.WriteByte('\n') + b.WriteString(renderDivider(m.width)) + b.WriteByte('\n') + + weekdayLabels := [heatmapRows]string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"} + for row := 0; row < heatmapRows; row++ { + var line strings.Builder + line.WriteString(" " + weekdayLabels[row] + " ") + for col := 0; col < heatmapCols; col++ { + cell := hm.Cells[row][col] + glyph := heatmapGlyph(cell.Count) + style := heatmapStyles[heatmapBucket(cell.Count)] + rendered := style.Render(glyph) + if row == cursorRow && col == cursorCol { + rendered = selectedStyle.Render("[" + glyph + "]") + } else { + rendered = " " + rendered + " " + } + line.WriteString(rendered) + } + b.WriteString(line.String()) + b.WriteByte('\n') + } + + b.WriteByte('\n') + var legend strings.Builder + legend.WriteString(" less ") + for i := 0; i < 4; i++ { + legend.WriteString(heatmapStyles[i].Render(heatmapFilledGlyph)) + legend.WriteString(" ") + } + legend.WriteString("more") + b.WriteString(legend.String()) + b.WriteByte('\n') + + b.WriteString(renderDivider(m.width)) + b.WriteByte('\n') + b.WriteString(renderTimelineFooter(m)) + b.WriteByte('\n') + return b.String() +} diff --git a/rerun.go b/rerun.go index 7259612..a63dc41 100644 --- a/rerun.go +++ b/rerun.go @@ -29,3 +29,18 @@ func rerunClaude(prompt, cwd string) tea.Cmd { return rerunDoneMsg{err: err} }) } + +// resumeClaude returns a tea.Cmd that resumes an existing Claude session by ID +// using `claude --resume `. ExecProcess suspends the renderer and hands +// the terminal to the child process cleanly. +func resumeClaude(sessionID, cwd string) tea.Cmd { + claudePath, err := exec.LookPath("claude") + if err != nil { + return func() tea.Msg { return rerunDoneMsg{err: err} } + } + cmd := exec.Command(claudePath, "--resume", sessionID) + cmd.Dir = cwd + return tea.ExecProcess(cmd, func(err error) tea.Msg { + return rerunDoneMsg{err: err} + }) +} diff --git a/rerun_test.go b/rerun_test.go index a72c8c2..4411fb4 100644 --- a/rerun_test.go +++ b/rerun_test.go @@ -128,3 +128,63 @@ func TestRerunDoneMsg_Error_ReturnsToListWithFlash(t *testing.T) { t.Errorf("flashMsg = %q, want it to contain error text %q", m.flashMsg, rerunErr.Error()) } } + +// ----- R resume tests ----- + +// fakeResumeFn is an injectable resumeFn that records the call args and +// immediately yields a rerunDoneMsg. +func fakeResumeFn(rerunErr error, gotID *string, gotCWD *string) func(id, cwd string) tea.Cmd { + return func(id, cwd string) tea.Cmd { + if gotID != nil { + *gotID = id + } + if gotCWD != nil { + *gotCWD = cwd + } + return func() tea.Msg { return rerunDoneMsg{err: rerunErr} } + } +} + +func TestModel_ListMode_R_InvokesResumeFn(t *testing.T) { + m := loadedModel("sess-abc") + m.visibleSessions[0] = Session{ID: "sess-abc", CWD: "/proj/abc", Project: "abc", Branch: "main"} + + var gotID, gotCWD string + m.resumeFn = fakeResumeFn(nil, &gotID, &gotCWD) + + next, cmd := m.Update(keyMsg("R")) + if cmd == nil { + t.Fatal("R in list mode returned nil cmd") + } + _ = next + + if gotID != "sess-abc" { + t.Errorf("resumeFn called with id=%q, want %q", gotID, "sess-abc") + } + if gotCWD != "/proj/abc" { + t.Errorf("resumeFn called with cwd=%q, want %q", gotCWD, "/proj/abc") + } +} + +func TestModel_DetailMode_R_InvokesResumeFn(t *testing.T) { + m := loadedModel("sess-xyz") + m.mode = modeDetail + m.detailSession = Session{ID: "sess-xyz", CWD: "/proj/xyz", Project: "xyz", Branch: "main"} + m.turns = []turn{{kind: "user", body: "hello"}} + + var gotID, gotCWD string + m.resumeFn = fakeResumeFn(nil, &gotID, &gotCWD) + + next, cmd := m.Update(keyMsg("R")) + if cmd == nil { + t.Fatal("R in detail mode returned nil cmd") + } + _ = next + + if gotID != "sess-xyz" { + t.Errorf("resumeFn called with id=%q, want %q", gotID, "sess-xyz") + } + if gotCWD != "/proj/xyz" { + t.Errorf("resumeFn called with cwd=%q, want %q", gotCWD, "/proj/xyz") + } +} diff --git a/search.go b/search.go index 6a7cf76..28f9db4 100644 --- a/search.go +++ b/search.go @@ -15,6 +15,68 @@ type SearchHit struct { Snippet string // first matching turn's text, truncated to ~80 chars } +// searchFilters holds the parsed structured filters from a search query. +// Empty strings mean "no filter". +type searchFilters struct { + project string + branch string +} + +// parseSearchQuery splits a query string into free text and structured filters. +// Recognized prefixes: project: and branch:. +// The prefix can appear anywhere in the query string. +func parseSearchQuery(q string) (text string, filters searchFilters) { + parts := strings.Fields(q) + var textParts []string + for _, p := range parts { + lower := strings.ToLower(p) + if strings.HasPrefix(lower, "project:") { + filters.project = p[len("project:"):] + } else if strings.HasPrefix(lower, "branch:") { + filters.branch = p[len("branch:"):] + } else { + textParts = append(textParts, p) + } + } + text = strings.Join(textParts, " ") + return +} + +// searchSessionsFiltered runs linear-scan search on text, then post-filters +// results by project and branch from filters. Prefix-free queries behave +// identically to the old searchSessions path. +func searchSessionsFiltered(sessions []Session, text string, filters searchFilters) []SearchHit { + candidates := sessions + if filters.project != "" { + var filtered []Session + for _, s := range sessions { + if strings.EqualFold(s.Project, filters.project) { + filtered = append(filtered, s) + } + } + candidates = filtered + } + if filters.branch != "" { + var filtered []Session + for _, s := range candidates { + if strings.EqualFold(s.Branch, filters.branch) { + filtered = append(filtered, s) + } + } + candidates = filtered + } + if text == "" { + // Filters only — return matching sessions as zero-hitcount hits so + // the search results view shows them. + var hits []SearchHit + for _, s := range candidates { + hits = append(hits, SearchHit{Session: s, HitCount: 1}) + } + return hits + } + return searchSessions(candidates, text) +} + // searchSessions performs a linear-scan full-text search across sessions. // For each session: // - Opens the file at session.Path diff --git a/search_test.go b/search_test.go index 7ad653f..f1c522c 100644 --- a/search_test.go +++ b/search_test.go @@ -441,3 +441,99 @@ func TestMatchAssistantEvent_SkipsThinking(t *testing.T) { t.Errorf("skip thinking: hits = %d, want 0", hits) } } + +// ----- Task 9: parseSearchQuery tests ----- + +func TestParseSearchQuery_NoPrefix(t *testing.T) { + text, filters := parseSearchQuery("hello world") + if text != "hello world" { + t.Errorf("text = %q, want %q", text, "hello world") + } + if filters.project != "" || filters.branch != "" { + t.Errorf("unexpected filters: %+v", filters) + } +} + +func TestParseSearchQuery_ProjectPrefix(t *testing.T) { + text, filters := parseSearchQuery("project:lore refresh token") + if text != "refresh token" { + t.Errorf("text = %q, want %q", text, "refresh token") + } + if filters.project != "lore" { + t.Errorf("project = %q, want %q", filters.project, "lore") + } +} + +func TestParseSearchQuery_BranchPrefix(t *testing.T) { + text, filters := parseSearchQuery("branch:main foo") + if text != "foo" { + t.Errorf("text = %q, want %q", text, "foo") + } + if filters.branch != "main" { + t.Errorf("branch = %q, want %q", filters.branch, "main") + } +} + +func TestParseSearchQuery_BothPrefixes(t *testing.T) { + text, filters := parseSearchQuery("project:lore branch:main query text") + if text != "query text" { + t.Errorf("text = %q, want %q", text, "query text") + } + if filters.project != "lore" { + t.Errorf("project = %q, want %q", filters.project, "lore") + } + if filters.branch != "main" { + t.Errorf("branch = %q, want %q", filters.branch, "main") + } +} + +func TestParseSearchQuery_PrefixAtEnd(t *testing.T) { + text, filters := parseSearchQuery("foo project:bar") + if filters.project != "bar" { + t.Errorf("project = %q, want %q", filters.project, "bar") + } + if text != "foo" { + t.Errorf("text = %q, want %q", text, "foo") + } +} + +func TestParseSearchQuery_OnlyPrefixes(t *testing.T) { + text, filters := parseSearchQuery("project:lore branch:feat/v0.8") + if text != "" { + t.Errorf("text = %q, want empty", text) + } + if filters.project != "lore" || filters.branch != "feat/v0.8" { + t.Errorf("unexpected filters: %+v", filters) + } +} + +func TestSearchSessions_ProjectFilter(t *testing.T) { + // Two sessions on different projects, same content. + dir := t.TempDir() + + writeSearchFixture(t, dir, "a.jsonl", "lore", "main", "index content") + writeSearchFixture(t, dir, "b.jsonl", "other", "main", "index content") + + ss := []Session{ + {ID: "a", Project: "lore", Branch: "main", Path: filepath.Join(dir, "a.jsonl")}, + {ID: "b", Project: "other", Branch: "main", Path: filepath.Join(dir, "b.jsonl")}, + } + + text, filters := parseSearchQuery("project:lore index") + results := searchSessionsFiltered(ss, text, filters) + + if len(results) != 1 { + t.Fatalf("got %d results, want 1", len(results)) + } + if results[0].Session.ID != "a" { + t.Errorf("got session %q, want %q", results[0].Session.ID, "a") + } +} + +func writeSearchFixture(t *testing.T, dir, name, project, branch, text string) { + t.Helper() + line := `{"type":"user","sessionId":"x","timestamp":"2026-01-01T00:00:00Z","cwd":"/` + project + `","gitBranch":"` + branch + `","message":{"content":"` + text + `"}}` + if err := os.WriteFile(filepath.Join(dir, name), []byte(line+"\n"), 0o644); err != nil { + t.Fatalf("writeSearchFixture: %v", err) + } +} diff --git a/session_test.go b/session_test.go index 65909e8..6b5d6fd 100644 --- a/session_test.go +++ b/session_test.go @@ -439,3 +439,28 @@ func TestScanSessions_NoWarningsWhenAllValid(t *testing.T) { t.Errorf("expected no warnings for valid files, got %d: %v", len(warnings), warnings) } } + +// FuzzParseSessionMetadata fuzz-tests the JSONL session metadata parser. +// Seeds cover the known valid event shape plus common malformed inputs. +func FuzzParseSessionMetadata(f *testing.F) { + // Seed: valid first user event + f.Add(`{"type":"user","sessionId":"abc","timestamp":"2026-01-01T00:00:00Z","cwd":"/x","gitBranch":"main","slug":"s"}`) + // Seed: empty + f.Add(``) + // Seed: non-JSON + f.Add(`not json at all`) + // Seed: valid first line but invalid second + f.Add("\"type\":\"queue\"\n{\"type\":\"user\",\"sessionId\":\"x\"}") + // Seed: valid JSON but wrong type + f.Add(`{"type":"assistant","message":{"content":"hi"}}`) + + f.Fuzz(func(t *testing.T, data string) { + // Must not panic regardless of input. + defer func() { + if r := recover(); r != nil { + t.Errorf("parseSessionMetadata panicked: %v", r) + } + }() + _, _ = parseSessionMetadata(strings.NewReader(data)) + }) +} diff --git a/stats.go b/stats.go index 9db519b..0586b0b 100644 --- a/stats.go +++ b/stats.go @@ -2,10 +2,13 @@ package lore import ( "bufio" + _ "embed" "encoding/json" "fmt" "io" + "os" "strings" + "sync" ) // SessionStats holds aggregated token usage for a single session. @@ -78,22 +81,48 @@ func parseSessionStats(r io.Reader) (SessionStats, error) { return stats, nil } -// modelPricing holds per-million-token rates for a model family. -type modelPricing struct { - inputPerMTok float64 // $ per 1M input tokens - outputPerMTok float64 // $ per 1M output tokens - cacheReadFraction float64 // fraction of input price for cache reads +//go:embed pricing.json +var embeddedPricingJSON []byte + +// pricingEntry is the JSON schema for one entry in pricing.json. +type pricingEntry struct { + Substr string `json:"substr"` + InputPerMTok float64 `json:"input_per_mtok"` + OutputPerMTok float64 `json:"output_per_mtok"` + CacheReadFraction float64 `json:"cache_read_fraction"` } -// pricingTable maps model name substrings to pricing. -// Matched by checking if the model string contains the key. -var pricingTable = []struct { - substr string - pricing modelPricing -}{ - {"opus", modelPricing{inputPerMTok: 15.0, outputPerMTok: 75.0, cacheReadFraction: 0.1}}, - {"sonnet", modelPricing{inputPerMTok: 3.0, outputPerMTok: 15.0, cacheReadFraction: 0.1}}, - {"haiku", modelPricing{inputPerMTok: 0.80, outputPerMTok: 4.0, cacheReadFraction: 0.1}}, +var ( + pricingOnce sync.Once + loadedPricing []pricingEntry +) + +// resetPricingOnce resets the sync.Once so tests can reload the table with a +// different LORE_PRICING_FILE environment variable. +func resetPricingOnce() { + pricingOnce = sync.Once{} + loadedPricing = nil +} + +// getPricingTable returns the pricing table, loading it on first call. +// Honors LORE_PRICING_FILE env var; falls back to the embedded pricing.json. +func getPricingTable() []pricingEntry { + pricingOnce.Do(func() { + data := embeddedPricingJSON + if path := os.Getenv("LORE_PRICING_FILE"); path != "" { + if b, err := os.ReadFile(path); err == nil { + data = b + } + } + var entries []pricingEntry + if err := json.Unmarshal(data, &entries); err == nil { + loadedPricing = entries + } else { + // Fallback: parse the embedded JSON (should never fail) + _ = json.Unmarshal(embeddedPricingJSON, &loadedPricing) + } + }) + return loadedPricing } // estimateCost returns an estimated USD cost for the given session stats. @@ -103,13 +132,12 @@ func estimateCost(stats SessionStats) float64 { return 0 } lower := strings.ToLower(stats.Model) - for _, entry := range pricingTable { - if strings.Contains(lower, entry.substr) { - p := entry.pricing - cost := float64(stats.InputTokens)/1_000_000*p.inputPerMTok + - float64(stats.OutputTokens)/1_000_000*p.outputPerMTok + - float64(stats.CacheReadTokens)/1_000_000*p.inputPerMTok*p.cacheReadFraction + - float64(stats.CacheWriteTokens)/1_000_000*p.inputPerMTok + for _, entry := range getPricingTable() { + if strings.Contains(lower, entry.Substr) { + cost := float64(stats.InputTokens)/1_000_000*entry.InputPerMTok + + float64(stats.OutputTokens)/1_000_000*entry.OutputPerMTok + + float64(stats.CacheReadTokens)/1_000_000*entry.InputPerMTok*entry.CacheReadFraction + + float64(stats.CacheWriteTokens)/1_000_000*entry.InputPerMTok return cost } } diff --git a/stats_test.go b/stats_test.go index 6f46342..02f8628 100644 --- a/stats_test.go +++ b/stats_test.go @@ -1,6 +1,7 @@ package lore import ( + "os" "strings" "testing" @@ -222,3 +223,28 @@ func TestFormatTokenCount(t *testing.T) { } } } + +// ----- LORE_PRICING_FILE override tests ----- + +func TestEstimateCost_PricingFileOverride(t *testing.T) { + // Write a temp pricing file with a custom rate (opus at $1/mtok in, $2/mtok out) + pricingJSON := `[{"substr":"opus","input_per_mtok":1.0,"output_per_mtok":2.0,"cache_read_fraction":0.0}]` + tmp := t.TempDir() + pf := tmp + "/custom_pricing.json" + if err := os.WriteFile(pf, []byte(pricingJSON), 0o644); err != nil { + t.Fatalf("write pricing file: %v", err) + } + t.Setenv("LORE_PRICING_FILE", pf) + resetPricingOnce() + + stats := SessionStats{ + Model: "claude-opus-4", + InputTokens: 1_000_000, + OutputTokens: 1_000_000, + } + got := estimateCost(stats) + want := 3.0 // $1 in + $2 out + if got != want { + t.Errorf("estimateCost with override = %.4f, want %.4f", got, want) + } +}