diff --git a/BUILD.md b/BUILD.md index efcf904..32e240e 100644 --- a/BUILD.md +++ b/BUILD.md @@ -1,7 +1,8 @@ # Building cathode locally -Requirements: **Go 1.22+** and the **`claude` CLI** (logged in with your Pro/Max -account: `claude login`). +Requirements: **Go 1.22+** and the CLI of whichever backend you drive — the +**`claude` CLI** logged in with your Pro/Max account (`claude login`), and/or +the **`codex` CLI** logged in with ChatGPT (`codex login`) for `-backend codex`. ```bash cd cathode diff --git a/README.md b/README.md index 01136a9..c41abe0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # Cathode -A **Bubble Tea TUI over the Claude Code stream-json protocol** (wordmark: `cath0d3`). +A **Bubble Tea TUI over an agent CLI's own protocol** (wordmark: `cath0d3`). +Drives `claude` over Claude Code stream-json, or `codex` over its `app-server` +JSON-RPC — see [Backends](#backends).

cath0d3 boot splash

@@ -10,22 +12,26 @@ A **Bubble Tea TUI over the Claude Code stream-json protocol** (wordmark: `cath0 Rendered in the built-in **Catppuccin Mocha** theme — the look is switchable, see Themes. The agent loop, context management, tool execution, and auth all live in the -official `claude` binary, which runs as a long-lived subprocess. This program -owns only the terminal UI and the stdin/stdout plumbing — so you build your own -experience without re-implementing an agent, and you ride your **Max -subscription** because we never set an API key. +official vendor binary, which runs as a long-lived subprocess. This program owns +only the terminal UI and the stdin/stdout plumbing — so you build your own +experience without re-implementing an agent, and you ride your **subscription** +because we never set an API key. ## Features -- **Rides your Pro/Max subscription** — drives the real `claude` CLI as a subprocess and scrubs `ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN` from its env, so it never silently falls back to API billing. +This list describes the claude backend, which is the complete one. The codex +backend shares the UI but not all of it — [Backends](#backends) says exactly +what differs. + +- **Rides your subscription** — drives the real vendor CLI as a subprocess and scrubs the API-key variables from its env, so it never silently falls back to API billing. Pro/Max on claude, ChatGPT on codex. - **Four permission modes** — `plan`, `ask`, `build` (auto-accept edits), `bypass`; cycle with `shift+tab` or `/mode`. - **Inline approvals** — in `ask` mode every gated tool call raises a `[ENTER] allow · [ESC] deny` bar (served by a hand-rolled in-process MCP permission server); edits show the diff before you decide. -- **Answers Claude's questions** — when Claude asks (its `AskUserQuestion` tool), the options pop up as a picker to choose from — always presented (never auto-approved, even in `build`/`bypass`), so a question actually waits for your answer instead of erroring out. +- **Answers the agent's questions** — when claude asks (its `AskUserQuestion` tool), the options pop up as a picker to choose from — always presented (never auto-approved, even in `build`/`bypass`), so a question actually waits for your answer instead of erroring out. - **Visual diff cards** — `Edit` / `Write` / `MultiEdit` render as line-numbered red/green diffs instead of raw JSON, unified or side-by-side **split** (`/diff` or `/settings`). -- **Markdown replies** — Claude's output is rendered with Glamour and reflows on resize. +- **Markdown replies** — the agent's output is rendered with Glamour and reflows on resize. - **Clickable links** — `http(s)` URLs in the transcript are wrapped as OSC 8 terminal hyperlinks (Cmd/Ctrl-click, or a plain click with `/mouse` off). - **Extended thinking** — the model's reasoning renders dim above its reply; a hook that blocks or fails surfaces too (routine ones stay quiet). -- **Session resume** — `ctrl+r` (or `/sessions`) fuzzy-filters `claude`'s own session history and re-execs into the one you pick. +- **Session resume** — `ctrl+r` (or `/sessions`) fuzzy-filters your session history for the running backend and re-execs into the one you pick. - **Command palette** — `ctrl+t` (or `/commands`) browses every command — our in-process ones plus claude's built-ins, **skills**, and **plugin** commands (from the init handshake) — and runs or forwards the one you pick; `/agents` lists subagents. Any `/command` we don't own is forwarded to claude, so custom & plugin commands work. - **11 themes + header animations** — `/theme` and `/settings`, with live preview, persisted across launches (see [Themes](#themes)). - **Live status bar** — permission mode, session id, git branch, a context-pressure gauge that auto-grows 200K → 2M, output tokens, and running cost. @@ -33,15 +39,16 @@ subscription** because we never set an API key. - **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). +- **Prompt history & steering** — `↑` / `↓` recalls past prompts (use `Ctrl+↑/↓` while composing a multi-line draft, where `↑/↓` move between lines); type while the agent 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) Those are native API-client agents: to use Max they route a subscription OAuth token through the API, the pattern Anthropic restricted in early 2026. Here the -engine *is* Claude Code, so subscription use stays inside its intended path. We -borrow their **TUI craft** (all MIT-licensed) — markdown rendering, message -cards, plan/build modes — not their engine. +engine *is* the vendor's own CLI — Claude Code, or codex — so subscription use +stays inside its intended path, and the same argument holds for both. We borrow +their **TUI craft** (all MIT-licensed) — markdown rendering, message cards, +plan/build modes — not their engine. ## Run it @@ -49,10 +56,13 @@ cards, plan/build modes — not their engine. claude login # one-time, with your Pro/Max credentials only go mod download # fetch deps (go.sum is checked in) go run . # AUTO (build) by default; -mode ask | plan | bypass to switch +go run . -backend codex # or drive codex instead (needs `codex login`) ``` Preflight: run `claude` once interactively and confirm `/status` shows the -subscription route (not API credits) before relying on this. +subscription route (not API credits) before relying on this. On codex, run +`codex exec` once — `codex login status` reports stored state and claims success +even when the token has expired, so it is not a check. ## Build & install @@ -150,7 +160,11 @@ Small files by responsibility (the project keeps each one scannable). | file | role | |------|------| | `main.go` | flags, mode→permission mapping, wires engine + Bubble Tea program + reader goroutine | +| `backend.go` | the `Engine` seam: the calls the UI makes, and nothing about the wire format | +| `backendpick.go` | picks the backend `-backend` asked for; the only file that knows both exist | | `engine.go` | the long-lived `claude` subprocess: spawn, env-scrub, bidirectional NDJSON stdin/stdout | +| `codex*.go` | the codex backend: JSON-RPC framing, thread and turn lifecycle, event adapter | +| `agentname.go` | every user-visible mention of the agent, so a label cannot name the wrong one | | `events.go` | `Envelope` structs + parser for the stream-json output | | `control.go` | control-request envelopes on stdin (set permission mode, interrupt) | | `stream.go` | routes one parsed envelope into the model (`handleEvent`) | @@ -195,7 +209,7 @@ Small files by responsibility (the project keeps each one scannable). | `commands.go` | the slash-command table + help modal | | `settings.go` | persisted settings (header / theme / fps / diff / sidebar) + their pickers | | `approvals.go` | the in-process MCP permission server (`--permission-prompt-tool`) | -| `question.go` | intercepts Claude's `AskUserQuestion` and answers it via a picker | +| `question.go` | intercepts claude's `AskUserQuestion` and answers it via a picker | **State & persistence** @@ -218,7 +232,7 @@ splash (`splash.go`) opens with the wordmark, a faux modem handshake, and a `press [ENTER] to logon` prompt (dismissed by the first keypress). Discipline: the leet/studly/ornament treatment runs on *chrome only* — banner, -dividers, status, labels, splash. Claude's replies and the diff code stay +dividers, status, labels, splash. The agent's replies and the diff code stay plain and readable. The `leet`, `studly`, `flavor`, and `sceneDivider` helpers live in `text.go`; reskin by adding or editing a palette row (ten colors) in `theme.go` — that's how all 12 built-in themes are defined (see @@ -234,7 +248,7 @@ The splash shows one of several wide block logos at random each launch variant by running `figlet -f -w 200 "cath0d3" | tr '\140' "'"` (any font — `colossal`, `epic`, `poison`, `cosmic`, or `toilet -f pagga` for shade-block CP437) and pasting the output as a new entry; narrow terminals fall -back to the compact `logoCompact`. While Claude works, an animated throbber runs in the +back to the compact `logoCompact`. While the agent works, an animated throbber runs in the status bar; choose its frames with `-spinner` (the `shade` pulse `░▒▓█` and the `scan` knight-rider are the most period-correct). @@ -244,7 +258,7 @@ Done: markdown rendering (Glamour), bordered message cards, plan/build/ask modes, MCP tool-wiring hook, visual diff cards for `Edit`/`Write`/`MultiEdit` (unified and side-by-side split), the inline permission/approval pane (in `ask` mode each gated tool routes through our in-process MCP server and raises an -`[ENTER] allow / [ESC] deny` bar, diffs shown first), Claude's questions +`[ENTER] allow / [ESC] deny` bar, diffs shown first), the agent's questions answered via a picker, multi-line input, session resume, 11 themes, extended thinking, clickable links, slash-command forwarding (skills & plugins), and the merged command palette. @@ -253,7 +267,7 @@ Next / deferred: (a) token-by-token streaming via `--include-partial-messages` ( off against markdown); (b) syntax-token highlighting inside the diff — chroma is already in the tree via glamour, so per-line token coloring on top of the red/ green background is a natural follow-on; (c) multi-select and free-text "Other" -answers for Claude's questions (single-select works today). +answers for the agent's questions (single-select works today). ## Known sharp edges @@ -261,6 +275,11 @@ answers for Claude's questions (single-select works today). the protocol; its shape matches the Agent SDK streaming-input format. - `ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN` are stripped from the subprocess env on purpose — either present would silently bill the API. +- On codex, `OPENAI_API_KEY` and `OPENAI_BASE_URL` are stripped for symmetry. + The key is belt-and-braces there: codex reads its credential from + `~/.codex/auth.json` and an environment key does not divert billing, verified + by probe. `OPENAI_BASE_URL` is the one that matters, because it decides which + host the conversation is sent to. - `CLAUDE_CODE_CHILD_SESSION` is stripped too. It is set inside a running Claude Code session and marks children as subsessions, which turns transcript saving off — so cathode started from inside one would leave `ctrl+r` empty and diff --git a/WRITEUP.md b/WRITEUP.md index a3e40a9..f87f6d7 100644 --- a/WRITEUP.md +++ b/WRITEUP.md @@ -1,6 +1,7 @@ # Cathode -*A personal, BBS-styled terminal harness for Claude — running on your Max plan.* +*A personal, BBS-styled terminal harness for a coding agent — running on your +subscription, not the API.* > The binary is `cathode`, the Go module is `ccharness`, and the wordmark renders > as `cath0d3` (`appName` in `theme.go`). The repo lives at @@ -8,22 +9,27 @@ ## What it is -Cathode is a single-binary terminal UI that drives Claude Code. You type, Claude -works, and the conversation — replies, tool calls, file edits — streams into a -custom TUI with a 90s bulletin-board aesthetic. It is built for one user (you), -on a Mac and on Ubuntu, and it bills against your Claude Max subscription rather -than the pay-per-token API. +Cathode is a single-binary terminal UI that drives a coding agent's own CLI. You +type, the agent works, and the conversation — replies, tool calls, file edits — +streams into a custom TUI with a 90s bulletin-board aesthetic. It is built for +one user (you), on a Mac and on Ubuntu, and it bills against your subscription +rather than the pay-per-token API. + +Two backends: Claude Code over its stream-json protocol, and codex over its +`app-server` JSON-RPC (`-backend codex`). The rest of this piece describes the +claude one, which came first and is the more complete of the two — the codex +backend is documented in the README. The design carries a deliberate BBS nod: on a board, a *door* was an external program the BBS shelled out to — door games and the like. This does exactly -that with the `claude` binary (the harness is the board; Claude is the door), +that with the agent's binary (the harness is the board; the agent is the door), which is why the project was first called *Doorway*. *Cathode* keeps the same era's glow — the CRT the whole aesthetic is drawn on. ## The constraint that shaped everything -The whole design follows from one requirement: **use the Max subscription, not -API billing.** That sounds like a small detail, but it dictates the +The whole design follows from one requirement: **use the subscription, not API +billing.** That sounds like a small detail, but it dictates the architecture, because there are only two ways software talks to Claude: The clean way is to drive the official `claude` binary as a subprocess. The diff --git a/asset_gen_test.go b/asset_gen_test.go index 96ad931..c4f3313 100644 --- a/asset_gen_test.go +++ b/asset_gen_test.go @@ -111,7 +111,7 @@ func previewModel(banner string) model { } m.entries = []entry{ {kind: entUser, text: "refactor add() to take a third arg and update the caller"}, - {kind: entClaude, text: "Here's the change to both the function and its caller:"}, + {kind: entAgent, text: "Here's the change to both the function and its caller:"}, {kind: entDiff, diffs: []fileDiff{{ file: "math.go", old: "func add(a, b int) int {\n\treturn a + b\n}", diff --git a/chrome_test.go b/chrome_test.go index d3562f0..4cec346 100644 --- a/chrome_test.go +++ b/chrome_test.go @@ -78,7 +78,7 @@ func TestFrameFitsHeight(t *testing.T) { m.resizeViewport() m.makeRenderer() for i := 0; i < 80; i++ { - m.add(entClaude, "streamed assistant output line") + m.add(entAgent, "streamed assistant output line") } m.busy = true m.resizeViewport() diff --git a/codexengine_test.go b/codexengine_test.go index a75ac03..01ba16e 100644 --- a/codexengine_test.go +++ b/codexengine_test.go @@ -201,8 +201,8 @@ func TestCodexAdapterMapsFramesToEntries(t *testing.T) { Params: json.RawMessage(`{"item":{"type":"agentMessage","id":"m1","text":"hello there"}}`), }) last := m.entries[len(m.entries)-1] - if last.kind != entClaude || last.text != "hello there" { - t.Errorf("agent message = %+v, want entClaude", last) + if last.kind != entAgent || last.text != "hello there" { + t.Errorf("agent message = %+v, want entAgent", last) } // The user's own turn is already in the transcript; echoing it would double it. diff --git a/codexitems.go b/codexitems.go index f814fa0..bc26ec5 100644 --- a/codexitems.go +++ b/codexitems.go @@ -37,7 +37,7 @@ func (m *model) codexItem(f codexFrame, started bool) { return // text arrives on completion } if t := strings.TrimSpace(head.Text); t != "" { - m.add(entClaude, t) + m.add(entAgent, t) } case "reasoning": if started { diff --git a/jump_test.go b/jump_test.go index 44e23da..8cf7f3f 100644 --- a/jump_test.go +++ b/jump_test.go @@ -19,7 +19,7 @@ func jumpModel() model { 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}) + entry{kind: entAgent, text: reply}) } m.rebuild() return m diff --git a/main.go b/main.go index 54cb6b0..1021b6c 100644 --- a/main.go +++ b/main.go @@ -72,7 +72,7 @@ func main() { modelID := flag.String("model", "", "pin a model (e.g. sonnet); empty uses account default") spin := flag.String("spinner", "bar", "working throbber: bar | shade | block | arrow | scan") dbg := flag.String("debug", "", "tee raw stream-json and MCP traffic to this logfile") - resume := flag.String("resume", "", "claude session id to resume (also set automatically when picking from Ctrl-R)") + resume := flag.String("resume", "", "session (claude) or thread (codex) id to resume; also set when picking from Ctrl-R") ctx := flag.String("ctx", "200k", "context window for the pressure gauge — \"200k\", \"500k\", \"1m\", or a raw token count. auto-grows if observed input exceeds it.") flag.Parse() diff --git a/model.go b/model.go index 1b9f896..3347596 100644 --- a/model.go +++ b/model.go @@ -24,8 +24,8 @@ import ( type entryKind int const ( - entUser entryKind = iota - entClaude + entUser entryKind = iota + entAgent // an assistant reply, from whichever backend is running entThinking entTool entToolResult diff --git a/perf_bench_test.go b/perf_bench_test.go index 495eb07..a8e0978 100644 --- a/perf_bench_test.go +++ b/perf_bench_test.go @@ -38,7 +38,7 @@ func benchModel(n int) model { case 0: m.entries = append(m.entries, entry{kind: entUser, text: fmt.Sprintf("question number %d about the code", i)}) case 1: - m.entries = append(m.entries, entry{kind: entClaude, text: fmt.Sprintf("Here is a **markdown** reply #%d with a list:\n\n- one\n- two\n- three\n\nand a `code` span.", i)}) + m.entries = append(m.entries, entry{kind: entAgent, text: fmt.Sprintf("Here is a **markdown** reply #%d with a list:\n\n- one\n- two\n- three\n\nand a `code` span.", i)}) case 2: m.entries = append(m.entries, entry{kind: entTool, toolName: "Bash", toolInput: json.RawMessage(`{"command":"go test ./..."}`)}) case 3: @@ -59,7 +59,7 @@ func BenchmarkAddEntry(b *testing.B) { base := benchModel(n) b.Run(fmt.Sprintf("entries=%d", n), func(b *testing.B) { for i := 0; i < b.N; i++ { - base.entries = append(base.entries, entry{kind: entClaude, text: "a **new** reply with `code`"}) + base.entries = append(base.entries, entry{kind: entAgent, text: "a **new** reply with `code`"}) base.rebuild() } }) diff --git a/render.go b/render.go index 28053c7..938b855 100644 --- a/render.go +++ b/render.go @@ -82,7 +82,7 @@ func (m *model) renderEntry(e entry) string { switch e.kind { case entUser: return userBox.Render(cYou.Render(ornBullet+" "+studly("you")) + "\n" + e.text) - case entClaude: + case entAgent: body := e.text if m.md != nil { if out, err := m.md.Render(e.text); err == nil { diff --git a/render_test.go b/render_test.go index e0af205..8857efa 100644 --- a/render_test.go +++ b/render_test.go @@ -19,7 +19,7 @@ func TestRebuildRendersMarkdown(t *testing.T) { t.Fatal("glamour renderer not constructed") } m.add(entUser, "fix the bug in **main.go**") - m.add(entClaude, "Here's a fix:\n\n```go\nfmt.Println(\"hi\")\n```\n\n- step one\n- step two") + m.add(entAgent, "Here's a fix:\n\n```go\nfmt.Println(\"hi\")\n```\n\n- step one\n- step two") m.add(entTool, "Edit\n{\"file\":\"main.go\",\"old\":\"x\",\"new\":\"y\"}") m.add(entInfo, "— done · 0.0012 USD —") out := strings.ToLower(m.vp.View()) diff --git a/replay_test.go b/replay_test.go index d0c1daa..0fef632 100644 --- a/replay_test.go +++ b/replay_test.go @@ -107,7 +107,7 @@ func TestLoadPriorTranscriptHidesMeta(t *testing.T) { {kind: entUser, text: "commit to 81"}, {kind: entUser, text: "/compact"}, {kind: entInfo, text: compactDoneText}, - {kind: entClaude, text: "picking up #42"}, + {kind: entAgent, text: "picking up #42"}, } if len(entries) != len(want) { t.Fatalf("entries = %d, want %d: %+v", len(entries), len(want), entries) @@ -148,7 +148,7 @@ func TestLoadPriorTranscriptAutoCompact(t *testing.T) { want := []entry{ {kind: entUser, text: "carry on"}, {kind: entInfo, text: compactDoneText}, - {kind: entClaude, text: "on it"}, + {kind: entAgent, text: "on it"}, } if len(entries) != len(want) { t.Fatalf("entries = %d, want %d: %+v", len(entries), len(want), entries) diff --git a/sidebar_test.go b/sidebar_test.go index 1ddcdc3..751ba2a 100644 --- a/sidebar_test.go +++ b/sidebar_test.go @@ -16,7 +16,7 @@ func TestSidebarPosition(t *testing.T) { m.vp = newTranscriptViewport(100-1-sidebarWidth, 4) m.ready = true m.makeRenderer() - m.entries = []entry{{kind: entClaude, text: "the reply"}} + m.entries = []entry{{kind: entAgent, text: "the reply"}} m.rebuild() return stripANSI(strings.SplitN(m.renderBody(), "\n", 2)[0]) // first row } diff --git a/stream.go b/stream.go index d6dac69..4743e3b 100644 --- a/stream.go +++ b/stream.go @@ -106,7 +106,7 @@ func (m *model) handleEvent(e Envelope) { switch b.Type { case "text": if t := strings.TrimSpace(b.Text); t != "" { - m.add(entClaude, t) + m.add(entAgent, t) } case "thinking": // Extended thinking — show it (dim) when present; many turns carry diff --git a/stream_test.go b/stream_test.go index 07fcadd..5640770 100644 --- a/stream_test.go +++ b/stream_test.go @@ -15,7 +15,7 @@ func TestThinkingBlocks(t *testing.T) { if len(m.entries) != 2 { t.Fatalf("want a thinking + a claude entry, got %d", len(m.entries)) } - if m.entries[0].kind != entThinking || m.entries[1].kind != entClaude { + if m.entries[0].kind != entThinking || m.entries[1].kind != entAgent { t.Fatalf("kinds: got %v,%v want thinking,claude", m.entries[0].kind, m.entries[1].kind) } @@ -24,7 +24,7 @@ func TestThinkingBlocks(t *testing.T) { {Type: "thinking", Thinking: " "}, // empty after trim → skipped {Type: "text", Text: "hi"}, }}}) - if len(m2.entries) != 1 || m2.entries[0].kind != entClaude { + if len(m2.entries) != 1 || m2.entries[0].kind != entAgent { t.Fatalf("empty thinking should be skipped, got %d entries", len(m2.entries)) } } diff --git a/transcript.go b/transcript.go index 7359743..7b8787c 100644 --- a/transcript.go +++ b/transcript.go @@ -148,7 +148,7 @@ func loadPriorTranscript(sessionID string, maxEntries int) (entries []entry, ctx } flushCompact() if t := strings.TrimSpace(c.Text); t != "" { - entries = append(entries, entry{kind: entClaude, text: t}) + entries = append(entries, entry{kind: entAgent, text: t}) } case "tool_use": flushCompact()