feat(browser): the browser reads the TUI's keymap, and i opens an editor

Follows the keymap move. `keys.rs`'s hand-written per-mode classifier is
gone; `resolve` now looks the key up in `Keymaps::default_keymaps()` — the
same table the terminal dispatches against — and what is left here is the
narrow residue: turning a bound command NAME into a `Command`, for the
subset a client holding no `CmdContext` can compose.

That is a better seam than the one it replaces. The drift risk was "which
key does what": 170 bindings, no direct test, invisible when wrong. It is
now "which command names can I execute", which is a list — and
`the_normal_mode_keys_that_still_need_the_host_are_these` becomes
`the_command_names_that_still_need_the_host_are_these` to match, so a
rebinding no longer invalidates it and a new binding for an
already-executable command reaches the browser with no change here.

Several gaps close by sharing rather than by code. Search-mode typing
resolves locally for the first time (the table binds `append-char
["search"]`, and "search" is `ViewState::search_query` — gotcha 11's pair is
mirrored and nothing else is). `Esc` resolves as the `Binding::Sequence` it
is, all-or-nothing. A modified character in a text-entry mode now types,
because the `AnyChar` fallback being reached for is the host's own — the old
"unmodified only" rule was a hedge against a table this client no longer
guesses at. And a key nothing binds is `Ignored` rather than forwarded into
`Host::handle`'s empty vec.

What is newly executable, and so what a user gets back: `edit-or-drill`
(`i`/`a`), `commit-cell-edit`/`commit-and-advance-right` (Enter/Tab in
`Editing`), `enter-mode` (`?`/F1, Esc), `enter-tile-select` (`T`),
`toggle-panel-visibility` (Ctrl-F/C/V), `toggle-panel-and-focus` (F/C/V) and
`transpose` (`t`) — four of which an independent browser smoke test had just
found inert.

`i` on an **aggregate** does not go upstream. Every upstream key is a black
hole, and a keypress that visibly does nothing is the worst of the three
answers, so it answers with a `SetStatus` naming drill. A view-half command
is folded into the asker's own mirror and broadcast to nobody, so that
message reaches this user and no one else.

The write itself is not composed twice: the keyboard commit calls
`socket::typed_cell_command`, the function click-to-edit already used, so
Enter and a click cannot disagree about what `""` or `"42"` means.

## `CellDisplay::edit_text`, found by lockstep

`tests/lockstep.rs` drove `i` through the host and the client over one grid
and they disagreed by a comma: the host seeds its edit buffer from
`App::display_value_at_cursor` (the stored `1200`) while the client had only
`CellDisplay::text`, which has been through `format_f64` (`1,200`).
Committing that stores the *string* `"1,200"` and the number is gone —
display rounding fed back into the model, which design-principles §4 forbids
outright.

So the projection is enriched rather than the client taught to un-format:
`CellDisplay::edit_text` is `display_value_of` on the wire, and it repairs
**click**-to-edit too, which had been seeding its `<input>` from the drawn
text all along.

## Making the edit visible

`RenderModel::editing` carries what the user has typed so far, or `None`
when no edit is open. The decision — is this an editing mode, and which
buffer does it type into — is made in Rust; the page draws the string in the
selected cell. Without it the mode indicator would read INSERT while every
keystroke vanished into a buffer nobody painted.

`web/app.js` and `web/style.css` change accordingly, and are **unrun**: there
is no browser here (gotcha 48). `node --check` only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
This commit is contained in:
Edward Langley
2026-09-19 21:35:14 -07:00
parent 402ba0b841
commit e13635638c
11 changed files with 1257 additions and 362 deletions
+16 -5
View File
@@ -232,15 +232,23 @@ function gridTable(model) {
row.cells.forEach((cell, c) => {
const td = tr.insertCell();
td.textContent = cell.text;
td.classList.toggle("numeric", cell.numeric);
// A keyboard edit is open on the selected cell when `model.editing` is
// a string; it holds what has been typed so far. Both facts are
// decided in Rust (`RenderModel::editing`) — the page does not know
// which modes are editing modes or which buffer they type into.
// Without this the mode indicator would say INSERT while every
// keystroke vanished into a buffer nobody drew.
const typing = cell.selected && typeof model.editing === "string";
td.textContent = typing ? model.editing + "\u258f" : cell.text;
td.classList.toggle("numeric", cell.numeric && !typing);
td.classList.toggle("error", cell.error);
td.classList.toggle("selected", cell.selected);
td.classList.toggle("typing", typing);
// `editable` is the server's answer relayed through Rust: a stored
// cell. An aggregate is a drill target and drill is not on the wire
// yet, so it is simply not offered here.
td.classList.toggle("editable", cell.editable);
if (cell.editable) td.addEventListener("click", () => edit(td, r, c));
if (cell.editable) td.addEventListener("click", () => edit(td, r, c, cell.edit_text));
});
});
return table;
@@ -248,10 +256,13 @@ function gridTable(model) {
/** Open an inline editor. Committing sends a frame; the value that comes
* back is the server's, never the string typed here. */
function edit(td, row, col) {
function edit(td, row, col, seed) {
if (editing) return;
editing = { row, col };
const original = td.textContent;
// `seed` is `RenderCell::edit_text` -- the STORED value, not the drawn
// one. `td.textContent` has been through the number format, so a stored
// 1200 reads "1,200" and committing that stores the string "1,200".
const original = seed;
const input = document.createElement("input");
input.value = original;
td.replaceChildren(input);