Vectrum
Sign In
403 lines · 22.2 KB

The TUI

On a terminal, the interactive mode is a full-screen Bubble Tea app. It opens on a welcome block — Codey, then the model, the endpoint and the root:

▄██████▄   codey v0.6.1
█  ██  █   Opus 5 · anthropic
███▀▀███   ~/Work/GO/coding-harness
 ▀█▀▀█▀    /help · ↑ earlier prompts · ! shell · shift+tab approvals

The gopher is drawn in half-blocks, so it needs no patched font, and it is dropped rather than sliced on a terminal too narrow to hold it beside a readable amount of text: half a mascot reads as a rendering bug, where the three lines alone read as a choice. The fourth line is the handful of things there is no other way to find out, and it costs no height at all: the gopher is four rows tall and the text was three. It is dropped whole rather than truncated when the terminal is too narrow for it, because half a sentence of hints is worse than none. From there it settles into a transcript above a prompt:

⏺ Let me see what the test expects before changing anything.

⏺ ReadFile(path=internal/agent/loop_test.go)
  ⎿ 1: package agent
    …

⏺ EditFile(path=internal/agent/compact.go)
  4 additions, 1 removal
  @@ -50,7 +50,9 @@
  +    if window == 0 {

 ✢ Noodling… (14s · turn 3 · ↓ 1.3k tokens · esc to interrupt)   ● high · /effort
╭──────────────────────────────────────────────────────────────────────────────╮
│ › _                                                                          │
╰──────────────────────────────────────────────────────────────────────────────╯
 joni   ✳ Opus 5   ◐ 62.2k (6%)   ↑ 4.2k ⟳ 58.0k ↓ 1.3k  $0.0835
 ⏵ approvals on (shift+tab to cycle) · /help for commands

Every event hangs off a dot in the left gutter, and everything belonging to it is indented under that dot, so a screen of tool output still reads as a sequence of things that happened. Diffs are shown in full and in colour with a count of what changed; other output is elided after six lines. A finished answer closes with how long it took — took 1m 30s · 3 turns — because the activity line counts those seconds only while the turn runs, and that number is worth keeping in the scrollback beside the answer it paid for.

What the model says is Markdown, and it is rendered as Markdown — headings, weight, lists, block quotes, rules, and tables laid out in columns:

⏺ Findings

  The refresh path drops the rotated token. Three things are going on:

  1. refresh() returns the new pair but never writes it back
  2. the old refresh token is already dead by then

   File                   │ Line                  │ Change
  ────────────────────────┼───────────────────────┼───────────────────────
   refresh.go             │ 84                    │ write back

    func refresh(tok string) (Creds, error) {
    	// TODO: persist the rotation
    }

Fenced code is syntax-highlighted by chroma, and every colour — prose, headings, keywords, strings — comes from the theme in force, so /setup repaints Markdown along with everything else. Headings drop their hashes: the levels are told apart by colour and weight instead. It renders as it streams, so a table fills in row by row.

Only what the model says goes through this. Tool results and the output of your own ! and $ commands are program output and are shown as they came, and -no-tui prints raw text so -p stays pipeable.

The status bar reports the context gauge — the last turn's input size against the window, which drops when the conversation is compacted — and separates fresh input tokens from cache reads, because the gap between the two is what METRICS.md is about. The prompt box grows with what you type, up to six lines.

The conversation folds itself into a summary at 70% of the window, and /compact does it on demand — before a long task, say, or when the gauge is climbing faster than the work is. /compact waits for the current turn to finish, because it rewrites the history that turn is building on. Afterwards the gauge goes blank rather than keep reporting a number the compaction just made untrue; the next turn measures the window again and it comes back. At 97% compaction stops being optional, and it can be turned off altogether: see the flags.

KeyDoes
entersend
/open the command menu — see below
!run a shell command yourself — see below
$the same, through python3
ctrl+jnewline without sending
ctrl+vpaste a picture in — see images below
shift+tabcycle the approval mode
ctrl+bopen the agents sidebar — see subagents
ctrl+topen a second session in a tab — see tabs
alt+1…alt+9go to a tab
ctrl+pgup/ctrl+pgdnwalk the tabs
alt+wclose the tab on screen
ctrl+rexpand elided tool output
pgup/pgdnscroll the transcript
escinterrupt the turn
ctrl+cinterrupt the turn; again when idle to exit

Interrupting ends the turn, not the session. A tool still running when you press esc is answered with an error saying it was interrupted, and one that had already finished keeps its result, so the conversation is never left with a tool_use nothing answered — which the API rejects on every request after it, the next thing you type or a -resume weeks later. A session saved in that state by an older build is repaired when it is reopened.

Commands: /allowed, /clear, /close, /compact, /effort, /exit, /help, /image, /model, /mouse, /new, /resume, /settings, /setup, /skills, /worktree, and one per discovered skill.

Images

ctrl+v pastes a screenshot into the prompt. What appears in the box is a marker:

╭──────────────────────────────────────────────────────────────────────────────╮
│ › the button is in the wrong place [image 1] can you find the style for it    │
╰──────────────────────────────────────────────────────────────────────────────╯

The marker is ordinary text. It can be moved, and deleting it takes the paste back — only the markers still standing when you press enter become pictures, each one in the message exactly where it stood in the sentence. So a prompt can point at one of two screenshots, or put a picture between two paragraphs, without any of that being a separate mode.

No terminal sends image bytes to the program running inside it, so the keystroke is a signal rather than a delivery: Codey reads the system clipboard itself, with the same helper you would run by hand — wl-paste on Wayland, xclip on X11, pngpaste or AppleScript on macOS, PowerShell on Windows. On Linux that means one of wl-clipboard or xclip has to be installed; when neither is, the paste says so rather than failing quietly.

Some terminals keep ctrl+v for themselves and paste the clipboard's text form, which for a screenshot is nothing at all. An empty paste is therefore taken as an attempt too, and answered by reading the clipboard directly — so the key works on both kinds of terminal. Dropping an image file on the window works as well, because that arrives as its path. With no picture on the clipboard ctrl+v pastes the text on it instead, which is what the key always meant — the textarea's own binding for it never reached the screen.

A path to an image that is still in the prompt when you press enter becomes that image too, wherever it came from. That is the one that always fires: catching a paste as it happens needs the terminal to bracket it, and not all of them do — a terminal that writes the picture to a file and pastes the path as plain keystrokes is caught here instead. A path to something that is not a readable image is left alone, so a prompt about a file still reads as one.

/image <path> attaches a file by name and needs no clipboard helper at all, which makes it the one that always works — over ssh, in a container, or on a terminal that swallows the key. Bare, /image takes whatever the clipboard has.

PNG, JPEG, GIF and WebP go up; the format is decided by the bytes rather than by the extension, and anything over about 3.7MB is refused with its size, because the API will not take it.

Questions

The agent can stop in the middle of a task and ask you something. It calls ask_user, the turn parks, and a box comes up with the options it thought of:

 ● the agent has a question
╭──────────────────────────────────────────────────────────────────────────────╮
│ Storage · question 1 of 2                                                    │
│ Sessions have to live somewhere once the process exits. Where should they go?│
│                                                                              │
│ ❯ 1. Postgres (recommended)                                                  │
│       what the rest of the stack already runs; no new service to operate     │
│   2. SQLite                                                                  │
│       a file on disk, and one fewer thing to have running                    │
│   3. Keep them in memory                                                     │
│       simplest, and they are gone when you quit                              │
│   4. something else — type it yourself                                       │
│                                                                              │
│ ↑↓ move · enter choose · esc skip the question                               │
╰──────────────────────────────────────────────────────────────────────────────╯
KeyDoes
↑/↓move the cursor — the wheel does the same
1–9answer with that option outright
spacetick a box, where the question allows several answers
entertake the row under the cursor, or confirm what is ticked
←back to the previous question, to change an answer
escskip: the agent is told to decide for itself and say what it chose

The last row is always there and always free text, so you are never cornered by three options that are all wrong; typing an answer is the same key as picking one. A click does what enter would — on the description under a label as well as on the label, because that is what it looks like you are clicking.

This is the approval box's twin, and it works the same way underneath: the agent blocks on a channel, the UI keeps running, and an interrupt releases it. Where the two collide the approval goes first — a tool call cannot start until someone says so, and the question is still there behind it. A question whose turn is interrupted goes with the turn, since the tool call waiting for the answer no longer exists.

Only the session's own agent gets the tool. A subagent's report goes to its parent, and the parent is not a person to ask.

Outside the TUI the same questions are asked a line at a time, and a run with nothing on stdin — a -p one-shot in a script — gets no answer, which the tool reports as a dismissal so the model decides for itself and says so.

The mouse

DoesHow
scroll the transcriptthe wheel, anywhere
selectdrag; double-click a word, triple-click a line, shift+click to extend
copylet go — the selection lands on the clipboard on release
chooseclick a menu row, a model, a session, a worktree, the effort slider, an approval, the mode line
work with agentsclick the agents pill or a row; click the prompt to type again

The prompt box is keyboard-driven: a click does not move the caret in it, and what you have typed is the one thing the mouse cannot select. Dragging against the top or bottom edge of the transcript scrolls, so a selection can be longer than the screen; it keeps going while the pointer moves, because a terminal reporting button events says nothing about a hand held still.

Codey runs in the alternate screen, where the terminal has no scrollback of its own to roll — so asking for mouse reporting is what makes the wheel work at all. The cost is the terminal's own text selection, which stops being the terminal's the moment drags are reported to the program. That is why the selection above exists: it is the replacement, and copying goes out over both OSC 52 (which works over ssh) and the desktop clipboard, because terminals disagree about which of the two they allow.

/mouse hands the mouse back. Selection and copy become the terminal's again, exactly as before this existed, and the wheel goes quiet. shift+drag usually reaches the terminal's own selection without turning reporting off at all.

A slash at the start of an empty prompt opens the command menu, which filters as you type — a name that starts with what you typed before one that merely contains it:

╭──────────────────────────────────────────────────────────────────────────────╮
│ › /s                                                                         │
╰──────────────────────────────────────────────────────────────────────────────╯
 ❯ /skills                the instruction sets found at startup
   /systematic-debugging  find the cause before changing code (project)
   /brainstorming         explore an idea before building it (personal)

↑/↓ move, tab or enter completes the highlighted name into the prompt — completing is not sending, so there is room to type arguments for a skill or a level for /effort — and esc dismisses the menu without touching what you typed. The menu offers exactly the commands the TUI answers, so nothing it suggests can come back as "unknown command".

Earlier prompts

↑ from the first line of the prompt box walks back through what has been typed into this checkout before, ↓ walks forward again, and esc abandons the walk. The empty box says so — its placeholder names the key, and sheds clauses rather than characters as the terminal narrows, since a hint truncated to ! shell · ↑ earli advertises nothing while looking like a bug. ctrl+p and ctrl+n do the same, because they already do in every other list Codey draws.

The rule about the first line is what lets one key do two jobs without a mode: anywhere below it, ↑ is moving the cursor through a paragraph you are still writing, which is what it looks like it is doing. Walking all the way forward again gives back the half-written prompt the walk started from — losing that is the one thing a history feature can do that is worse than not existing — and editing a recalled line ends the walk, because the line is yours now.

A ! or $ line and a prompt are different kinds of thing, so they are offered back separately: ↑ at the shell prompt walks shell commands, and the prompt aimed at the model never hands you a go test to send as prose.

The last few hundred entries are kept in .codey/history inside the checkout Codey was started in — not per session, which would forget the thing you are most likely to want back, and not one file per machine, which would offer you the test command of a project you are not in. Two tabs append to it rather than rewriting it, so neither loses the other's lines. It is inside the state directory, which .gitignore already covers; delete the file and the prompt box simply stops remembering.

Running something yourself

! on an empty prompt points the box at a shell instead of at the model; $ points it at python3. The marker replaces the ›, the frame changes colour, and enter runs the line where the agent's own bash tool would have run it — from the working root, with the same timeout and the same output cap.

╭──────────────────────────────────────────────────────────────────────────────╮
│ ! go test ./internal/agent/                                                  │
╰──────────────────────────────────────────────────────────────────────────────╯
⏺ ! go test ./internal/agent/
  ⎿ exit code: 1 (2.3s)
    --- stdout ---
    --- FAIL: TestCompactKeepsTheLastExchange (0.00s)
        compact_test.go:88: history has 2 messages, want 4
    … +9 more lines (ctrl+r)

The agent is shown what you saw. The command and its output are appended to the conversation as a user message, so the next thing you type can be "fix that" and the model already has the failure. Nothing is sent at that moment — it waits in the history and goes up with your next real turn, so several commands can be run before saying anything about them.

What reaches the model is the capped report, not the raw firehose: a noisy command cannot cost more context from the prompt box than it would through the tool. The transcript collapses it further to six lines, and ctrl+r expands to exactly what the model was given, so the two never drift.

esc leaves the mode, as does backspace over the marker. The mode is per-command — after a run the prompt returns to normal, the way a shell prompt comes back. One command runs at a time; while it does, esc interrupts it and a message you type waits rather than jumping ahead of the output it is about. A ! typed mid-sentence is an exclamation mark.

These bypass the approval policy by design: you typed the command, so there is nobody left to ask. They are also the one part of the TUI that runs something without the model's involvement — worth remembering when the root is not a throwaway clone.

/model asks the endpoint what it serves — GET /v1/models, paginated — and opens a picker on the answer:

 Select model
 from anthropic · ↑↓ move · enter apply · esc cancel
   1. Claude Fable 5.1      claude-fable-5-1 · 1.0M in · $10 / $50 per Mtok
   2. Claude Opus 5         claude-opus-5 · 1.0M in · $5 / $25 per Mtok ✓
 ❯ 3. Claude Sonnet 5       claude-sonnet-5 · 1.0M in · $2 / $10 per Mtok
   4. Claude Haiku 4.5      claude-haiku-4-5-20251001 · 200.0k in · $1 / $5 per Mtok

The cursor starts on the model in force, which is also the one marked. The id, the context window and the list price come from the endpoint and from the pricing table; a model neither knows about simply says less. The switch applies from the next turn — the conversation so far is replayed to the new model — and costs already incurred stay priced at the model that incurred them, because a session that switched models was not all billed at the model it ended on.

Not every endpoint serves that route. When the fetch fails the picker says so, names the host and the reason, and closes; /model <id> and the -model flag still switch without asking anyone's permission.

/effort opens a slider in place of the prompt, on the level in force — an unset level is the API's own default, high, so the marker starts there rather than at an end:

 Faster                                                            Smarter
 ──────────────────────────────────────▲───────────────────────────────────
       low            medium            high             xhigh         max
 ←→ move · 1-5 jump · enter apply · esc cancel

Moving the marker changes nothing until enter; esc leaves the level alone. /effort xhigh still applies a level directly for anyone who can type it, and either way it is the -effort flag reached from the keyboard. The level in force shows above the prompt on the right. A terminal too narrow for five labels gets ◀ high ▶ and the same keys rather than a track with the words sliced in half.

/model and /effort change the session. /settings and /setup change what the next session starts with — see Setup.

shift+tab cycles between asking before every mutating call and accepting them all, which is -yolo reached from the keyboard. The mode line says which is in force at all times, and the switch is recorded in the transcript, because a mode that silently lets the agent write to disk is not one to keep in a corner.

Approvals appear inline as a bordered prompt, answered by number or by the key in brackets — y, n, a for this exact command, t for the whole tool. The agent goroutine blocks until you answer, which is the point: the tool has not run yet. Interrupting while that prompt is open denies the call and cancels the turn rather than leaving the agent waiting on a channel nobody will read.

The TUI is skipped automatically when stdin or stdout is not a terminal, and by -p and -no-tui, which use the line-based renderer instead. Both front ends consume the same agent.Event stream, so neither can show something the other cannot.