feat(protocol): one home for UI strings (improvise-x7sw, ktv1, k099)

Three tickets asked one question — where do UI strings live relative to a
serde wire enum — and answering it three times is the disease this unit exists
to cure. One answer, in `improvise-protocol/src/help.rs`:

**A UI string is keyed by a command name, never by a key, and never hangs off
the enum that crosses the wire.** `AppMode::label`/`hint_text` are gone;
`help::mode_label`/`help::hint_line` replace them, so the enum a client
deserializes carries no English at all (improvise-k099). **And wherever a
string names a key, the key is looked up rather than spelled**:
`Hint { keys, command, caption }` takes `keys` off `Keymaps::default_keymaps()`,
`command` is the semantic value a localized front end keys off, and `caption`
is the default English — the `AxisColor`/`axis_fg` shape.

- **improvise-x7sw**: `help::which_key` is the which-key popup's rows, keys and
  captions together. The client already holds the keymaps, which is the answer
  the ticket preferred, so `ViewState::transient_prefix` stays an address.
- **improvise-ktv1**: the help pages are data — `help::pages()` — with every
  key row derived. `src/ui/help.rs` keeps its ratatui styles and loses ~310
  lines of content; `web/app.js` paints rows it does not author, and
  `harness/tests/shell.spec.js` asserts that `?` really draws them.

**What this deletes is the measure of it.**
`every_hint_key_resolves_in_its_mode` and its 60-line hint tokenizer are gone:
they existed to hold a hand-written string honest against the keymap, and the
string is derived now, so the property is true by construction
(`doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)`).
`Keymap::binding_hints` goes with them — `which_key` is the one walker, and the
binding dump reads it. What replaced the test checks the *other* direction, a
mode advertising a command nothing binds, which was never checkable before.

Two defects the derivation exposed, both fixed here: `key_label` dropped
modifiers, so Ctrl-arrows read as bare arrows, and `Char(' ')` printed a literal
space instead of `Space`.

**What is deliberately not derived**, and says so: the import wizard's keys
(its whole keymap is one `Any` binding into `HandleWizardKey`, which decides
per step inside the command — a real hole, left visible), `gg`/`yy`/`ZZ` and
the ex-commands (`HelpLine::Literal`), and the captions, which no test can
judge. `context/code/protocol.md` §8 has the argument.

Cost, stated: the hints changed shape. They are longer, they name arrow keys
beside `hjkl`, and two sub-lists on the Panels page lost their visual indent.
That is the price of the keys being true.

Evidence: 1321 tests pass, and the 7 Chromium shell tests pass against the
assembled site. Two faults were injected into the help overlay — dropping the
`paintHelp` call, and removing the `mode != Help` guard so it is never hidden —
and each fails the new spec.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Edward Langley
2026-09-20 11:18:17 -07:00
parent 555d9f1ca7
commit 414d02c4a6
20 changed files with 1442 additions and 618 deletions
+43 -2
View File
@@ -31,6 +31,7 @@ const el = {
categories: document.getElementById("categories"),
mode: document.getElementById("mode"),
hint: document.getElementById("hint"),
help: document.getElementById("help"),
status: document.getElementById("status"),
viewName: document.getElementById("view-name"),
};
@@ -177,9 +178,10 @@ function repaint() {
painted = model.revision;
el.mode.textContent = model.mode;
// The mode's own hint, off `AppMode::hint_text()`. The page spells no
// keys itself; see `RenderModel::hint`.
// The mode's own hint, off `help::hint_line`. The page spells no keys
// itself; see `RenderModel::hint`.
el.hint.textContent = model.hint;
paintHelp(model);
modelStatus = model.status;
paintStatus();
el.viewName.textContent = model.view_name;
@@ -197,6 +199,45 @@ function repaint() {
if (changed) sendViewport();
}
/** The help overlay. An empty `help` is what says it is closed -- the page
* asks no question about the mode, and writes none of the content: the rows,
* their keys and the page titles are `improvise_protocol::help`'s, the same
* ones the TUI draws (improvise-ktv1). */
function paintHelp(model) {
el.help.hidden = model.help.length === 0;
if (el.help.hidden) {
el.help.replaceChildren();
return;
}
const tabs = document.createElement("div");
tabs.className = "help-tabs";
model.help_titles.forEach((title, i) => {
const tab = document.createElement("span");
tab.textContent = title;
tab.className = i === model.help_page ? "help-tab active" : "help-tab";
tabs.append(tab);
});
const body = document.createElement("dl");
body.className = "help-body";
for (const row of model.help) {
const keys = document.createElement("dt");
keys.textContent = row.keys;
keys.className = `help-${row.kind}`;
body.append(keys);
// A prose row has no description and spans both columns (its class says
// so); emitting an empty `dd` beside it would leave a blank grid cell.
if (row.description) {
const desc = document.createElement("dd");
desc.textContent = row.description;
body.append(desc);
}
}
el.help.replaceChildren(tabs, body);
}
/** The grid, painted from a model in which every frame decision is already
* made (improvise-anr9). The page decides nothing here: which labels repeat
* and are therefore blank, how many header levels there are, which columns