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.2
█ ██ █ 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.
| Key | Does |
|---|---|
enter | send |
/ | open the command menu — see below |
! | run a shell command yourself — see below |
$ | the same, through python3 |
ctrl+j | newline without sending |
ctrl+v | paste a picture in — see images below |
shift+tab | cycle the approval mode |
ctrl+b | open the agents sidebar — see subagents |
ctrl+t | open a second session in a tab — see tabs |
alt+1…alt+9 | go to a tab |
ctrl+pgup/ctrl+pgdn | walk the tabs |
alt+w | close the tab on screen |
ctrl+r | expand elided tool output |
pgup/pgdn | scroll the transcript |
esc | interrupt the turn |
ctrl+c | interrupt 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.
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.
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 │
╰──────────────────────────────────────────────────────────────────────────────╯
| Key | Does |
|---|---|
↑/↓ | move the cursor — the wheel does the same |
1–9 | answer with that option outright |
space | tick a box, where the question allows several answers |
enter | take the row under the cursor, or confirm what is ticked |
← | back to the previous question, to change an answer |
esc | skip: 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.
| Does | How |
|---|---|
| scroll the transcript | the wheel, anywhere |
| select | drag; double-click a word, triple-click a line, shift+click to extend |
| copy | let go — the selection lands on the clipboard on release |
| choose | click a menu row, a model, a session, a worktree, the effort slider, an approval, the mode line |
| work with agents | click 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".
↑ 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 ~/.config/chats/<repo>/history,
beside the sessions of 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. Delete the file and the
prompt box simply stops remembering.
! 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.