Files
improvise/context/code/tui.md
T
Edward Langley 9eb83538fe A message beside its open editor stands in the browser too (improvise-1x9h)
The toast hid every status after six seconds, so a refusal the user was
still fixing vanished with its prompt open: su4u's bug on a timer. The
browser now asks the TUI's question. EditorSession moves into
improvise-protocol, ClientState records which editor a status was
written in, and RenderModel::status_stands tells the toast to hold it
until that editor closes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 18:08:26 -07:00

515 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:
- **`layout` resolves a records-mode column** (`record_col`), and only that. It
keys `drill_state`'s pending edits, so it is session-local by design and
correct to keep off the wire (gotcha 1).
- **`drill_state` is session-local residue** (gotcha 30). Pending drill edits
belong to whoever staged them and are deliberately not projected, so they
shadow the cache rather than being on it.
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:
- The event loop converts every incoming `Event::Key` regardless of
`KeyEventKind` (Press/Repeat/Release), matching the pre-conversion behaviour,
which never inspected the kind.
- Codes the command layer does not model (media keys, `Insert`, …) become
`KeyCode::Null`, which no keymap binds.
- `SHIFT`/`CONTROL`/`ALT` survive; every other modifier bit (`SUPER`, `HYPER`,
`META`) is dropped.
### Semantic-to-terminal colour, and the one that is wrong
This file holds the mapping design-principles §5 names as the shape everything
else should imitate: **the decision lives in the shared layer as a semantic
enum, and only the last translation is local.**
- `axis_fg(AxisColor) -> Color` — `Axis::color` is the single place each axis's
accent is decided; this is the single place axis colouring is *rendered*
(improvise-rml, gotcha 26). Before it, the tile bar and the category panel
each carried their own copy of the colour table with nothing keeping them in
agreement.
- `minibuf_fg(MinibufColor) -> Color` — the same shape for the minibuffer.
- `src/ui/import_wizard_ui.rs::field_kind_fg(WizardFieldKind) -> Color` — the
same shape again. `improvise-protocol` must not name a ratatui `Color`.
`mode_style(&AppMode) -> Style` stays here for the same reason (`Style` and
`Color` are ratatui types and `improvise-command`, where `AppMode` lives,
carries no rendering-framework dependency), and since `improvise-zx8t` it is
**exhaustive**: the thirteen variants that take the default chrome are spelled
out rather than swept up by a `_ =>` wildcard, so adding an `AppMode` fails the
build here until someone chooses its colour. The arithmetic is the check a
prose count does not get: 13 + `Editing`/`RecordsEditing` + `CommandMode` +
`TileSelect` = 17, which is `AppMode`'s variant count. It read *fourteen* until
2026-09-22, and nothing could have caught that — a count written in prose is
checked by no one.
It is still not quite the same shape as `axis_fg` and its two siblings, and the
difference is deliberate rather than pending. Those three map a *semantic enum*
the shared layer owns; `mode_style` matches `AppMode` itself, because there is
no `ModeColor`. `improvise-zx8t` offered making one, and it was declined here
for two reasons: the colour is the only thing at stake, and the ticket says to
decide it together with `improvise-k099`, which asks the same question of
`hint_text`/`label` and is not in this unit. Exhaustiveness is what the
repo map's "the compiler names every one you must fill in" promise needed; a
semantic enum is a separate, larger answer and is still open.
**The guard is the compiler, not a test.** Nothing asserts the colours, and a
test enumerating the variants would not fail when a fifteenth is added — only
the missing match arm does. Verified by deleting the `AppMode::Help` arm:
`error[E0004]: non-exhaustive patterns: `&AppMode::Help` not covered`.
### 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:
- **The title bar keeps the right.** Its left segment is decoration whose length
is unbounded — a document name plus a filename — and its right segment is the
fixed `" ?:help :q quit "`, the only thing on screen telling a stuck user how
to get out. Unconditional left priority made the escape hatch the first thing
sacrificed, at 100 columns with an ordinary document name (improvise-t14d).
- **Both bottom-bar rows keep the left**, which is what they already did. There
the left is the user's own typing plus the cursor (`minibuffer_line`) or the
status message (`draw_status`), and the right is a view badge or the message's
own margin. Right priority there would let a long message eat the text being
edited — the same mistake in the other direction.
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):
- **Tile select: the status bar wins.** `hint_line(&mode)` is read off the
keymap, so it cannot go stale, and it is a strict superset of what the tile
bar's literal carried — `Space`, `h`/`l` and `Tab` were only ever on the
derived line. The tile bar's own hints are therefore **deleted**, both of
them, and `TileBar::render` now advertises no keys at all. The one it drew in
Normal was false: it named `Ctrl+↑↓←→` as "to move tiles" while those four
keys are bound to `enter-tile-select`, which moves nothing.
- **The import wizard: the overlay wins.** `hint_line(&AppMode::ImportWizard)`
is derived too, but from the *wrong input* — the wizard's real keys are
resolved per step inside `HandleWizardKey`, and one static line off the mode's
keymap is wrong on four of its eight steps, including telling the user `Esc`
cancels on `Done` where `Esc` goes back. So `draw_status` suppresses its hint
fallback for that one mode and lets the overlay's per-step hints stand alone.
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 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:
- The grid's viewport covers the **whole** grid, so a cache position is an
absolute position (gotcha 44). Only the viewport narrows when the TUI becomes
a client.
- The wizard is **built here rather than sent**: it is session-local and has no
`Projection` (improvise-764). It still goes through `project`, so the widget
sees the same shape a browser wizard would.
`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:
- The category panel draws one flattened tree — the axis travels on the entry
(improvise-edp), so there is no `View` in it.
- The view panel's "which view is this client on" is read off `ViewState`, not
the workbook: the active view is per-client and is deliberately absent from
`ViewListCache` (improvise-8uz, gotcha 29).
`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:
- a message a key produces is drawn after that key, whatever it is, errors
included. It stays until the user presses something, which is the moment they
have acted on it. An error is never wiped before it has been drawn.
- a message that arrives outside a key, such as one from startup or an import,
also stands until the next key.
- the next key clears it *before* its own effects run. So a key that writes a
new message shows that message, and a key that writes none shows the hint.
**Rejected alternatives.**
- *Expire on mode change.* This fails the ticket's own case: a message written in
Normal would stand in Normal indefinitely.
- *Expire on the next successful command.* Effects carry no notion of success,
and a failed command would leave the stale message in place. That is exactly
the case where the user most needs the hint back.
- *Clear after dispatch.* Every message would be wiped before it is drawn. The
test's middle step exists to catch that.
- *Compose message and hint on one line.* A long message still eats the hint, and
the stale message still stands forever.
**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:
- a text-entry mode was open before the key;
- the same editor session is open after it (below);
- the key wrote no message of its own.
So:
- editing keys keep the refusal on screen until the user acts on it;
- a key's own message always wins, so a second refused `Enter` shows the new
error, not the old one;
- leaving the minibuffer, by a successful commit or by `Esc`, drops it, exactly
as the plain rule does.
**Rejected alternatives.**
- *Expire only on the commit key.* That needs to know which keys commit, which
is the keymap's knowledge (`TextEntry::commit`), and a second copy of it in
the TUI loop. "The mode did not change" is observed rather than predicted,
like gotcha 31's `ReturnToNormalUnlessMoved`.
- *Never clear in a text-entry mode.* The message would then outlive `Esc` and
stand in the mode the user returns to, which is the cq3d bug again.
- *Compare the message after the key with the stale one.* A key that wrote the
same text would be indistinguishable from a key that wrote none. Clearing
first and restoring only an empty slot has no such ambiguity.
### 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:
- **the whole `AppMode`**, not only its variant: an `ItemAdd` for `Month` is
not the `ItemAdd` for `Region` it replaced;
- **the cursor cell**, which is a cell editor's target, so a commit that
advances is a new editor. The prompt modes do not move the cursor while the
user types, so for them this is inert;
- **the editor's seed** (`opened_editor.seed`). `EnterEditAtCursor` re-seeds
from the cell. So a commit that re-opens on the **same** cell, such as `Tab`
in the last column, is a new editor too, because the seed is now the
committed text.
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".