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
5 changes: 3 additions & 2 deletions BUILD.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
57 changes: 38 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
@@ -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).

<p align="center">
<img src="assets/cathode-splash.svg" alt="cath0d3 boot splash" width="640"><br><br>
Expand All @@ -10,49 +12,57 @@ A **Bubble Tea TUI over the Claude Code stream-json protocol** (wordmark: `cath0
<sub>Rendered in the built-in **Catppuccin Mocha** theme — the look is switchable, see <a href="#themes">Themes</a>.</sub>

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.
- **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).
- **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

```bash
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

Expand Down Expand Up @@ -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`) |
Expand Down Expand Up @@ -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**

Expand All @@ -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
Expand All @@ -234,7 +248,7 @@ The splash shows one of several wide block logos at random each launch
variant by running `figlet -f <font> -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).

Expand All @@ -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.
Expand All @@ -253,14 +267,19 @@ 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

- The stdin envelope (`outUser` in engine.go) is the under-documented half of
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
Expand Down
24 changes: 15 additions & 9 deletions WRITEUP.md
Original file line number Diff line number Diff line change
@@ -1,29 +1,35 @@
# 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
> `github.com/tripledownab/cathode` (it began life as `doorway`).

## 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
Expand Down
2 changes: 1 addition & 1 deletion asset_gen_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
Expand Down
2 changes: 1 addition & 1 deletion chrome_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
4 changes: 2 additions & 2 deletions codexengine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion codexitems.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion jump_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
4 changes: 2 additions & 2 deletions model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions perf_bench_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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()
}
})
Expand Down
2 changes: 1 addition & 1 deletion render.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
4 changes: 2 additions & 2 deletions replay_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading