Merge: one keymap table, shared by the TUI and the browser (improvise-bpq2 successor)

The browser's hand-written key classifier was a second spelling of
default_keymaps(), held honest by a lockstep test over a hand-written list --
doctrine(a_consolidation_that_keeps_its_compatibility_test_is_unfinished)'s
exact shape. So the TABLE moved to improvise-protocol and improvise-command
keeps only what needs a CmdRegistry. Evidence is binding_dump_diff, not the
suite: 4021 resolved (mode, key, modifier) lookups over sixteen modes,
BYTE-IDENTICAL, with the dump kept as an opt-in test and proved
discriminating by renaming one binding.

The boundary that did NOT move is the better one: key -> intent is shared,
intent -> Command stays client-side, and the named list of what still needs
the host is now a list of COMMAND NAMES rather than keys -- so a rebinding no
longer invalidates it.

AND A LOCKSTEP TEST FOUND A LIVE BUG, BY A COMMA. CellDisplay::text has been
through format_f64; App::display_value_at_cursor has not. Seeding an editor
from the drawn text and committing turns a stored 1200 into the string
'1,200' -- display rounding fed back into the model, which design-principles
4 forbids outright. CellDisplay::edit_text puts the unformatted value on the
wire, and it repairs web/app.js's click-to-edit, which had been seeding its
input from td.textContent since it was written. That bug is on the live page
today for any value with a thousands separator.

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:36:47 -07:00
14 changed files with 2422 additions and 1260 deletions
+20 -5
View File
@@ -84,6 +84,7 @@ const el = {
grid: document.getElementById("grid"),
categories: document.getElementById("categories"),
mode: document.getElementById("mode"),
hint: document.getElementById("hint"),
status: document.getElementById("status"),
viewName: document.getElementById("view-name"),
};
@@ -233,6 +234,9 @@ 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`.
el.hint.textContent = model.hint;
modelStatus = model.status;
paintStatus();
el.viewName.textContent = model.view_name;
@@ -261,15 +265,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;
@@ -277,10 +289,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);
+1
View File
@@ -36,6 +36,7 @@
<h1>improvise</h1>
<span id="view-name"></span>
<span id="mode"></span>
<span id="hint"></span>
<span id="status" role="status"></span>
</header>
+12
View File
@@ -89,6 +89,18 @@ td.selected {
outline: 1px solid var(--accent);
}
#hint {
opacity: 0.7;
font-size: 0.85em;
}
/* A keyboard edit in progress. Left-aligned even in a numeric column,
because what is drawn is the text being typed rather than a value. */
td.typing {
text-align: left;
outline: 2px solid var(--accent);
}
#grid {
flex: 1;
overflow: auto;