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).
@@ -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()