Vectrum
Sign In
402 lines · 22.2 KB
# The TUI

On a terminal, the interactive mode is a full-screen [Bubble Tea](https://github.com/charmbracelet/bubbletea)
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](https://github.com/alecthomas/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](../../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](flags.md#flags-and-thresholds).

| 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](#images) below |
| `shift+tab` | cycle the approval mode |
| `ctrl+b` | open the agents sidebar — see [subagents](subagents.md#the-agents-sidebar) |
| `ctrl+t` | open a second session in a tab — see [tabs](tabs.md) |
| `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.

## 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                               │
╰──────────────────────────────────────────────────────────────────────────────╯
```

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

## The mouse

| 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".

## 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 `~/.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.

## 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](setup.md).

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