Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/-<abs-path>`, 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.

Expand Down Expand Up @@ -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 <font> -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.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
43 changes: 12 additions & 31 deletions claude_sessions.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,6 @@ import (
"strings"
)

// claudeProjectDir returns ~/.claude/projects/<slug>, 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
Expand Down Expand Up @@ -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"`
Expand All @@ -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
Expand All @@ -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 ""
Expand Down
1 change: 1 addition & 0 deletions commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
60 changes: 60 additions & 0 deletions jump.go
Original file line number Diff line number Diff line change
@@ -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()
}
124 changes: 124 additions & 0 deletions jump_test.go
Original file line number Diff line number Diff line change
@@ -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])
}
}
9 changes: 9 additions & 0 deletions keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading