improvise-5xbq: resolve::page_rows is now the one home of the page-scroll
rule; the TUI's Move calls it instead of repeating visible_rows * 3 / 4.
A lockstep case pages (PgDn/PgUp, Ctrl-d/Ctrl-u) on a non-square window,
8 rows by 3 columns, and pins the TUI's landing rows as well as comparing
host and client, since one home moves both ends together. Both faults
the ticket injected (page_rows = window_rows / 2; paging by window_cols)
now fail at the first PgDn, before and after the move to one home.
improvise-1c5m: the help overlay draws the mode's own hint (hint_line,
the string #hint carries) in a sticky footer, so a narrow window whose
app bar wraps under #help still says how to page. Playwright case at
600px, red at base, and red again with the footer made non-sticky.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
Resolved context/test-evidence/clients.md: gh/main's version, whose
improvise-w7qd/5kdr paragraphs continue the entry above the conflict,
followed by this branch's two improvise-0j0f/4img entries.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
B clears the cell while A's editor is open, then A commits. The commit is
refused as RecordMoved. The store no longer holds the record, but
App::resolve_record falls back to the layout and still resolves it, and its
counter has moved. Under the claim-at-commit rival, the same commit is accepted
and re-creates the cell as 42. Corrects the evidence that reasoned RecordGone.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
21x21 grid, 8x8 window, start (10,10): moves land at 10+d, page-scroll at
10+page_rows(8)*d, jumps at 0/20. grid_move's page rule is page_rows(),
which the test reads rather than restating. Doubled move-selection, a
one-row scroll-rows and a page-size-blind page-scroll each fail.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
improvise-6yvg reproduced. A JSON import in improvise-command and a legacy
JSON .improv load in improvise-io stored 0.24815714285714285 as
0.24815714285714283 in each crate's own test build. The root binary's
`improvise import` used the same lossy parser. Now float_roundtrip is on every
normal serde_json dependency in the workspace: root, improvise-command,
improvise-io, and the optional test-support dependency of improvise-protocol.
improvise-dz8c: the hand-listed cargo-tree check over three frame-parsing
crates is replaced by tests/json_numbers.rs. It reads
`cargo metadata --no-deps` and names every package, including target-specific
entries, whose normal serde_json dependency lacks the feature. The rule and why
it beats "parses a frame", the build graph, and enabling the feature once in
the protocol crate are in context/code/protocol.md.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
The worker transport wrote "in-page worker · <doc>" when it spawned the
worker, before anything had answered, so a tab whose worker could not start
showed a success-looking status over a blank page. It now writes "starting
the worker · <doc>…", and createShell's arm() takes an `answer` that sawFrame
writes on the first frame, but only while the watchdog is still armed. A
ReadOnly or SeedMissing notice lands BEFORE the Snapshot, and fail() has
already disarmed, so the claim cannot overwrite the precise reason.
The two-tab spec now logs every #status and denies both the premature claim
and "did not answer" before the page clock is walked. A new spec pins the
exact status sequence on the writable path. Reasoning:
context/code/clients.md, "The watchdog".
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
scroll-rows and page-scroll derive their landing from their argument as
move-selection does; any bound command that moves the cursor with no
stated landing fails; the numeric floor is gone. Reversed Ctrl-d/u and
PgUp/PgDn, and a dropped jump table with $ sent to column 0, both fail.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
CellValue's Display writes a non-whole number {:.4}, and every editor seed
was that string, so i then Enter on an untouched 1.23456789 stored 1.2346,
in the TUI (pivot and records) and in the browser, which seeds from the
projected edit_text. CellValue::edit_text is now the one home of the seed:
the shortest decimal that reads back as the same f64 (non-finite values
take the core.md §16 token). display_value_in's two arms and
cell_display's records arm all go through it; the now-orphaned
GridLayout::resolve_display is removed.
Text that reads as a number (007) is exact in the seed but still
reclassified by typed(); filed as improvise-ixxx.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
composed() dispatches on the command name and ignores the event, so the
',' it was given pinned nothing: rebinding view-forward to '~' left the
test green (improvise-3ij0). resolve() looks the key up in the shared
keymap first, so the same rival now fails at that assertion.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
views/formulas were empty-means-closed, so an open panel over a document
with no formulas built the same RenderModel as a closed one. Option<Vec<_>>
carries both facts in one field; the page hides on null.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
PR #162 (improvise-xg1z) made render.rs paint edit_seed when Editing
arrived without its edit buffer, but commit and append-char still read
the absent buffer as the empty string. So the overlay showed the value,
Enter composed ClearCell, and a typed character replaced the text
instead of extending it: two derivations of what is in the editor.
The fold now seeds the missing buffer (ClientState::settle_editor, next
to settle_mode), and render.rs reads the buffer through read_buffer, as
commit does. edit_seed goes back to pub(crate) and no renderer calls it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
In the TUI, EditOrDrill hands an aggregated cell to DrillIntoCell, so `i`
on an aggregate is drill by another door. The help page called it only
"Edit current cell", and the browser refused it in words unrelated to `>`'s,
so a user could not tell the two were one missing feature.
- help.rs: the edit-or-drill row says it drills into an aggregate.
- help.rs: Normal-mode Enter was captioned "Edit current cell (same as i)"
but is bound to enter-advance alone, which only moves down; the row moves
to Cursor movement and says so.
- resolve.rs: the Aggregate arm now reads "an aggregate is edited by
drilling in — " + not_yet("drill"), the same sentence `>` uses.
- context/code/clients.md and the evidence entry reverse the recorded
"different sentences on purpose" rationale, which was false about the TUI.
- harness/tests/drill.spec.js follows the new wording.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
ChatGPT Codex on PR #161, and the finding was correct. The structural
guard counted `requested(name).is_some()` as "answered", but `chord`
maps `Step::Request(_)` to `None` — so a future prefix binding for
`save` would have PASSED the guard while the request was discarded at
runtime. That is the exact shape improvise-lut7 was filed about: a green
derivation over a broken thing.
The guard now refuses a request-backed prefix name. `prefixed` is split
so the binding step is `chord(state, event, Option<&Binding>)`, which
lets a test drive a binding the shared table does not contain — the
regression the review asked for. It shows the outcome is the disarm
ALONE, and asserts `requested("save").is_some()` first so the guard's
refusal clause is known to be able to fire.
Two faults injected. Binding `save` behind `g` in `default_keymaps` —
the scenario the review described — fails the guard, naming `save`. And
making `chord` return `KeyOutcome::Request(r)` (the review's other
suggested option) fails the new test, because the disarm is then lost:
that is the evidence the alternative trades one stranding for another.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
`render_model` built `editing` as
`is_editing().then(|| buffers.get("edit").cloned().unwrap_or_default())`,
so an `AppMode::Editing` that arrived without its `SetBuffer` painted
`Some("")` — a blank editor drawn over a cell that still holds a value.
The page opens an `<input>` seeded from that string, so the next Enter
commits the empty string and the value is gone. An ABSENT buffer and an
EMPTY one are two different facts, and `unwrap_or_default` collapsed them.
Reachable because the pair is two `Command`s rather than one: this
client's own resolver emits `SetBuffer` then `ChangeMode`, and a
`Snapshot` or a host batch can carry the mode without the buffer.
The fallback is `improvise_protocol::edit_seed`, which is the EXISTING
home of "what an editor opened here starts with" — `seed_at` at the
cursor, the function `enter-advance` already seeds from. A second
reconciliation of the scroll offset inside `render.rs` would have been
gotcha 42's own trap, so this costs one `pub` rather than a copy.
Two tests, and the second is the one that matters for the shape of the
fix: an empty buffer is a state the user typed their way into with
backspace, so refilling it would make the last backspace un-doable. The
over-broad repair — "empty or absent, refill" — fails that test alone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
`resolve` answered `Binding::Prefix` with `KeyOutcome::Upstream`
unconditionally, and `Host::handle` answers `ClientFrame::Key` with an
explicit empty vec (gotcha 47) — so `g`, `y` and `Z` were black holes and
everything behind them was dead. `grid_move`'s `GridMove::ToStart(true)`
arm was correct and unreachable, so a browser user could reach the last
row and never get back to the first.
Arming is now a `Command::SetTransientPrefix`, which was already on the
wire and already folded by `reduce_view`; nothing composed it. The second
key is resolved in the sub-keymap through `Keymaps::prefix_keymap`,
mirroring `App::dispatch_key` line for line — including that the prefix
is spent whether or not the second key matched, because leaving it armed
strands every key after it in a sub-keymap the user has forgotten about.
Every outcome of a chord therefore carries the disarm, so the second key
can never be `Upstream`: there would be nowhere to put it. The two names
that reach no composition, `wq` and `toggle-col-group-under-cursor`, are
answered by `declined` instead (improvise-rmvx: silence was worse), and
`every_name_bound_behind_a_prefix_is_answered_by_this_client` makes that
structural — a future prefix-bound name nothing answers fails the build.
`p` could never do anything because nothing could put a value in
`ViewState::yanked` (improvise-lut7) — a *state* reason no name-level
derivation can see, which is why paste appeared on neither the declined
list nor the host-needed one. `yank` now composes, off the `edit_text`
the host already projects, which is the value `YankCell` evaluates.
The three prefixes leave `the_command_names_that_still_need_the_host_are_these`.
Not predicted by either ticket: `tests/lockstep.rs` — the instrument for
"host and client agree on a keypress" — passed unchanged through all of
this, because no chord had ever been driven through it. Every key it
pressed was a single key. `the_g_chord_returns_to_the_first_row_on_both_ends`
closes that gap and compares both ends after *each* press.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
`view-forward` was bound in NO keymap, in either front end, from the day it was
written. The TUI reached it as `:view-forward`; 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, 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 reading as `>`'s partner would deepen 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.
IT IS DELIBERATELY NOT A CTRL- KEY. Ctrl-R, the universal "forward through a
history" spelling, is browser reload. The keymap is SHARED between the two front
ends, so a key chosen for the terminal is one the browser must live with, and
improvise-clso is already open for exactly this against Ctrl-S.
The absence assertion PR #117 placed in
`the_forward_step_mirrors_its_cmd_and_no_key_reaches_it` is INVERTED rather than
deleted: renamed `..._and_one_key_reaches_it`, it now asserts the binding exists,
keeps `view-back` as the control, and pins the key itself so the binding cannot
drift while the presence check stays happy.
Reasoning: context/code/tui.md.
Evidence: context/test-evidence/{command,clients}.md.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH