Source: context/code/tui.md

On this page

The terminal front end (src/)

The root crate is the ratatui binary: src/draw.rs (event loop and frame composition), src/ui/*.rs (the widgets), src/main.rs (clap). It is one of two front ends, and design-principles §5 is the rule it is judged by — it paints and wires, and every decision with a silently wrong answer is made in improvise-protocol so the browser cannot answer it differently.

Test narratives live in context/test-evidence/tui.md. Facts about GridCache, AxisFrame, Projection and the Command/Effect layer belong to context/code/protocol.md and context/code/command.md; this file cites them and does not restate them.


#src/ui/grid.rs — GridWidget

The grid is drawn from a GridCache (improvise-n10).

No &Model. Every value the grid paints — each cell's text, whether it is an aggregate, the column widths, the page bar and the totals strip — arrives already decided, on the cache. A widget that cannot reach the model cannot quietly answer a question differently from the way the browser client answers it, and that divergence is the whole thing projecting exists to prevent.

The frame comes off the cache too (improvise-anr9). GridCache carries an AxisFrame per axis — the header levels, the label tuples and the groups with their collapse state — so the multi-level headers, the repeat suppression, the group spans and the pieces below are all read rather than derived, and a browser painting the same frame paints the same thing. It used to be derived here from GridLayout + View::is_group_collapsed while the protocol carried GridLayout::row_label's slash-joined form — the rendering two_row_categories_produce_cross_product_labels asserts must not appear — so the browser drew Food/Alice.

Two things are still not on the cache and are passed separately, each a different kind of absence:

The cache is expected to cover the whole grid — offsets zero, every row and every column — so a position in it is an absolute position and the widget's scrolling arithmetic is unchanged.

#Inside render_grid

GridWidget::cursor is this client's cursor in the active view (improvise-8uz), passed in rather than read off View, which no longer carries per-client state.

Everything about the frame is the cache's answer (improvise-anr9): how many header levels, what each level's label is, which group a position sits in and whether that group is collapsed. Nothing in render_grid reads GridLayout or View, so a browser folding the same AxisFrame draws the same header.

The bulk forms are built once per frame, and that is not a micro-optimisation. header_labels(offset), groups() and shares_ancestry(sel_col) each make one pass. The per-position accessors (item, label, shows_level, group_of, same_prefix) scan the frame from the start, so asking them inside the draw loops is quadratic in the grid and, at a deep scroll offset, grows with the offset. col_lines transposes header_labels to level-major because the column header is drawn one line per level while header_labels answers per column.

The underline on the column header marks columns sharing sel_col's ancestor group through each level: at the bottom level that is only sel_col, at higher levels it spans all the sub-columns.

Pending drill edits shadow the projected value. They are this session's uncommitted typing and by construction are not on the cache (gotcha 30).

Aggregate cells are drawn in italic to signal "drill to edit", and the fact is not re-derived here: the projection already named what each cell is, and CellId::Aggregate is exactly that fact (ADR 002), decided once by the side that holds the model. The widget used to walk none_cats × kind.is_regular() — a second hand-rolled copy of project::cell_id.

Whether there is a total row at all is the cache's answer. An empty totals strip means "no totals" (records mode, or an empty grid), which is why the widget does not read is_records_mode a second time.

The collapse glyph (indicator) is the one thing that is legitimately local: the fact — whether the group is collapsed — is on the frame; only its spelling is the terminal's (design-principles §5).


#src/draw.rs

#The key-event boundary

convert_key_event / convert_key_code / convert_modifiers are the only place crossterm's key types and the crate's own command::key types may both appear. Three rules:

#face_style — the one place a face becomes a terminal style

ADR 004 replaced the four per-family maps this section used to describe (axis_fg, minibuf_fg, field_kind_fg, mode_style) with one function: face_style(Face) -> Style resolves the face against the shipped theme (Theme::resolve, in improvise-protocol) and translates the result. That is design-principles §5's shape with nothing left over: the decision is shared, and only the last translation is local — Colour → ratatui::Color and the six attributes → Modifier. The chain is never walked here (ADR 004 clause 3). What the vocabulary is and why is context/code/protocol.md §10.

Three things to know.

The mode indicator is still exhaustive over AppMode, now in Face::of_mode rather than here: adding a variant fails the build there (improvise-zx8t's guard, moved with the table). The thirteen DarkGray variants are no longer one arm: they are the five faces ADR 004 derives (mode.resting, mode.text-entry, mode.panel.*, mode.overlay, mode.transient), which all still inherit the same colours from mode.

No-op evidence. face_tests::the_shipped_theme_reproduces_every_migrated_literal holds the old literals as a table and asserts the shipped theme draws each one, which is clause 8's property. The one deliberate difference, the category-add prompt turning green, is asserted by the_category_add_prompt_draws_as_the_item_add_prompt_does rather than being left out of the table silently.

Still literal: every other Color:: in src/ (the selection sites, the panels' accents, the grid's headers, help, the welcome popup). They move as their files are touched (improvise-nn4s.3, improvise-nn4s.6). Do not touch src/ui/grid.rs for this while improvise-felo.6 is in flight.

#The minibuffer shares the bottom row with the status line

draw_bottom_bar used to render the minibuffer instead of draw_status, so a status message written while any text-entry mode was open was never drawn (improvise-mai9, and the stranding gotcha 21 describes). minibuffer_line composes both: the prompt and buffer on the left, any pending status_msg right-aligned. The alternatives and why they lost are in context/test-evidence/tui.md.

#fill_line — which segment yields is a per-call-site decision

Three rows in the TUI are one left segment and one right segment sharing a line: the title bar, minibuffer_line and draw_status. fill_line composes them, and it owes two things that it did not originally do — it must measure in display cells (it padded with left.len(), bytes, and the title's own · is two of them, so every document's right segment sat one cell in from its edge) and it must truncate, because concatenating and letting ratatui clip clips the right.

What clipping the right costs depends on the row, which is why Keeps is a parameter and not a rule:

So the rule is not "the right wins" but "the affordance wins over the decoration", and which is which is local. Truncation goes through ui::text::truncate (improvise-8l0c), so an overflow is a … rather than a mid-token clip of ?:.

#One key table on screen, and it is the one derived from the keymap

The TUI stacks the tile bar (row 3) directly above the status bar (row 4), and the import wizard's overlay sits above the status bar too, so a mode can put two key tables on screen at once. When it does, they disagree — different notation, different content, and in one case a flat contradiction.

The rule is not "the overlay wins" or "the status bar wins". It is keep whichever table is derived from the thing it describes (improvise-rr8n):

Two things worth keeping from how this survived. every_hint_key_resolves_in_its_mode checks AppMode::hint_text() against the keymaps and cannot see a string literal in a widget, which is the same blind spot improvise-wk18 names for help.rs. And the widget's own characterization test renders at 80 columns, where the hint does not fit and is never drawn — so the test that pins this widget's output was structurally incapable of showing either hint. A fixture width is part of what a test can see.

The longer-term shape the ticket names is still open: the wizard's per-step keys belong in advertised() keyed by step, which would make them derived like everything else and let the status bar carry them correctly instead of being switched off.

#The grid's width is one answer, and a scroll is measured where it is drawn

Two parts of the TUI scroll horizontally, and both lost their cursor for the same reason: the code that chose the scroll offset measured a different width from the code that drew it (improvise-gtn7.1).

Why it looked like a regression. The grid defect dates from b5418f2e (2026-04-06), when compute_visible_cols replaced a rough estimate and took term_width. It was always wrong with a panel open. But until 50e4a372 (2026-09-20) no panel was open at launch. That commit opened the category panel by default, in the shared ViewState::default(), and from then on every TUI session started in the broken configuration. The tile bar's offset has been wrong since 4686f470 (2026-04-09), when it first scrolled.

What is still wrong: improvise-gtn7.2. visible_cols is a count, taken at the old offset, and viewport_effects applies it at the new one. Across columns of mixed width no single count is right, so the cursor can still leave the grid with no panel open. The fix is to measure the new offset against the widths, as first_tile does. It is not in this change because resolve.rs::scrolled_to mirrors viewport_effects over a window counted in columns, and lockstep.rs holds the two in agreement.

#The caches are built here, per frame

project::formulas_of, category_tree_of, view_list_of, tile_bar_of, grid_of and wizard_of are all called from draw/draw_content against the authoritative App. This is the temporary half of improvise-35e: when the TUI becomes a client it will hold each cache and fold the matching Projection into it, and none of the widgets change either way.

Two consequences today:

draw renders overlays last so they appear on top. The which-key popup's armed prefix is stored as the key that opened it and resolved back to a sub-keymap by the keymap set (improvise-jc8, gotcha 13).


#src/ui/import_wizard_ui.rs — ImportWizardWidget

Drawn from a WizardCache (improvise-764).

No ImportWizard, no ImportPipeline, no Workbook. The widget used to hold the live wizard and read through it into improvise-io types and into the model — it called regular_category_names() and measure_item_names() on the built Workbook to write its one-line confirmation summary. Everything it draws now arrives already decided, from project::wizard_of.

The cache is not a Projection, and that is the ticket's answer to where wizard state lives: a half-open import dialogue belongs to the session that opened it, not to every connected client. WizardCache's own documentation carries the argument.

Smaller facts the drawing depends on: the root array path is already spelled (root) by the projection, so both ends say it the same way; the date components arrive in the order the wizard offers them, already paired with whether they are on, so the cursor index counts exactly those rows; and on Done the workbook is built but not yet installed, so the step shows what the user is about to accept, warnings included (improvise-907).

#set_bottom_line — one writer for the bottom row

That row has two writers — each step's key hint, and the wizard message that overrides it while present — and they write in that order to the same y. Buffer::set_string writes exactly as many cells as the string has and leaves the rest alone, so a message shorter than the hint it replaced left the hint's tail sitting beside it: something went wrongcel, the cel from Esc: cancel (improvise-q0vb).

Padding here rather than at each site is the point. The bug is not that one of the five writers forgot; it is that the row had five writers and no one of them owned clearing it.

The padding is measured in terminal cells, not characters. Buffer lays a string out by display width, so a char count over-pads a message carrying a wide character — enough to run past the panel and overwrite the border — and under-pads a combining mark, which is how a sliver of the hint would be left behind. src/ui/grid.rs measures the same way.

#truncate / prefix — bytes, deliberately

truncate cuts by byte length. prefix returns the longest prefix at most max bytes that ends on a char boundary, because every string truncate cuts can carry a field name, a formula or a message the user typed — and a multi-byte character straddling the cut used to panic the whole TUI on a slice at a non-boundary index. Walking down to the nearest boundary is the smallest change that cannot: for ASCII, which is what every fixture in this file holds, max is already a boundary and the result is byte-identical, so the characterization tests keep saying what they said.

It is bytes and not columns deliberately. Byte length is the measure truncate has always used, and changing it to display width would move where every line wraps — a different question from not crashing, and one that belongs with the hint-line padding it travels with. Bytes are never fewer than cells, so truncate cutting by bytes and set_bottom_line padding by cells cannot together overrun.

The blind spot, stated because the fixtures cannot state it: the eight exact-frame characterization fixtures in this file are entirely ASCII. They are therefore blind to every question about display width — a change to either measure is invisible to them, and the evidence for those has to be built directly (see set_bottom_line_clears_every_cell_it_covers_and_no_more).


#src/ui/help.rs

~600 lines of mostly-static content: five pages of styled (key, description) lines, a tab bar and a footer. improvise-ktv1 proposes moving that content into the shared layer so a browser help overlay is not a second copy of it. Until then it is TUI-only and help_page is already in ViewState, which is why gotcha 32 says the overlay needs no Projection.

#The panel widgets

src/ui/panel.rs holds the PanelContent trait and the generic Panel widget. The one non-obvious member is panel(): each impl declares its own identity, and the widget compares it against AppMode::panel() to decide whether to draw itself as active, instead of restating the mode match per panel.

category_panel.rs, formula_panel.rs, view_panel.rs and tile_bar.rs each draw one cache and nothing else. Two facts worth keeping:

tile_bar.rs scrolls by whole tiles: it finds the smallest start index at which the selected tile still fits in the available width, and draws ‹/› overflow indicators. That is the file's only piece of real arithmetic and it is local geometry, not a shared derivation.

#, walks the view history forward (improvise-htl6)

view-forward was bound in no keymap, in either front end, from the day it was written. The TUI reached it as :view-forward and a browser could not reach it at all, because declined() refuses to enter CommandMode at the door. It survived because pressing < twice is the only way to observe the forward stack in the first place, and until PR #115 no client maintained one.

The key is ,, and > is why the obvious answer is wrong. < is view-back and > is drill-into-cell, so the pair that looks symmetric is not one — > moves within the grid, < moves between views. Binding the forward half to anything that reads as >'s partner would deepen exactly the confusion that hid the gap. , is the unshifted twin of <, so both halves of the history sit on one physical key, and it carries no direction-in-the-grid meaning to collide with.

It is deliberately not a Ctrl- key. Ctrl-R, the universal "forward through a history" spelling, is browser reload; Ctrl-S and Ctrl-W are already bound here and Ctrl-S is already a filed bug for precisely that reason (improvise-clso, "Ctrl-S saves and also opens the browser's own save dialog"). The keymap is shared between the two front ends, so a binding chosen for the terminal is a binding the browser must also live with, and adding a second instance of an open bug is not a thing to do by accident.

Nothing else moved. help.rs keys its lines off the command name and looks the key up in the keymap, so the overlay and the hint line follow the binding; every_bound_command_name_is_registered already covers the new entry. The absence assertion PR #117 put in resolve::tests::the_forward_step_mirrors_its_cmd_and_no_key_reaches_it was inverted rather than deleted — the test is renamed …_and_one_key_reaches_it and now asserts the binding exists, with view-back kept as the control so it cannot pass against a table that binds everything or nothing. Coming there and saying so was the point of that assertion, and an inverted guard keeps the property under watch where deleting it would have let the key quietly go away again.

, is not advertised in AppMode::Normal's hint line, and neither is <: the hint is width-constrained and the history lives on help page 5 under "View history" with both halves. That is the #149 lesson — a hint that does not fit is never drawn, so the test pinning the widget cannot see it.

#A status message lasts until the next key (improvise-cq3d)

draw_status draws status_msg instead of the mode's derived hint, and nothing in production ever cleared status_msg: it was only replaced by the next message. So after the first "Saved", "Loaded" or error of a session, the derived key hint (the one key table on screen, improvise-rr8n) was never drawn again, in any mode.

The rule: a message expires at the start of the next keypress. draw.rs::press applies SetStatus("") and then dispatches the key, so:

Rejected alternatives.

Why here and not in App::dispatch_key. dispatch_key is shared with the lockstep harness (reduce_key), which compares status_msg on the host and the browser after every key. Expiring there without changing the browser's resolver too would make the two ends disagree. The expiry is also a presentation decision about the TUI's one-line status row, so it belongs to the TUI front end.

The browser already behaves this way. Its status_msg is state that is never cleared either, but web/app.js shows it in a toast that hides after TOAST_MS (web/shell.js::createToasts), and #hint is a separate element that is always drawn. So a browser user sees a message briefly and never loses the hint. The TUI now does the same, on a keypress rather than a timer, because a terminal status row has one line to share and a keypress is the event the TUI already has.

#Except while a minibuffer stays open under it (improvise-su4u)

In a text-entry mode minibuffer_line draws the message to the right of the prompt. Since improvise-v4hg, a formula that does not parse keeps the user in FormulaEdit with the error. Under the plain rule, the error vanished on the first correcting key: one Backspace and the user was fixing text they could no longer see the complaint about. The same happened to any refusal that leaves the user in a minibuffer.

The rule: a key that leaves the user in the same editor, and says nothing of its own, puts the stale message back. press remembers the editor session and the message, clears the message as before, dispatches the key, and then restores the message only if all three of these hold:

So:

Rejected alternatives.

#What "the same editor" means (improvise-sx7h)

The first version of this rule compared modes: an equal AppMode before and after the key meant the same editor. That is false for the two cell editors. Editing and RecordsEditing commit with CommitAndAdvance, which drops to Normal, writes the cell, moves the cursor and re-opens the editor through EnterEditAtCursor, which sets an equal AppMode. So after an Enter or Tab that committed and moved on, the mode looked unchanged, and the stale message was restored beside a different cell's editor. It came back again on every later commit until Esc. The reviewer's repro was R, o, 5, Tab, 7: "Added new record row" survived the Tab, and so did o's refusal "That record row is already here…" after the Tab that filled the row in. FormulaEdit could not show it, because its successful commit leaves the mode and writes a message of its own.

An editor session is one opening of a minibuffer. The TUI loop cannot see openings, so EditorSession compares what an opening sets:

What it cannot see: a commit that re-opens on the same cell with the same seed. That means the user committed the text the cell already held, which writes nothing. Restoring a message across a commit that changed nothing is harmless.

Rejected: an editor generation counter. It would be exact, but an editor is opened by more than one effect (EnterEditAtCursor, and ChangeMode from enter-mode). A counter bumped in each is a hand-kept list of the places a mode is written, which is the shape gotcha 24 retired. The three fields are set by the opening itself, so they need no list.

Not included: the active view. The cursor is per view, so in principle the view belongs with the cell. But no key a minibuffer binds can switch the view and stay in the minibuffer: a : line that switches view settles back to Normal. So nothing could test the field, and an untestable field is a claim no test can check.

The browser asks the same question (improvise-1x9h). EditorSession now lives in improvise-protocol and is built from a ViewState, so the TUI's press and the browser's ClientState::status_stands compare the same value. The browser's mechanism differs: its message is a toast on a timer, and there is no per-key clear. So it keeps the message's owner and suspends the timer while that owner is open. See context/code/clients.md, "A message beside its open editor stands until that editor closes".

#The import wizard wraps and scrolls; it never truncates a message (improvise-cgqt)

The user's ruling of 2026-09-25: "a message like this should wrap not truncate so I can read the whole thing." Every warning and message the wizard draws goes through ui::text::wrap. That covers the Confirm step's warnings, the bottom-line message, the duplicates step's intro, options, disabled reasons and colliding records, and the name step's explanation. wrap measures in cells, like truncate (gotcha 50). It breaks at spaces, cuts a word only when the word alone is wider than the line, and emits a lone double-width character rather than dropping it when the line is narrower than that character. A leading indent is kept on every line, so an indented row hangs under itself.

The bottom-line message wraps upward from the row the key hint uses. A one-line message therefore draws exactly as before, which is why the pinned frames (every_step_draws_what_it_drew_before, the_formula_step_draws_its_transient_state) did not move.

Lists scroll by item, and the header says what is shown. Panel::scrolling draws the items from WizardCache::scroll on, each one wrapped whole. The list's own header row reads Colliding records 3–9 of 14 (PgUp/PgDn): whenever some items are off screen. A first version put "more above" and "more below" markers inside the list's rows. When the first item filled the list exactly, the marker landed on the hint row and was drawn over (Codex, PR #304). The header row belongs to no item, so no item can cover it, and no item loses a line to make room for a marker. ImportWizard::scroll clamps to len - 1, so the last item can reach the top and the list can never scroll away. It is by item, not by line, because the wizard state cannot know the panel width that line-level clamping needs. The residual is honest. A single item taller than the whole list area, roughly 1,500 characters at 80 columns, is still clipped at the panel's bottom. A collision tuple has at most MAX_CATEGORIES coordinates (24 since improvise-felo.1, up from 12) plus labels. With short names that is a few hundred characters. Neither the names nor a label value is bounded, though, so reaching the residual got easier when the cap doubled.

Every write is clipped to the panel. The widget draws through Panel, whose put writes only inside the panel's inner rectangle, and its height arithmetic saturates. Before this change, the duplicates step wrote at an unbounded row and ratatui panicked at 80×10, and four older steps panicked on u16 underflow at heights of 3 to 5 (review B2 on PR #304). The bottom row's set_bottom_line now truncates in cells through ui::text::truncate. It had used the widget's byte-measured truncate, so a wrapped line holding a ← was cut one character early, with …, although it fit (review B1). The local byte truncate still draws field names and labels. It is byte-identical on ASCII, which keeps every pinned frame, and those rows are labels rather than messages.

Other TUI surfaces still truncate text a user needs. They are out of this ticket's scope and reported in its hand-back rather than fixed here.