diff --git a/CLAUDE.md b/CLAUDE.md index 325c9c5..5ac7b18 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,7 +11,7 @@ make test # go test ./... go test -run TestApprovalsSSEFraming ./... # single test ``` -`go.sum` is checked in; run `go mod tidy` after changing dependencies to refresh it. The Go module is named `ccharness` and the repo directory is `cathode`, with the binary also `cathode` (set by `APP` in the Makefile) and the wordmark rendering as `cath0d3` (`appName` in `theme.go`); keep BUILD.md and README in sync when renaming. (The repo dir was renamed from `doorway`; because `claude` partitions its per-project session JSONLs by cwd slug under `~/.claude/projects/-`, a repo-path rename also requires moving that slug dir — else prior sessions stop surfacing in the resume picker.) The persisted state dir is `$XDG_STATE_HOME/cathode` (resolved in `state.go`); a one-time `migrateLegacyState` renames an old `$XDG_STATE_HOME/doorway` dir into it on first run so existing sessions/history/settings survive. All three stores (`sessions.go`, `history.go`, `settings.go`) go through `stateFilePath`/`stateDir` — don't re-derive the path inline, and change the dir name only with a matching migration. +`go.sum` is checked in; run `go mod tidy` after changing dependencies to refresh it. The Go module is named `ccharness` and the repo directory is `cathode`, with the binary also `cathode` (set by `APP` in the Makefile) and the wordmark rendering as `cath0d3` (`appName` in `theme.go`); keep BUILD.md and README in sync when renaming. (The repo dir was renamed from `doorway`; because `claude` partitions its per-project session JSONLs by cwd slug under `~/.claude/projects/`, a repo-path rename also requires moving that slug dir — else prior sessions stop surfacing in the resume picker. The slug rule is in `projectdir.go`: every character outside `[A-Za-z0-9-]` becomes `-`, one dash per character and no run-collapsing, so `/Users/w/Work/Triple Down/web` → `-Users-w-Work-Triple-Down-web` and `/Users/w/.config/wezterm` → `-Users-w--config-wezterm`. Getting this wrong is silent — the lookup just lands on a path that doesn't exist, which empties both the ctrl+r picker and the resumed transcript — so `claudeProjectDir` falls back to matching the `cwd` field stamped inside the session records when the slug misses. Both readers go through it; never rebuild the path inline.) The persisted state dir is `$XDG_STATE_HOME/cathode` (resolved in `state.go`); a one-time `migrateLegacyState` renames an old `$XDG_STATE_HOME/doorway` dir into it on first run so existing sessions/history/settings survive. All three stores (`sessions.go`, `history.go`, `settings.go`) go through `stateFilePath`/`stateDir` — don't re-derive the path inline, and change the dir name only with a matching migration. Running the app requires the `claude` CLI on PATH with `claude login` already completed (Pro/Max account). Verify with `claude` + `/status` showing the subscription route — anything else means you'll bill the API. @@ -48,6 +48,8 @@ Key subtleties: The transcript is stored as a `[]entry` of raw text/data, not pre-rendered strings. `rebuild()` renders each entry **once** into a retained buffer (per-entry cache keyed by wrap width) — the common case appends only the new tail, which is what keeps long sessions O(new) per message instead of the old O(n²). A full re-render happens only when the width changes or entries were removed; anything else that changes how existing entries render (theme swap, diff-style toggle) must go through `rerender()`, which drops the cache first. The composed frame body (viewport + scrollbar + sidebar) is additionally memoized per `bodyKey` (`view.go:refreshBody`), so typing and header animation don't re-style the transcript. If you add a new entry kind, add a case to `renderEntry()`; if you add a rendering-relevant setting, key `bodyKey` on it and route its commit through `rerender()`. +`appendEntry` also records each entry's first content line in `m.entryLine`, which is what makes an entry index addressable as a scroll offset — `jump.go` (shift+↑/↓ steps through your past prompts) turns `entryLine[i]` straight into `vp.SetYOffset`. That mapping is only exact because every renderer wraps to `m.vp.Width`, so one content line is one viewport line; a renderer that emits lines wider than the viewport would desync it. The index is rebuilt with the buffer, so it can't drift. + ### Theme discipline (`theme.go`, `splash.go`) The BBS look (leet/studly/ornament/scene-divider helpers) is applied to chrome only — banner, dividers, status, labels, splash. Claude's replies and the diff body stay plain and readable. Don't sprinkle `leet()`/`studly()` into transcript content. Theming is the `palettes` map in `theme.go` (11 built-in themes, ten colors each, switched live via `/theme` and persisted); add a theme by adding a palette row + a `themes` entry — every style rebuilds from the active palette in `buildStyles`. The wordmark is `appName` in `theme.go` (rendered `cath0d3`), and the splash shows a random pick from `logoVariants` in `logos.go` (regenerate a row with `figlet -f -w 200 "cath0d3" | tr '\140' "'"`). The marketing SVGs and per-theme shots in `assets/` regenerate from live UI code via `CATHODE_GENASSETS=1 go test -run 'TestGenerateAssets|TestGenerateThemeAssets'` — regenerate them whenever chrome the preview shows (status bar, banner, diff card) changes. diff --git a/README.md b/README.md index 1577579..d66ac1c 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ subscription** because we never set an API key. - **Info sidebar** — `ctrl+g` / `/sidebar` toggles an at-a-glance BBS info rail; `/sidebar left|right` (or `/settings`) sets the side it docks to (default right). - **Bring your own tools** — point `-mcp` at a `.mcp.json` to wire extra MCP tools alongside the built-in approvals server. - **Multi-line input** — Enter sends; insert a line break with `Alt+Enter`, `Ctrl+J`, or a trailing `\`. The prompt grows with your draft — line breaks *and* soft-wrap in narrow windows — up to 8 rows, then scrolls. +- **Jump back through your prompts** — `Shift+↑` / `Shift+↓` scroll the transcript one *turn* at a time, parking each of your past prompts at the top of the view; stepping past the newest one drops you back at the live bottom. - **Prompt history & steering** — `↑` / `↓` recalls past prompts (use `Ctrl+↑/↓` while composing a multi-line draft, where `↑/↓` move between lines); type while Claude is busy and the message is injected into the running turn, so you can course-correct mid-flight instead of waiting for it to finish (`Esc` interrupts the turn to undo a mis-sent steer). ## Why this architecture (vs forking Crush/OpenCode) diff --git a/claude_sessions.go b/claude_sessions.go index 7c3ffd5..140e2b9 100644 --- a/claude_sessions.go +++ b/claude_sessions.go @@ -9,17 +9,6 @@ import ( "strings" ) -// claudeProjectDir returns ~/.claude/projects/, where slug is cwd with -// every "/" replaced by "-". This is where claude persists one JSONL per -// session for the project — the same path layout transcript.go reads from. -func claudeProjectDir(cwd string) (string, error) { - home, err := os.UserHomeDir() - if err != nil { - return "", err - } - return filepath.Join(home, ".claude", "projects", projectSlug(cwd)), nil -} - // listClaudeSessions enumerates claude's per-project session files for cwd, // most-recently-modified first. The session ID is the filename stem; LastUsed // is the file mtime (claude rewrites the JSONL on every turn, so mtime tracks @@ -69,7 +58,7 @@ func parseSessionHead(path string) (firstPrompt, model string) { } defer f.Close() sc := bufio.NewScanner(f) - sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) + sc.Buffer(make([]byte, 0, 64*1024), maxRecordBytes) for sc.Scan() { var rec struct { Type string `json:"type"` @@ -86,7 +75,7 @@ func parseSessionHead(path string) (firstPrompt, model string) { model = rec.Message.Model } if firstPrompt == "" && rec.Type == "user" && rec.Message.Role == "user" { - firstPrompt = extractUserText(rec.Message.Content) + firstPrompt = firstText(rec.Message.Content) } if firstPrompt != "" && model != "" { return @@ -95,24 +84,16 @@ func parseSessionHead(path string) (firstPrompt, model string) { return } -// extractUserText pulls the first non-empty user-typed text from a message's -// content. Handles both encodings claude emits — a bare string, or an array -// of typed content blocks — and skips non-text blocks (tool_result wrappers -// also show up under type="user" but aren't what the human typed). -func extractUserText(raw json.RawMessage) string { - var s string - if err := json.Unmarshal(raw, &s); err == nil && strings.TrimSpace(s) != "" { - return s - } - var arr []struct { - Type string `json:"type"` - Text string `json:"text"` - } - if err := json.Unmarshal(raw, &arr); err == nil { - for _, c := range arr { - if c.Type == "text" && strings.TrimSpace(c.Text) != "" { - return c.Text - } +// firstText pulls the first non-empty user-typed text out of a message's +// content, skipping non-text blocks (tool_result wrappers also show up under +// type="user" but aren't what the human typed). Both of claude's content +// encodings are handled by contentBlocks (transcript.go) — this and the +// transcript replay must decode identically or the picker and the replay +// disagree about which sessions have any conversation in them. +func firstText(raw json.RawMessage) string { + for _, c := range contentBlocks(raw) { + if c.Type == "text" && strings.TrimSpace(c.Text) != "" { + return c.Text } } return "" diff --git a/commands.go b/commands.go index 4c52871..68d4e53 100644 --- a/commands.go +++ b/commands.go @@ -329,6 +329,7 @@ func helpText() string { b.WriteString(" ? open this help modal\n") b.WriteString(" ↑ / ↓ history · cursor between lines (multi-line) · scroll (mouse off)\n") b.WriteString(" ctrl+↑ / ↓ prompt history (always)\n") + b.WriteString(" shift+↑ / ↓ jump to your previous / next prompt in the transcript\n") b.WriteString(" shift+scroll drop into select mode (terminals that forward it; /mouse returns)\n") b.WriteString(" esc interrupt the running turn (or quit when idle)\n") b.WriteString(" ctrl+c interrupt the running turn · again to quit\n") diff --git a/jump.go b/jump.go new file mode 100644 index 0000000..412464b --- /dev/null +++ b/jump.go @@ -0,0 +1,60 @@ +package main + +// Jump-to-prompt (shift+↑ / shift+↓). +// +// Stepping back through your own turns is a scroll, not a mode: the target +// prompt's first line becomes the viewport's top line, and that's the whole +// interaction. The anchor is *derived* from vp.YOffset rather than stored, so +// jumping composes with every other way the transcript moves — wheel, PageUp, +// auto-follow — and there's no cursor to invalidate when entries are appended +// or /clear drops them all. +// +// The entry → line mapping is m.entryLine, maintained by appendEntry +// (render.go) as the transcript is rendered. + +// jumpPrompt scrolls to the nearest user entry above (delta < 0) or below +// (delta > 0) the current top line. Stepping past the newest prompt returns to +// the bottom and re-arms auto-follow — that's how you get back to live output. +// A press with nothing to move to is a no-op (still consumed, so it never +// leaks into the prompt). +func (m *model) jumpPrompt(delta int) { + // entryLine only covers what's been rendered (== renderedCount), which lags + // entries by at most the tail rebuild() is about to add. + n := len(m.entryLine) + if n > len(m.entries) { + n = len(m.entries) + } + if !m.ready || n == 0 { + return + } + off := m.vp.YOffset + if delta < 0 { + // Strictly above the top line, so repeated presses keep walking back + // instead of re-selecting the prompt already parked at the top. + for i := n - 1; i >= 0; i-- { + if m.entries[i].kind == entUser && m.entryLine[i] < off { + m.scrollToLine(m.entryLine[i]) + return + } + } + return + } + for i := 0; i < n; i++ { + if m.entries[i].kind == entUser && m.entryLine[i] > off { + m.scrollToLine(m.entryLine[i]) + return + } + } + // Nothing below: we're at the last prompt already, so rejoin the stream. + m.follow = true + m.vp.GotoBottom() +} + +// scrollToLine parks a content line at the top of the viewport. SetYOffset +// clamps within the last screenful, so a prompt near the end lands as close to +// the top as it can — and if that clamp puts us at the bottom, auto-follow +// re-arms rather than leaving the transcript silently frozen. +func (m *model) scrollToLine(line int) { + m.vp.SetYOffset(line) + m.follow = m.vp.AtBottom() +} diff --git a/jump_test.go b/jump_test.go new file mode 100644 index 0000000..8b4ec50 --- /dev/null +++ b/jump_test.go @@ -0,0 +1,124 @@ +package main + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" +) + +// jumpModel renders a transcript of three prompts separated by replies tall +// enough that each prompt can reach the top of the (short) viewport. +func jumpModel() model { + m := model{w: 60, h: 24, ready: true, follow: true} + m.vp = newTranscriptViewport(58, 6) + reply := strings.TrimRight(strings.Repeat("a line of reply\n", 8), "\n") + for _, q := range []string{"first question", "second question", "third question"} { + m.entries = append(m.entries, + entry{kind: entUser, text: q}, + entry{kind: entClaude, text: reply}) + } + m.rebuild() + return m +} + +// userLines returns the content line each user entry was recorded at. +func userLines(m model) []int { + var out []int + for i, e := range m.entries { + if e.kind == entUser { + out = append(out, m.entryLine[i]) + } + } + return out +} + +// The whole feature rests on entryLine indexing real viewport lines: line +// entryLine[i] of the content must be the first line of entry i's render. +func TestEntryLineIndexesContent(t *testing.T) { + m := jumpModel() + lines := strings.Split(m.content.String(), "\n") + if len(m.entryLine) != len(m.entries) { + t.Fatalf("entryLine has %d entries, want %d", len(m.entryLine), len(m.entries)) + } + for i, e := range m.entries { + want := strings.Split(linkify(m.renderEntry(e)), "\n")[0] + at := m.entryLine[i] + if at >= len(lines) { + t.Fatalf("entry %d: line %d past end of content (%d lines)", i, at, len(lines)) + } + if lines[at] != want { + t.Errorf("entry %d: content line %d = %q, want %q", i, at, lines[at], want) + } + } +} + +// Shift+↑ walks back one prompt at a time; shift+↓ walks forward and, past the +// last prompt, returns to the live bottom. +func TestJumpPromptSteps(t *testing.T) { + m := jumpModel() + prompts := userLines(m) + if len(prompts) != 3 { + t.Fatalf("got %d prompts, want 3", len(prompts)) + } + + m.jumpPrompt(-1) + if m.vp.YOffset != prompts[2] { + t.Fatalf("first shift+up: offset %d, want the last prompt at %d", m.vp.YOffset, prompts[2]) + } + if m.follow { + t.Error("jumping up should release auto-follow") + } + m.jumpPrompt(-1) + if m.vp.YOffset != prompts[1] { + t.Fatalf("second shift+up: offset %d, want %d", m.vp.YOffset, prompts[1]) + } + m.jumpPrompt(1) + if m.vp.YOffset != prompts[2] { + t.Fatalf("shift+down: offset %d, want %d", m.vp.YOffset, prompts[2]) + } + m.jumpPrompt(1) + if !m.vp.AtBottom() || !m.follow { + t.Errorf("stepping past the last prompt should return to the bottom and follow (atBottom=%v follow=%v)", + m.vp.AtBottom(), m.follow) + } +} + +// Walking off the top is a no-op, not a scroll to line 0 (entry 0 IS a prompt, +// so "above the first prompt" must find nothing rather than re-selecting it). +func TestJumpPromptStopsAtTop(t *testing.T) { + m := jumpModel() + for i := 0; i < 5; i++ { + m.jumpPrompt(-1) + } + if got := m.vp.YOffset; got != m.entryLine[0] { + t.Fatalf("offset %d after walking off the top, want the first prompt at %d", got, m.entryLine[0]) + } +} + +// An empty transcript can't crash the binding, and the key is consumed rather +// than falling through to the textarea. +func TestJumpPromptEmpty(t *testing.T) { + m := model{w: 60, h: 24, ready: true, follow: true} + m.vp = newTranscriptViewport(58, 6) + m.input = newPromptArea() + m.jumpPrompt(-1) + if _, _, handled := m.handleKey(tea.KeyMsg{Type: tea.KeyShiftUp}); !handled { + t.Error("shift+up should be consumed by the jump handler") + } +} + +// /clear resets the transcript; the line index must reset with it or jumps +// would point at lines that no longer exist. +func TestJumpIndexResetsOnClear(t *testing.T) { + m := jumpModel() + m.entries = m.entries[:0] + m.rebuild() + if len(m.entryLine) != 0 || m.lineCount != 0 { + t.Fatalf("after clear: entryLine=%d lineCount=%d, want 0/0", len(m.entryLine), m.lineCount) + } + m.add(entUser, "fresh start") + if m.entryLine[0] != 0 { + t.Errorf("first entry after clear starts at line %d, want 0", m.entryLine[0]) + } +} diff --git a/keys.go b/keys.go index d36b1e9..1b3d234 100644 --- a/keys.go +++ b/keys.go @@ -215,6 +215,15 @@ func (m model) handleKey(msg tea.KeyMsg) (model, tea.Cmd, bool) { m.vp.ViewDown() m.follow = m.vp.AtBottom() return m, nil, true + // Shift+↑/↓ page by *prompt* — the same gesture at a coarser grain, for + // finding what you asked earlier (jump.go). Consumed either way so an + // unavailable step can't fall through into the textarea. + case "shift+up": + m.jumpPrompt(-1) + return m, nil, true + case "shift+down": + m.jumpPrompt(1) + return m, nil, true } // Shift+Tab cycles permission mode without restarting the subprocess. diff --git a/model.go b/model.go index e6e0124..6d0c0ac 100644 --- a/model.go +++ b/model.go @@ -127,6 +127,12 @@ type model struct { content *strings.Builder renderedCount int cacheWidth int + // entryLine[i] is the content line entry i starts on; lineCount is how many + // lines are in the buffer so far. Both are maintained by appendEntry and + // cleared with the buffer, so they can't drift from what the viewport shows. + // jump.go uses them to scroll a chosen prompt to the top (shift+↑/↓). + entryLine []int + lineCount int // frameBody memoizes the composed transcript body (viewport + scrollbar + // sidebar) — ~80% of a frame's cost, and identical between frames while you // type or the wordmark shimmers. refreshBody (update.go) rebuilds it only diff --git a/projectdir.go b/projectdir.go new file mode 100644 index 0000000..a07536c --- /dev/null +++ b/projectdir.go @@ -0,0 +1,140 @@ +package main + +import ( + "bufio" + "encoding/json" + "os" + "path/filepath" + "strings" +) + +// maxRecordBytes caps one line of a claude session JSONL. Tool results are the +// long pole (a whole file read lands in one record), so the default 64K scanner +// limit is nowhere near enough. +const maxRecordBytes = 16 << 20 + +// cwdProbeLines is how far into a session file we look for the cwd stamp before +// moving on. claude writes "cwd" on nearly every record, but the head of a file +// is queue-operation bookkeeping that carries none — in practice it shows up by +// line three. +const cwdProbeLines = 20 + +// projectSlug encodes a cwd the way claude names its per-project session +// directory: every character outside [A-Za-z0-9-] becomes "-", one dash per +// character — runs are never collapsed. So `/Users/w/Work/Triple Down/web` +// slugs to `-Users-w-Work-Triple-Down-web` (the space is a dash like the +// slashes), and `/Users/w/.config/wezterm` to `-Users-w--config-wezterm` (the +// "/" and the "." each contribute one). +// +// This used to replace only "/", which silently broke every project whose path +// contains a space, dot, or underscore: the lookup pointed at a directory that +// does not exist, so the ctrl+r picker lost claude's own sessions and a resumed +// session replayed an empty transcript. +func projectSlug(cwd string) string { + var b strings.Builder + b.Grow(len(cwd)) + for _, r := range cwd { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-': + b.WriteRune(r) + default: + b.WriteByte('-') + } + } + return b.String() +} + +// claudeProjectsRoot is ~/.claude/projects, the parent of every per-project +// session directory. +func claudeProjectsRoot() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".claude", "projects"), nil +} + +// claudeProjectDir resolves the directory where claude persists one JSONL per +// session for cwd — the path both the resume picker (claude_sessions.go) and +// the transcript replay (transcript.go) read from. +// +// The slug is the fast path. When it misses we ask the sessions themselves, +// since every record is stamped with the cwd it was recorded in; that keeps +// resume working even where claude's naming rule and ours disagree on some +// character (non-ASCII, say) that hasn't been pinned down. If nothing matches, +// the slug path comes back anyway so callers simply fail to open it — a project +// claude has never seen degrades to "no sessions", not an error. +func claudeProjectDir(cwd string) (string, error) { + root, err := claudeProjectsRoot() + if err != nil { + return "", err + } + slugged := filepath.Join(root, projectSlug(cwd)) + if fi, err := os.Stat(slugged); err == nil && fi.IsDir() { + return slugged, nil + } + if dir := findProjectDirByCwd(root, cwd); dir != "" { + return dir, nil + } + return slugged, nil +} + +// findProjectDirByCwd scans ~/.claude/projects for the directory whose sessions +// were recorded in cwd. Only reached when the slug lookup missed, so the cost — +// a partial read of one file per project — stays off the common path. +func findProjectDirByCwd(root, cwd string) string { + ents, err := os.ReadDir(root) + if err != nil { + return "" + } + want := filepath.Clean(cwd) + for _, e := range ents { + if !e.IsDir() { + continue + } + dir := filepath.Join(root, e.Name()) + if recordedCwd(dir) == want { + return dir + } + } + return "" +} + +// recordedCwd returns the first cwd stamped into any session file in dir, or "" +// if the directory holds no readable session. Files are tried in turn so one +// truncated JSONL doesn't disqualify the whole project. +func recordedCwd(dir string) string { + ents, err := os.ReadDir(dir) + if err != nil { + return "" + } + for _, e := range ents { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") { + continue + } + if c := sessionCwd(filepath.Join(dir, e.Name())); c != "" { + return c + } + } + return "" +} + +// sessionCwd reads the cwd stamp off the head of one session JSONL. +func sessionCwd(path string) string { + f, err := os.Open(path) + if err != nil { + return "" + } + defer f.Close() + sc := bufio.NewScanner(f) + sc.Buffer(make([]byte, 0, 64*1024), maxRecordBytes) + for i := 0; i < cwdProbeLines && sc.Scan(); i++ { + var rec struct { + Cwd string `json:"cwd"` + } + if err := json.Unmarshal(sc.Bytes(), &rec); err == nil && rec.Cwd != "" { + return filepath.Clean(rec.Cwd) + } + } + return "" +} diff --git a/projectdir_test.go b/projectdir_test.go new file mode 100644 index 0000000..472a68d --- /dev/null +++ b/projectdir_test.go @@ -0,0 +1,161 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +// TestProjectSlugMatchesClaude pins the naming rule against real directories +// observed under ~/.claude/projects. Every one of these used to resolve to a +// path that does not exist, which is what emptied the resume picker and the +// replayed transcript for those projects. +func TestProjectSlugMatchesClaude(t *testing.T) { + cases := []struct{ cwd, want string }{ + {"/Users/w/Work/Cathode/cathode", "-Users-w-Work-Cathode-cathode"}, + {"/Users/w/Work/Triple Down/web", "-Users-w-Work-Triple-Down-web"}, // space + {"/Users/w/.config/wezterm", "-Users-w--config-wezterm"}, // dot: two dashes, no collapsing + {"/Users/w/Work/BunkerIntel/fuel_forecaster", "-Users-w-Work-BunkerIntel-fuel-forecaster"}, // underscore + {"/Users/w/Work/Tessio Provenance", "-Users-w-Work-Tessio-Provenance"}, + } + for _, c := range cases { + if got := projectSlug(c.cwd); got != c.want { + t.Errorf("projectSlug(%q) = %q, want %q", c.cwd, got, c.want) + } + } +} + +// TestClaudeProjectDirFallsBackToRecordedCwd covers the case the slug rule +// can't: a directory claude named by some rule we don't reproduce. The cwd +// stamped inside the session records is the ground truth, so resume still +// finds it. +func TestClaudeProjectDirFallsBackToRecordedCwd(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + cwd := "/work/muskö.ai" + + // Deliberately NOT projectSlug(cwd) — this stands in for any naming rule + // we'd otherwise miss. + dir := filepath.Join(home, ".claude", "projects", "-work-muskö-ai") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + // Head-of-file bookkeeping carries no cwd, exactly like the real thing. + body := `{"type":"queue-operation","operation":"enqueue"} +{"type":"user","cwd":"/work/musk` + "ö" + `.ai","message":{"role":"user","content":"hello"}} +` + if err := os.WriteFile(filepath.Join(dir, "sess.jsonl"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + + got, err := claudeProjectDir(cwd) + if err != nil { + t.Fatal(err) + } + if got != dir { + t.Fatalf("claudeProjectDir = %q, want %q (found via recorded cwd)", got, dir) + } + + // And an unrelated project must not be claimed by the scan. + other, err := claudeProjectDir("/work/somewhere-else") + if err != nil { + t.Fatal(err) + } + if other == dir { + t.Fatalf("unrelated cwd matched %q", dir) + } +} + +// TestClaudeProjectDirPrefersSlug keeps the scan off the common path: when the +// slugged directory exists it wins outright, no directory walk. +func TestClaudeProjectDirPrefersSlug(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + cwd := "/work/repo A" + + want := filepath.Join(home, ".claude", "projects", projectSlug(cwd)) + if err := os.MkdirAll(want, 0o755); err != nil { + t.Fatal(err) + } + // A decoy whose records claim the same cwd — the slug match must win. + decoy := filepath.Join(home, ".claude", "projects", "aaa-decoy") + if err := os.MkdirAll(decoy, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(decoy, "s.jsonl"), []byte(`{"cwd":"/work/repo A"}`+"\n"), 0o644); err != nil { + t.Fatal(err) + } + + got, err := claudeProjectDir(cwd) + if err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("claudeProjectDir = %q, want %q", got, want) + } +} + +// TestLoadPriorTranscriptSpacedCwd is the end-to-end regression: a project whose +// path contains a space replays its history instead of coming back empty. +func TestLoadPriorTranscriptSpacedCwd(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + // loadPriorTranscript reads os.Getwd(), so put the test in a real spaced dir. + work := filepath.Join(t.TempDir(), "Triple Down", "web") + if err := os.MkdirAll(work, 0o755); err != nil { + t.Fatal(err) + } + prev, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(work); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chdir(prev) }) + // Take the cwd back from the OS: on macOS the temp dir resolves through a + // symlink (/var → /private/var), and it's the resolved path that has to slug. + work, err = os.Getwd() + if err != nil { + t.Fatal(err) + } + + dir := filepath.Join(home, ".claude", "projects", projectSlug(work)) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + body := `{"type":"user","message":{"role":"user","content":"first prompt"}} +{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"a reply"}]}} +` + if err := os.WriteFile(filepath.Join(dir, "sess.jsonl"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + + entries, _ := loadPriorTranscript("sess", 40) + if len(entries) != 2 { + t.Fatalf("entries = %d, want 2 (spaced cwd must still resolve)", len(entries)) + } + if entries[0].kind != entUser || entries[0].text != "first prompt" { + t.Fatalf("entry 0 = %+v, want the bare-string user prompt", entries[0]) + } +} + +// TestContentBlocksBothEncodings pins the decoder that the replay and the +// picker now share: a bare string is a text block, an array passes through. +func TestContentBlocksBothEncodings(t *testing.T) { + got := contentBlocks([]byte(`"just text"`)) + if len(got) != 1 || got[0].Type != "text" || got[0].Text != "just text" { + t.Fatalf("string content = %+v", got) + } + got = contentBlocks([]byte(`[{"type":"text","text":"hi"},{"type":"tool_use","name":"Edit"}]`)) + if len(got) != 2 || got[1].Name != "Edit" { + t.Fatalf("array content = %+v", got) + } + if b := contentBlocks([]byte(`" "`)); len(b) != 0 { + t.Fatalf("blank string content = %+v, want none", b) + } + if b := contentBlocks([]byte(`null`)); len(b) != 0 { + t.Fatalf("null content = %+v, want none", b) + } +} diff --git a/render.go b/render.go index fbd3cc3..9035f3f 100644 --- a/render.go +++ b/render.go @@ -23,6 +23,7 @@ func (m *model) rebuild() { if m.cacheWidth != m.vp.Width || m.renderedCount > len(m.entries) { m.content.Reset() m.renderedCount = 0 + m.entryLine, m.lineCount = m.entryLine[:0], 0 for _, e := range m.entries { m.appendEntry(linkify(m.renderEntry(e))) } @@ -49,12 +50,18 @@ func (m *model) rebuild() { // before every entry after the first, then the render and a terminating // newline. This reproduces the old "join with \n\n plus a trailing \n" layout // while only touching the new tail. +// It also records where the entry starts, which is what makes an entry index +// addressable as a scroll offset (jump.go). The count is exact because every +// renderer wraps to m.vp.Width, so one content line is one viewport line. func (m *model) appendEntry(s string) { if m.renderedCount > 0 { m.content.WriteString("\n") + m.lineCount++ } + m.entryLine = append(m.entryLine, m.lineCount) m.content.WriteString(s) m.content.WriteString("\n") + m.lineCount += strings.Count(s, "\n") + 1 m.renderedCount++ } diff --git a/transcript.go b/transcript.go index 58ac625..5b74f9e 100644 --- a/transcript.go +++ b/transcript.go @@ -11,16 +11,12 @@ import ( // claudeRecord is the minimal shape we need from one line of claude's session // JSONL. Each turn writes a {"type":"user"|"assistant", "message":{...}} record; // many other record types (queue-operation, summary, hook events) get ignored. +// Content stays raw because claude writes it two ways — see contentBlocks. type claudeRecord struct { Type string `json:"type"` Message *struct { - Role string `json:"role"` - Content []struct { - Type string `json:"type"` - Text string `json:"text"` - Name string `json:"name"` - Input json.RawMessage `json:"input"` - } `json:"content"` + Role string `json:"role"` + Content json.RawMessage `json:"content"` // usage is present on assistant records; its input + cache totals give // the context size at that turn, which we replay into the gauge on // resume (see loadPriorTranscript / model.go). @@ -28,25 +24,45 @@ type claudeRecord struct { } `json:"message"` } -// projectSlug encodes a cwd the same way claude does — every `/` replaced with -// `-` — so `/Users/foo/bar` becomes `-Users-foo-bar`. -func projectSlug(cwd string) string { - return strings.ReplaceAll(cwd, "/", "-") +// contentBlock is one element of a message's content array. +type contentBlock struct { + Type string `json:"type"` + Text string `json:"text"` + Name string `json:"name"` + Input json.RawMessage `json:"input"` +} + +// contentBlocks decodes both encodings claude uses for message.content: an +// array of typed blocks, or a bare string for a plain typed prompt. Decoding +// the bare-string form into a []contentBlock fails, and the record it belongs +// to is the user's own prompt — so treating that as "unparseable, skip" dropped +// half the conversation out of a replayed transcript. +func contentBlocks(raw json.RawMessage) []contentBlock { + var s string + if err := json.Unmarshal(raw, &s); err == nil { + if strings.TrimSpace(s) == "" { + return nil + } + return []contentBlock{{Type: "text", Text: s}} + } + var blocks []contentBlock + _ = json.Unmarshal(raw, &blocks) + return blocks } // sessionTranscriptPath returns the on-disk path to claude's JSONL transcript // for the given session, scoped to the current cwd. Returns "" + error if the // home dir or cwd can't be resolved. func sessionTranscriptPath(sessionID string) (string, error) { - home, err := os.UserHomeDir() + cwd, err := os.Getwd() if err != nil { return "", err } - cwd, err := os.Getwd() + dir, err := claudeProjectDir(cwd) if err != nil { return "", err } - return filepath.Join(home, ".claude", "projects", projectSlug(cwd), sessionID+".jsonl"), nil + return filepath.Join(dir, sessionID+".jsonl"), nil } // loadPriorTranscript reads claude's persisted session for sessionID and @@ -68,7 +84,7 @@ func loadPriorTranscript(sessionID string, maxEntries int) (entries []entry, ctx defer f.Close() sc := bufio.NewScanner(f) - sc.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) // tool results can be large + sc.Buffer(make([]byte, 0, 64*1024), maxRecordBytes) // tool results can be large for sc.Scan() { var rec claudeRecord if err := json.Unmarshal(sc.Bytes(), &rec); err != nil || rec.Message == nil { @@ -80,7 +96,7 @@ func loadPriorTranscript(sessionID string, maxEntries int) (entries []entry, ctx u := rec.Message.Usage ctxTokens = u.InputTokens + u.CacheReadInputTokens + u.CacheCreationInputTokens } - for _, c := range rec.Message.Content { + for _, c := range contentBlocks(rec.Message.Content) { switch c.Type { case "text": if t := strings.TrimSpace(c.Text); t != "" {