TUI conventions
What a key does in the VibeSys TUI should be a citation, not a preference. This page records the sources so a binding argument resolves against prior art rather than taste, and so a reviewer can check a change against something.
Read it in two parts. Rules in force describe what the code does today, so a change that breaks one is a regression. Proposed: movement and naming is design that is not implemented, kept here so the argument is settled once rather than reopened per PR.
There is no single TUI standard. There are four bodies of prior art that between them settle most questions.
Sources
IBM Common User Access. Introduced in 1987 under Systems Application Architecture and documented in Panel Design and User Interaction (1987) and the Advanced Interface Design Guide (1990). CUA 91 was adopted by Windows 95, and GNOME and KDE still track it. It is the closest thing to a formal standard for keyboard-driven interfaces, and it is where Tab-between-fields, F1-for-help, and Esc-cancels come from.
WCAG 2.2. Written for the web, but four criteria describe properties a terminal can hold and fail:
| Criterion | Requirement | Why it applies here |
|---|---|---|
| 1.4.1 Use of Color | Colour is never the only carrier of information | Two of the eight themes are high-contrast and have almost no palette, so a colour-only signal says nothing in them |
| 2.4.7 Focus Visible | Keyboard focus has a visible indicator | The operator has to know where a keystroke lands before pressing it |
| 2.4.3 Focus Order | Focus order is meaningful and predictable | Panes are a hierarchy, and movement has to match it |
| 2.1.1 Keyboard | Everything is reachable by keyboard | A TUI has no other input |
De facto terminal conventions, unwritten but near-universal across less,
vi, man, htop, tig, lazygit, and k9s.
readline line editing. Ctrl+A, Ctrl+E, Ctrl+K, Ctrl+W, Ctrl+U in
any text input. This is what every shell does, so it is what fingers expect.
Rules in force
Focus is never carried by colour alone
WCAG 1.4.1. A focused pane differs from a resting one by at least one channel
that is not colour. That channel is the marker: paneTitle reserves a gutter
cell in every pane title and swaps ▸ into it on focus. Colour reinforces it,
borderFocus against border, and is never the only signal.
Border weight is not one of the channels. paneBorderStyle returns rounded
whether or not a pane holds focus. A heavier frame was tried and dropped: the
only heavier box-drawing weights are square (┏┓┗┛), because Unicode has no
heavy rounded corner, so the swap trades rounded corners for square ones. That
is a change of shape rather than of weight, and reads as the pane becoming a
different kind of object rather than a louder one.
The gutter is reserved rather than inserted, so the marker swaps a glyph instead of pushing the title sideways. A title that moves on focus reads as the layout shifting rather than the pane lighting up.
focus.test.ts holds this for every built-in theme, so it is a property rather
than an intention. It also pins the direction of the colour channel: a focused
border is never lower-contrast against the canvas than a resting one.
One surface wears the focus treatment, and it is a pane
focusedPane is the single authority for which one. Every pane compares its own
PaneId against it, so at most one can answer yes, and a surface that is not a
pane never asks. Two cases follow from that and are worth naming, because both
were shipped wrong once:
- A box nested inside a pane does not repeat the treatment. The chat's
Messagecomposer sits inside the chat pane, so the pane frame carries the marker and the composer keeps the resting frame. Which box holds the cursor is said by the cursor and by the hint line under it. TheCommandbox is the same case: it is drawn inside the pane whose keys it takes, which is the log on the landing view, the transcript inside a round, and whichever single pane a zoom has left on screen. - A surface that takes the keys is a pane, whatever its shape. The expanded todo
list is a strip rather than a column, but
keybindings.tsroutes the arrow keys to it, so it is aPaneIdand the pane it opened over goes back to rest. The same holds for a presentation swap: belowMIN_SPLIT_WIDTHa visualization is drawn through the overlay, and that overlay is then the performance pane, with its title and its marker.
The rounds rail is the one deliberate exception to the second case. It is a
selector column at the left of the round view, not a content pane: focusedPane
reports the agents side while the rail holds roundFocus, so F4 zooms the
agents pane rather than a column with nothing to enlarge. The rail draws its own
focus border from roundFocus, and the agents pane suppresses its own while the
rail is on screen and holds the keys, so exactly one border is lit. ← and →
step rounds, agents, transcript and clamp at the ends, and ↑ and ↓ step
rounds while the rail holds focus. When a resize hides the rail (roundRailColumns
is 0) a stale rounds focus falls through to the agents pane, so the keys never
drive an invisible selection.
Zoom is a separate question from focus. visiblePaneIds is the set the content
row can be given to, and the todo list is deliberately not in it: it is as tall
as its own contents, so F4 over it leaves the layout alone.
Esc goes back one level
Esc cancels a modal, closes an overlay, leaves a drill-down, or returns pane
focus to the left column. It never quits the application. This is CUA, and it is
the one movement rule the current bindings already keep.
d opens the diff of the selected round
tig is the source: its default keymap opens the diff view with d. Here d
opens the round diff viewer from the design pane and from a round drill-down,
and only while the command input is empty, so typing keeps priority and a
command containing a d is never hijacked. The viewer is a modal: it swallows
every key it does not handle, ← and → move by file, ↑ and ↓ by hunk,
Page Up and Page Down scroll, and its Escape closes the viewer alone, per
Esc goes back one level.
A resize moves columns, and only columns
< and > change a pane's width by whole columns (PANE_WIDTH_STEP), the way
a window resize does in vim and LazyVim. Two panes share the mechanism: the
round view's Agents pane, and the home page's docked chat pane, which the
experiment log absorbs the remainder from rather than holding a width of its
own. Not a ratio: a share of the terminal that a floor then rounds away is a
key press that did nothing, and the width the operator sees is what they are
aiming at.
The rounds rail is not one of those panes: it keeps its fixed 28 columns (13 in
the compact band, none below 85 terminal columns) and no resize key moves it.
The Agents pane's width, its automatic share, its floor and ceiling, and the
clamp the keys apply are all measured against the room the rail leaves
(terminalWidth - roundRailColumns), so the transcript keeps its floor beside
the rail. Whether the rail is on screen changes the numbers the keys clamp to,
never whether they work. A zoom, the log, and an open split hide the rail, and
the pane then measures against the whole terminal again.
An explicit width is sticky, so = gives it back, the way <C-w>= does in vim.
Without it one press would cost the pane its automatic sizing for the life of
the process: the Agents pane would stop following the terminal, stop widening
as agent names get longer, and keep cutting names automatic sizing promises
never to cut; the chat pane would stop following the terminal and the log
beside it would stop reclaiming the columns automatic sizing would have given
it back. A resize pair with no way home is a trap, not a feature. = shares
the guards that decide whether the keys belong to a pane at all (an empty
command input, no zoom, the pane on screen), but alone among the three it is
not also gated on the pane being resizable: it is the way out, and a terminal
or a round where < and > do nothing is exactly where an override left over
from a wider terminal has to be clearable.
A resize key also never swaps a pane's contents for a different presentation.
The Agents pane's stacked list is an automatic fallback for a terminal too
narrow to draw a graph, not the next step down from the narrowest graph: it
cannot draw a node with in-degree above one, so reaching it by pressing <
would lose edges the operator was resizing in order to see. < stops at
agentGraphMinWidth, the narrowest pane an override may ask for, and > stops
at agentPaneCeiling, past which the extra columns are padding rather than
name. The chat pane has no fallback presentation to protect: chatPaneVisible
already decides whether it is docked at all, so wherever </>/= apply to
it, it is already the chat pane and never becomes something else.
The stacked list is therefore a floor < cannot cross in either direction. From
it > opens the narrowest graph, because asking for the graph is the only thing
> can mean there, while < stays put: it is already at the narrowest the pane
goes, and the gap up to agentGraphMinWidth is not a step a shrink key may take
(26 columns at three stages, 7 at two, none at all at one).
That last case generalizes, and the general rule is the one the code keeps for
every resizable pane: a press that would not change the rendered width stores
nothing. A round with no phases yet, a round of one short-named stage, and a
terminal wide enough that automatic sizing already sits at the ceiling each
leave the Agents pane's < or > with nowhere to go, and a press that stored
an override anyway would arm one the operator cannot see and pin every later
round in the session to it. A terminal too narrow for any graph is the same
rule reached through the terminal instead of the round, and a terminal too
narrow for the chat to dock at all (chatDockFits false) is the same rule
again: chatPaneVisible is already false there, so the keys are not claimed in
the first place, and nothing is stored either way.
Storing nothing means storing nothing, not storing the bound the press ran into, so an explicit width outlives a narrowing. A pane set to 64 columns on a wide terminal renders at whatever a narrower one allows, and a dead press there leaves the stored 64 alone, so widening back restores 64 rather than the narrowed width. That also keeps a dead press on one round, or on one terminal size, from overwriting a width chosen under a different shape.
Automatic sizing keeps its own rule for the Agents pane, that no agent name is
ever cut. Truncation is what an explicit override buys, and it buys only that:
agentGraphMinWidth keeps the same STACKED_WIDTH floor agentPaneFloor
does, so an override narrows the agent names and never the round heading or the
empty-round placeholder. An operator who asks for a narrower pane than the
names need has said which of the two they want.
The chat pane's override keeps the mirror image of that promise instead.
Automatic sizing (chatPaneWidth) never grows past CHAT_PANE_MAX, wide enough
for a question and its answer to read as prose; an explicit override
(clampChatWidthOverride) is allowed past it, the same way an Agents override
is allowed under agentPaneFloor into truncation automatic sizing would never
choose on its own. Asking for a width automatic sizing would never pick is the
point of overriding it. The floor either way is CHAT_PANE_MIN, and the
override still keeps the experiment log at LOG_COMPACT_PANEL_WIDTH: the extra
columns a wide override asks for come out of the chat's own ceiling, never out
of the log's floor.
Nothing moves that does not have to
Reserve space for anything that appears conditionally: focus markers, status lines, counts, and variable-width numbers. A row that appears and disappears resizes everything under it, and a list that jumps under the cursor costs more than the row saved.
Bindings are visible
The active bindings are shown on the key-help line at the bottom of the screen, under every pane, and that line changes with whichever surface is in front. A binding a person has to already know is a binding they do not have.
The line keeps the full width of the screen rather than moving inside a pane with the command input. A pane is narrower than the terminal, and a row of bindings truncated to fit one is a row whose last bindings nobody has.
A fill lives on an inner box
OptimizedBuffer.drawBox takes one background for the whole rectangle and the
buffer is write-only, so a backgroundColor on a bordered box fills the border
ring as well. The painted rectangle is then a cell larger than the drawn line on
all four sides: the line sits in a solid block with fill on both sides of it,
and under a rounded arc the fill paints the outside of the curve and squares the
corner back off. That is #642.
So the fill goes on an inner box that occupies the interior, and the outer box
draws only the border. A cell then reads canvas, then line, then fill, and a
corner cell holds no fill to bleed past the arc. box-fill.ts is the one way to
do it: a layer inset to zero on all four sides, absolutely positioned so it adds
no row, no padding and no containing block and the box's children stay where
they were.
Exception: an overlay keeps an outer fill. A box that floats over other
content needs an opaque edge, or what is behind shows through its border ring.
That fill reaches the ring by design, so an overlay draws a square border
(borderStyle: 'single'): a ring of fill under a rounded arc is #642 again. The
three modals and the two suggestion popups are the whole list.
Two other answers were tried and rejected, so they are settled rather than open. Blending the corner cell between the fill and what was behind it is still one flat colour standing in for two regions, and reads as a smudge rather than a curve. Forcing every filled box square fixes the corner and leaves the fill overhanging the border on all four sides, which is the same disagreement between the drawn shape and the painted one, one cell further out.
app.test.ts holds both halves over the whole constructed tree rather than over
a list of call sites, so a box added later fails it without anyone remembering
to extend a list: no non-overlay box fills its own rectangle, and a site that
used to fill one still paints a layer inside its border. The exception is an
explicit list there, because floating over something is not a property a box
carries: the agent map's cards are absolutely positioned too. One trap the walk
exists to catch: OpenTUI turns a border back on if borderStyle or
borderColor is passed beside border: false, so a box that means to draw no
border has to omit both.
Proposed: movement and naming
Nothing in this section is implemented. #711's keybinding matrix supersedes the naming table this section used to carry: see that document for the full six-source comparison (Claude Code, Codex, Vim, LazyVim, GNU Readline, WCAG 2.2), its explicitly recorded conflicts, and, per binding, whether it can hold while the composer is focused and non-empty. A PR that adopts one of these lines moves it up into Rules in force in the same change, citing the matrix entry it follows.
Movement
Today pane focus is Ctrl+W, which cycles the chat, the left column, and the
visualization pane. Inside the left column, ← and → move between the agent
strip and the transcript, ↑ and ↓ move within whichever of the two holds
focus, and Tab / Shift+Tab step through agents once the command input's
completion has declined the key.
CUA says arrows move within the focused pane and Tab moves between panes,
which is one fix for the asymmetry above, but not the only precedent: the
matrix's Conflicts section records at
least three different rules, across the sources it compares, for splitting
within-pane movement from between-pane movement. CUA is a candidate, not a
settled answer. Until a PR names which rule it follows, Ctrl+W is the
binding and the key-help line says so.
The replacement keymap on the unmerged adi/tui-keymap-defaults branch (no
PR, and premature by #711's own account) binds paneNext to Tab and
Ctrl+Right, and panePrevious to Shift+Tab and Ctrl+Left. It is
evidence of the failure mode #711 exists to avoid, implementing a keymap
before this comparison existed, not a shortcut past it.
Naming
Candidate prefixes and single keys are compared, per source and context, in
the matrix's command entry,
help, and search
sections. Of the keys those sections discuss, only F1 is bound to its
candidate meaning today, opening the command palette. The TUI's only prefix
is /, and it means command (/help, /theme, /open-round), not search.
There is no search and no : prefix; F2, F3, and F4 toggle todos, the
latest prompt, and pane zoom, and F5 opens the private notepad (/note,
#805): the same sequential-function-key precedent as F2-F4, the next free
slot after them. Inside the notepad, F6 and F7 promote its text to an
unsent steer or chat draft; those two are scoped to the notepad's own modal
rather than global, the same tier as a key whose meaning already changes by
context elsewhere in this table (e.g. the arrows), so they are not listed here
as single global keys.
/ for search is about as strong as unwritten cross-tool consensus gets,
which is the argument for moving commands to :. It remains a proposal
rather than a rule because it renames every typed command, so it needs its
own change and its own deprecation, and because the matrix's
Conflicts section records that : is not
free either (Claude Code binds bare :name: to emoji shortcodes).
Applying this
A PR that adds or moves a binding names the rule it follows. If no rule covers it, say so and propose one here in the same change, so the next person inherits a decision rather than a precedent.