From 903948e0f612691791aa72387a52df78ace63fdd Mon Sep 17 00:00:00 2001 From: tdwd Date: Fri, 7 Aug 2026 16:32:53 +0200 Subject: [PATCH] =?UTF-8?q?Fix=20empty=20transcript=20on=20resume;=20add?= =?UTF-8?q?=20shift+=E2=86=91/=E2=86=93=20prompt=20jump?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resuming a session showed no chat history for any project whose path contains a space, dot, or underscore. projectSlug replaced only "/" with "-", but claude replaces every non-alphanumeric character, one dash each with no run-collapsing. So `/Users/w/Work/Triple Down/web` slugs to `-Users-w-Work-Triple-Down-web`, not `-Users-w-Work-Triple Down-web`. The lookup landed on a path that does not exist and both readers degrade silently to empty: the ctrl+r picker lost claude's own sessions (leaving only cathode's store, which is why resume still worked) and the replay rendered nothing. Every .claude-worktrees worktree was affected too, via the leading dot. The slug rule and the directory resolution move to projectdir.go, shared by the picker and the replay. When the slug misses, claudeProjectDir falls back to matching the cwd stamped inside the session records — ground truth, so resume survives future drift in claude's naming instead of failing silently. Second cause of the same symptom: claudeRecord typed message.content as an array, but claude also writes it as a bare string for a typed prompt. That decode fails, and loadPriorTranscript skipped the whole record on any error, dropping the user's own prompts from a replay. claude_sessions.go already handled both encodings in its own decoder; the two had diverged. They now share contentBlocks. Also lands jump.go: shift+↑/↓ steps the viewport through past prompts, deriving the anchor from vp.YOffset (via m.entryLine) so it composes with wheel, PageUp and auto-follow rather than holding a cursor to invalidate. --- CLAUDE.md | 4 +- README.md | 1 + claude_sessions.go | 43 ++++-------- commands.go | 1 + jump.go | 60 +++++++++++++++++ jump_test.go | 124 ++++++++++++++++++++++++++++++++++ keys.go | 9 +++ model.go | 6 ++ projectdir.go | 140 +++++++++++++++++++++++++++++++++++++++ projectdir_test.go | 161 +++++++++++++++++++++++++++++++++++++++++++++ render.go | 7 ++ transcript.go | 48 +++++++++----- 12 files changed, 556 insertions(+), 48 deletions(-) create mode 100644 jump.go create mode 100644 jump_test.go create mode 100644 projectdir.go create mode 100644 projectdir_test.go 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 != "" {