9aa3581c2d85b5bf6b9d076333246edddfd67081
29 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
0ed2e41c32 |
Merge gh/main into fix/6p8q-records-yank-paste
The TUI's records yank now reads records_value unfiltered, the value the browser yanks since improvise-dsxq; the blank case is filed as improvise-s1lx. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze |
||
|
|
5ea122b3ea |
improvise-6p8q/l4mp: context reasoning and test evidence
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze |
||
|
|
8aaddeea1d |
WIP improvise-6p8q: records yank/paste lockstep
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze |
||
|
|
351a0adc5d |
fmt
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze |
||
|
|
772c695d3e |
WIP: lockstep yy/p cases where edit text and value disagree (failing)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze |
||
|
|
6c46502f52 |
Resolve prefix chords in the browser (improvise-hs0u, improvise-lut7)
`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 |
||
|
|
253b84b2da |
fix(protocol): a refused frame is put back by the fold that made it (improvise-nxah)
`Client::key` folds a locally-resolved batch before sending it, so the page redraws on the keystroke. A refusal left those folds in place: measured against a real `Host`, a refused commit left the client one row down with its edit buffer cleared while the host's mirror of that same client was still at the origin holding the user's typing. What rolls back is `ViewState` — the thing the fold produces — never a cache. A `Command::Model` is a no-op in `reduce_view`, so the optimistic fold never touched the caches, and the bug report's own evidence (`page_coords`) cannot diverge this way: it moves only under `Projection::Grid`. What diverges is the mode, cursor, status and buffers that ride along with the model command. The rollback needed a positive acknowledgement first, which the protocol did not have: with only `Rejected` on the wire, a client holding two frames in flight cannot tell which one an answer addresses. `ServerFrame::Accepted` closes that — emitted once per accepted mutating frame, last, after everything the frame produced — the same shape as the basis-publication gap ADR 001 originally left. Two existing facts make the rule usable rather than a convention: only `Command::Model` can be refused, so a frame carrying none is answered by neither and needs no entry; and `Rejection::refused_an_edit` already separates the two `Save` refusals, so a failed write cannot consume the answer owed to an edit. A refusal drops one frame and replays the rest, because a single saved snapshot is unsound under pipelining — it would discard a later frame the host applied. This does not do improvise-3al1's force, and it does not contradict it: the 2026-09-21 wave recorded the two tickets as contradictory on the ground that a rollback would wipe the staged typing. It does the opposite — the typing frames carry no model command, are never refused, and have already settled, so rolling back the commit restores the buffer to the typing. Asserted, not inferred. Reasoning: context/code/clients.md, context/code/protocol.md §4. Evidence: context/test-evidence/clients.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
d0ec6a44a1 |
fix: a new view takes an unused name, derived once for both front ends
improvise-vw47. Create View 2 and View 3, configure View 3, delete View 2,
then ask for a new view: both resolvers spelled `format!("View {}", len + 1)`,
which is the count and not a free name. `Workbook::create_view` replaces
whatever holds the name, so View 3's format reset to defaults and no view was
added -- a symptom that looks like nothing happened.
The name is derived at two sites, `cmd/panel.rs::CreateAndSwitchView` and
`resolve.rs`'s "create-and-switch-view" arm, so fixing one would have left the
TUI and the browser naming different views. `view::unused_view_name` in
improvise-core is the one derivation both now call. It takes an iterator of
names because the browser holds no `Workbook`, only the `ViewListCache` that
`view_summaries` maps one-for-one off `workbook.views`.
`Workbook::create_view` is deliberately untouched. Its replacement semantics is
what the `_Records`/`_Drill` rebuilders need -- both reach it through
`effect::CreateView` on every entry -- so refusing or renaming there would turn
"a view was reset" into "the key does nothing". The argument is in
`context/code/core.md` section 11.
Three tests, written failing first. The helper's own cases pin that a run of
taken names is searched rather than bumped once. The `Cmd` test uses the
ticket's fixture and asserts the count is below the highest name before asking
anything, because the pre-existing sibling test's fixtures all leave the
derived name free -- which is how the bug lived under it. `lockstep.rs`
witnesses the agreement: note that `both_ends` PASSES against the unfixed
source, both ends being wrong identically, so that test carries the count and
format assertions too.
Rivals injected: fixing panel.rs alone fails lockstep on the document text;
starting the search at 1 fails the pre-existing incremental-naming test.
Baseline 1667 passed / 0 failed / 7 ignored over 37 result lines; now 1670.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
|
||
|
|
aa86a48af1 |
review: the "grid, not mode" clause was argued and unchecked
Adversarial review of
|
||
|
|
6b28e58352 |
Give the browser a key that reaches the view history (improvise-cfly)
PR #115 put `Command::ViewBack`/`ViewForward` in the view half, made `reduce_view` fold all three view effects, and gave every client its own `view_back_stack`. `resolve.rs` was owned by another agent that wave, so `declined()` still refused `view-back`: the merged wave held the capability and no key, and `<` in a browser reported a feature that was fully supported. Delete the decline together with its comment, whose stated reason ("it commits a drill first, so composing it would commit another client's typing") was false by the time it shipped. `ApplyAndClearDrill` is off the wire, so a resolver cannot compose it; and no browser path stages a drill at all — `drill-into-cell` is declined and `edit_or_drill`'s `Aggregate` arm answers out loud. The divergence from the `Cmd` is confined to a state this client cannot enter. The asymmetry that IS real is a different one: `reduce_view`'s `stepped` is silent on an empty stack where `ViewNavigate` emits "No previous view" / "No forward view". `step_history` mirrors both arms, so a client that has nowhere to go hears the same sentence the TUI does. Found on the way and not predicted by the ticket: `view-forward` is bound in NO keymap, in either front end. The arm is added and tested by name, and the test asserts the absence of a binding (with `view-back`'s presence as the control) so that adding one has to come there and say so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
81fe546594 |
Settle the mode by observing the grid, not at each composition site
ChatGPT Codex's P1 on #98: switch_view_at composes [SwitchView(target), ChangeMode(Normal)], and changed_mode settled that against the grid the client STILL HOLDS -- the outgoing view's. Records -> pivot emitted RecordsNormal and pivot -> records emitted Normal, which is the cross-view drift this PR exists to close, by a second door. The hand-back predicted this as follow-up 3 and called it unreachable because R is a stranding decline in the browser. That is right about the TOGGLE route and wrong about this one: records-ness is auto-detected from the axes (gotcha 1), so a view switch changes it with nothing in the batch saying "records". One door was enumerated and there were two. So the remedy is general rather than a patch to switch_view_at. changed_mode is DELETED; every site emits the plain AppMode::Normal; and ClientState::fold settles after every fold against the grid it HOLDS. Route-independent, so no enumeration of composition sites is load-bearing any more -- the same shape the repo already prefers in cursor_epoch over matching on CycleAxis, document_generation over matching on LoadModel, and the dirty flag over a table of command variants. improvise_protocol::settled_mode is the one rule and all three ends call it: ClientState::fold, ClientSession::fold (the server's mirror, so the two cannot diverge in a field nobody currently reads today) and App::settle_normal_mode. App::normal_mode is gone -- it was that function's host-side duplicate, and my change orphaned it. Bidirectional coverage drives a real Host: a session connected to a records-mode host, the pair folded locally and sent as a ClientFrame::Commands, every reply delivered in arrival order. The composed ChangeMode carries the STALE answer deliberately, because composing the friendly value would let the records -> pivot direction pass under the fault. Both directions fail with settle_mode no-opped, each proved separately since the loop stops at the first. Evidence, the injections and why the first fix's unreachability argument was too narrow: context/test-evidence/clients.md, context/code/protocol.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
618801701e |
The browser asks the grid what kind of grid it is (improvise-e5pf)
One substitution at three decision points: wherever the host asks layout.is_records_mode(), resolve.rs asked state.view.mode.is_records(). The two agree only while the mode and the layout agree, and Command::ChangeMode(AppMode::Normal) is the message that separates them -- the host settles Normal against the layout the batch ends on, reduce_view folds the variant it was handed. GridCache::records carries the fact, projected by grid_of off layout.is_records_mode() and diffed like the extent. Deriving it on the client from row_frame.cats would have been a second implementation of is_records_mode (improvise-edp's disease); deriving it from the mode is the bug. Like Extent it frames nothing, so apply does not re-fit and its batch position is not load-bearing. The client's settling has one home, resolve::changed_mode, reached by every site that can emit AppMode::Normal -- including entered(), which is how a bound `enter-mode normal` gets there. The three decision points ask state.grid.records, so a commit at a records cell declines rather than composing typed_cell_command against a grid whose columns are [coords.., +cat, measures.., +]. lockstep now DRIVES the keys: in_lockstep presses on both ends and refreshes the client from a real projection rather than from the host's slice, which is what let the old records coverage start from agreement. The reachable path is Tab (category panel) then Esc; c/f/v are Ignored in RecordsNormal and R is a stranding decline, which a first draft of the test found the hard way. Two resolve.rs fixtures put a records mode over a non-records grid -- a state no host can produce -- and had to be corrected with the fix. Evidence and both injected faults: context/test-evidence/clients.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
1dd6950ed3 |
A view-scoped ModelCommand names its view (improvise-lltt)
Nine ModelCommand variants configure a view and named it implicitly: the effect read App::view_mut(), the HOST's slice. Since improvise-jytm made ViewState::active_view authoritative per client, a client on another view moved the host's view instead of its own -- its grid did not change and every other client's did. Each of the nine now carries `view: String`; model_effects maps it to effect::ViewTarget::Named, and the nine effects carry a ViewTarget rather than reading an ambient one. There is no operandless form left: App::view_mut() is deleted and replaced by view_mut_of(&ViewTarget), so a future geometry effect has to say which view it writes. The host's key path passes ViewTarget::Active, which is right -- the TUI is the host and has no session to ask. A Named view the document has not got is a no-op rather than resolve_view's fallback onto the default. Second half, which the ticket did not predict: naming the view made a reframe of a view the host is NOT on reachable, and adopt_cursor_epoch could not see one -- cursor_epoch is bumped only by the host's own reset_cursor, so those clients were reset by nobody and told nothing (gotcha 24's strictly-worse-than-nothing state). App::last_reframed_elsewhere is the second trigger; its opening frames are taken lazily inside view_mut_of, the one door such a write goes through, so the held-key path still hashes one view and improvise-mcq4's declined cost is not paid. Evidence and the three injected faults: context/test-evidence/command.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
e3fcdc8bc6 |
test(lockstep): take the fixtures' basis from the server, not from a recipe (improvise-1l9o)
furnished_client and client_ready_to_edit built their BasisCache by walking the client's own grid for Record cells and calling basis::published(0, 0, records) with every version hard-coded to zero -- a small re-implementation of project.rs::basis_update's record selection, in a file whose entire purpose is to rule out re-implementations. Both now take the Projection::Basis out of a real Sessions::connect snapshot. The reason the ticket calls it a nit is the reason it needed a test: a fresh App really is at generation 0, layout 0 and every record at 0, so the fabrication and the publication are indistinguishable on every existing fixture -- all 23 lockstep tests pass under either. So the change alone is structural and untested by construction. the_fixtures_basis_tracks_a_host_whose_layout_has_moved moves a tier first and then asserts the fixture's claim names the host's current tiers; re-fabricating the two document tiers fails it (0,0) against (0,1) and fails nothing else. Worth recording: SetNumberFormat was the first attempt and does not move layout_version, although it declares Model(LayoutGeometry). layout_version is OBSERVED from view_shapes rather than read off the declaration (improvise-pdvm), and a number format is not part of a view's shape hash. Classification is not a promise about the counter. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH |
||
|
|
e89b6467e1 |
Derive the AppMode lists from AppMode, and close minibuffer()'s wildcard
improvise-e1z5 (half) and improvise-g9jb.
`AppMode::minibuffer()` ended in `_ => None` where its sibling `panel()`
is exhaustive, so a text-entry mode could be added and be invisible to
`EnterMode`'s clear and to the `TextEntry` builder. It is now exhaustive.
`AppMode::all()` and `AppMode::text_entry()` are the one enumeration.
`all()` walks a total, cyclic `after()` rather than listing: with no
`None` arm, the only way to answer for a new variant is to name another,
which is what splices it into the walk. `text_entry()` filters `all()`
by `minibuffer().is_some()`, so the set is derived from the thing that
makes a mode text-entry rather than restated.
Four hand-written lists removed, enumerated by grep before the claim:
1. protocol/src/help.rs::all_modes() 17 removed
2. command/src/ui/app.rs::tests::all_modes() 16 removed — it was
missing SavePrompt, latently
3. protocol/src/resolve.rs::tests::text_entry_modes() 8 removed
5. wasm-client/tests/lockstep.rs::text_entry_modes() 7 removed — also
missing SavePrompt, latently
One list survives and is not a copy: command/src/command/keymap.rs's
`text_entry_modes_with_exits` carries commit/leave KEYS, which are not
derivable from AppMode. Its mode set now comes from `AppMode::text_entry()`
and only the keys are stated, in a match with no wildcard.
e1z5's second half needs no change: `strands_the_user` was deleted by
improvise-rmvx and replaced by name-driven `declined`. Only a stale doc
reference to it survives, at resolve.rs:1948.
Verified by injecting a throwaway `AppMode` variant: the build fails at
lib.rs:220 (`minibuffer`) and lib.rs:266 (`after`), neither of which
could have failed before.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
|
||
|
|
d1ce2ecbc3 |
fix(web): an advertised key is never silent, a formula is visible, and +cat commits
improvise-rmvx, improvise-fdt6, improvise-gcvb — three faces of the same
thing: a browser offering something and then doing nothing.
rmvx. `the_command_names_that_still_need_the_host_are_these` was built with
`in_mode(AppMode::Normal)`, so it only ever asked about Normal-mode bindings:
`add-record-row` (RecordsNormal) and `delete-formula-at-cursor` (FormulaPanel)
were outside its reach entirely and could never have failed it. A named list
that silently scopes itself to one mode is a guard that looks broader than it
is. `every_advertised_command_answers_in_its_own_mode` crosses
`help::advertised` — command NAMES, so no hint string is tokenised — against
`resolve` in every mode, over a fixture furnished enough that an `Upstream` can
only mean an unhandled name. It found a seventh instance the ticket had not
predicted: `view-back` is advertised in RecordsNormal.
Six names now decline out loud, each with its own true subject, out of one
`declined` table; `stranding` returns the subject rather than a bool, so `:`
says the command line is unavailable instead of doing nothing. A declining step
takes its whole `Binding::Sequence` with it, which is what keeps `o` ("add a
record row, then edit it") from opening an editor on a row nobody created.
fdt6. `commit-buffer formula` already resolved; what did not exist was any way
to SEE the typing. `RenderModel::editing` is scoped to the two cell-editing
modes and reads the "edit" buffer by name, so FormulaEdit, CategoryAdd, ItemAdd
and the three prompts opened and swallowed every character. `RenderModel::
minibuffer` is `AppMode::minibuffer()` plus the buffer that config names, read
through `improvise_protocol::read_buffer` so gotcha 11 keeps its one home, and
`web/app.js` paints it.
gcvb. The records-mode early return was broader than its reason. A
`CellId::Create` commits the typed NAME as one existing `ModelCommand`, reads
no `record_col` and writes no cell, so the return narrows rather than
disappears. `+cat` must NOT advance: an inserted column renumbers every index
to its right and the advance was computed from the pre-commit layout, so a
client that advanced anyway would land one column off, silently. Both halves
proved by injection against `CommitAndAdvance` in `tests/lockstep.rs`.
Every new guard was proved by injecting the fault it names. The one worth
repeating: `web/tests/render_surface.test.js` went green over a page that
painted nothing, because the string `model.minibuffer` was inside a function
nothing called. The static check is a completeness check, never an execution
check — `harness/tests/keys.spec.js` caught it in Chromium on the first run.
1394 → 1397 Rust tests; harness 17 → 19 passed, 3 skipped; node 26.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
|
||
|
|
850ddcf0eb |
Merge gh/main (#48 active_view, #50 shared front end) into wave/b-projection-gaps
Eight conflicts, all resolved keeping both sides. The three in context/ were append-vs-append. The five in code were the restructure #50 declared in advance: - render.rs: render_model became a free fn over &ClientState and the mode label moved to improvise_protocol::mode_label. Took that shape and re-added the three doc_* fields. - state.rs -> improvise-protocol/src/client.rs: only the import path conflicted. The file now lives IN improvise-protocol, so `crate::` is right, plus DocumentCache. - keys.rs went 1387 -> 187 lines because resolve/step moved to improvise-protocol/src/resolve.rs. Took gh/main's slim file and ported every addition into resolve.rs: the Derived and Create arms of edit_or_drill, the or-patterns in record_at_cursor and record_cell_at_cursor, page_cycle with its Odometer, the two bindings, and `[`/`]` leaving the still-needs-the-host list. - project.rs: kept the value-based numeric/error from improvise-tom1 with gh/main's cell_id(app, layout, ..) signature; kept BOTH document_update and view_in; kept our title-bar projection inside their per-view projection set. - lockstep.rs: both sides appended tests and the hunks interleaved mid-function, so took gh/main's file and re-appended our page-turn block whole. THE CLEAN MERGE WAS THE DANGEROUS ONE, exactly as warned. `cargo check --workspace --all-targets` passed with three of our tests SILENTLY GONE — they lived in keys.rs's test module, which moved wholesale. i_on_a_derived_cell_names_ the_formula, the_page_keys_turn_the_odometer_as_the_command_does and a_filter_axis_is_projected_but_not_cycled are ported into resolve.rs, with client_on now returning a ClientState rather than a Client. One assertion changed meaning under us and is corrected: lockstep's page_selections read workbook.active_view(), which since improvise-jytm is the document's DEFAULT. SetPageSelection writes App::view(), the client's. They coincide in a one-view fixture, so it was passing by fixture luck; it now reads App::view() and still fails under the injection. All four injections re-run on the merged code and all four still discriminate: reversed odometer, dropped records formula_for lookup, title bar gated on Dirty, and filtering_cats leaking into the protocol. 1348 passing, clippy clean, fmt clean, all eight wasm crates clean, both comment-run checks clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
fc4035dbca |
Merge remote-tracking branch 'gh/main' into wave/h-shared-frontend
# Conflicts: # crates/improvise-command/src/ui/reduce.rs |
||
|
|
f66c4aff04 |
fix(protocol): records_named has one home, and the lockstep sees every cursor
Findings 1 and 2 of the adversarial review of PR #50, both demonstrated by injection before being fixed. **F1.** `resolve.rs`'s `structural`/`record_write` were a second derivation of `reduce.rs::records_named`, and nothing tested them: `lockstep.rs`'s `apply_on` restates the basis against its twin with `caught_up`, which re-derives it with the SERVER's answer and throws the client's claim away before anything is compared. Emptying `record_write`'s claim left the workspace green. Fixed by collapsing rather than testing, which is what this unit argues for everywhere else. `records_named` is pure and `RecordHash` is already re-exported by the protocol, so it moved DOWN into `improvise-protocol` beside the enum it reads; `improvise-command` re-exports it; `resolve.rs` has one `model()` that claims `records_named(&cmd)`. The claim is now right by construction and the guard is the exhaustive match that was always there. Verified by emptying the shared function's `SetCell`/`ClearCell` arm: five server-side tests fail, where the client's copy had none. What the old shape cost, which was not a compile error: `structural` accepted any variant and claimed `[]`, so a new record-writing command compiled clean on the client with the wrong answer and the symptom was a dead browser key, refused as `RecordUnstated`, with nothing failing. **F2.** Three resolver divergences the suite could not see, all now failing: `delete_view_at_cursor`'s cursor step-back (its only decision — the test pressed `n` and `Enter` while its doc comment said "create, switch and delete"), and both Formula arms of `CommitKind`, Formula being the only kind whose `on_commit()` is `Some`. `both_ends` now compares every cursor, not only the document, mode and status. A fourth injection — `cycle_panel_focus`'s `start` index — still survived, because `PanelKeymap` binds Tab to `enter-mode normal` inside a panel so no key reaches those arms; they are now driven directly by `panel_focus_cycles_from_the_focused_panel_not_the_first`. Widening the commit test found a real divergence, pinned rather than fixed as improvise-cl7y: the client folds `CommitBuffer`'s optimistic "Formula added" and cannot parse, so a browser reports a formula it did not add. `a_formula_the_client_cannot_parse_diverges_on_the_status_line` asserts both sides, so a fix has to come there and change it. F3 filed as improvise-thpe and deliberately not fixed here. `apply_on`'s doc comment pointed at an evidence entry that did not exist and now names `context/code/clients.md`, which has the argument. 1324 tests pass, up from 1322. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
414d02c4a6 |
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>
|
||
|
|
8b3de934fc |
feat(protocol): project the page-axis item lists so [ and ] work in a browser
improvise-p1ym. GridCache::page_coords carried (category, selected item), so PageNext/PagePrev resolved to a command name the browser held but could not compose an argument for. They reached Host::handle, which answers an empty vec, so a smoke test found them inert on the live page. The item list is not a second field beside the selection: page_coords becomes Vec<PageAxis>, and `items` is Some exactly when the axis is Axis::Page. That puts gotcha 27's split in the type — a Filter axis constrains without being navigable, so it carries no list and no caller can compose a cycle for one. GridLayout::page_coords stays filtering_cats(), which is right for the bar, and navigation.rs::page_cat_data stops spelling the Page-vs-Filter rule a second time: it reads the layout's list. The browser's page_cycle mirrors PageNext/PagePrev line for line, odometer carry included, and `[`/`]` leave the still-needs-the-host list. Two decisions stated in context/code/core.md. The list is effective_item_names — the same call that already chose the default selection, so selection and list come from one place; page_cat_data used ordered_item_names before, which for a _Measure page axis let the default selection be a formula target `[`/`]` could not reach. And the list is NOT filtered to visible items: the TUI cycles hidden ones, and making the browser differ would be the divergence rather than the fix. The lockstep test's first version was vacuous — it rebuilt the client's cache from the host and compared it against that same host. It now drives two independent documents and compares View::page_selection; the injection that reverses the odometer's significance now fails it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
555d9f1ca7 |
feat(protocol): fifteen more bindings resolve on the client (improvise-e24v)
`cycle-panel-focus`; the category panel's `cycle-axis-at-cursor`, `open-item-add-at-cursor`, `filter-to-item`, `toggle-cat-expand` and `delete-category-at-cursor`; the view panel's `switch-view-at-cursor`, `create-and-switch-view` and `delete-view-at-cursor`; tile select's `set-axis-for-tile` and `cycle-axis-for-tile`; the help overlay's `help-page-next`/`help-page-prev`; `commit-buffer` in all three kinds; and the two cell writes ADR 002 lets a thin client name, `clear-cell` and `paste`. Each mirrors its `Cmd` line for line, status sentence included — a client that emitted the mutation without the sentence beside it would diverge on the status line and nowhere else. Two orderings are copied rather than rationalised: `CommitBuffer` writes its optimistic status first (improvise-6t3) and `TileAxisOp` writes its axis first. **A divergence found and fixed on the way.** `effect::HelpPageNext` clamps at the last page and `HelpPagePrev` saturates at zero, while `reduce_view`'s `Command::HelpPage` arm *wrapped*. Two derivations of one fact, disagreeing, invisible because `h`/`l` in the help overlay had always been upstream keys and nothing had ever sent the command. `walking_off_the_end_of_the_help_pages_ agrees_with_the_host` was written first and failed — client 0, host 4 — and the wire was then changed to match the effect, since the effect is what ships in the TUI. Whether both should wrap instead is improvise-trd3. **Three names the ticket listed do need enrichment**, filed as improvise-4bwa: `toggle-prune-empty` words its status off `View::prune_empty`, which is in no cache; `delete-formula-at-cursor` needs `(target, target_category)` and `FormulaCache` carries only the raw text; and `view-back` emits `ApplyAndClearDrill`, which is deliberately off the wire (gotcha 38) and is blocked on ADR 001 layer 2 rather than on a projection. The lockstep harness is new and is the evidence: it applies what the client composed to a **second authoritative host** and compares the documents as `.improv` text (`Debug` prints the symbol table's `HashMap`s, so it differs per process — gotcha 18). Four injected faults — a wrong axis, a wrong status sentence, a constant view index, and `AddCategory` for `AddItem` — each fail a named test; the second one did *not* until the fixture was made to sit on a category header as well as an item row. `the_command_names_that_still_need_the_host_are_these` shrinks by exactly what closed. 1318 tests pass, up from 1311. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
5b7b53466f |
feat(active-view): split the document's default from each client's view
improvise-jytm, improvise-6c6q, improvise-k69y. `Workbook::active_view` becomes the document's *default* — what a freshly connected client opens and what `.improv` writes as `Initial View` — and `ViewState::active_view` becomes authoritative per client. Two clients may sit on different views of one document: the model is shared, the UI is not. - `App::switch_to_view` writes only the client's slice; `App::view()`/ `view_mut()`/`view_name()` resolve it against the workbook. - `effect::SwitchView`/`ViewBack`/`ViewForward` are `ViewOnly`, which takes `ViewBack`/`ViewForward` off `ModelCommand` — their operand is one client's history stack (the `ApplyAndClearDrill` argument). - `grid_in`/`category_tree_in`/`tile_bar_in` project against the session's view; `ClientSession` observes its own move through `projected_view`. - `apply_switch` takes a `SessionId`, moves only the asker's mirror, and relays `Broadcast::ToAsker` only when the document refused. `adopt_active_view` and `Broadcast::ToOthers` are deleted: nothing produces them any more. - `draw_status` reads the client's copy (improvise-k69y). `the_two_active_view_names_never_diverge` is replaced, not deleted, by `the_documents_default_view_outlives_this_clients_switches` and `a_fresh_session_opens_the_documents_default_view`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
8ed3df8ce3 |
docs(wasm-client): move the fold, render and lockstep arguments to context/
lib.rs's crate statement, socket.rs's frame/wording/composition arguments (including the "obligation stated but not discharged" note on `typed`), render.rs's four silent-if-wrong decisions and state.rs's two dispatch properties are now prose in context/code/clients.md. The test narratives from both integration tests — the visual-vs-data row confusion Copilot found on PR #23, the multi-command frame improvise-955n had to keep accepting, and the lockstep fixtures' deliberate shapes — are in context/test-evidence/clients.md. No behaviour change: comments only. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
301a74821a |
feat(protocol): the grid's frame on the wire, as AxisFrame
`GridCache::row_labels`/`col_labels` were `Vec<String>` built by `GridLayout::row_label`, which slash-joins a multi-level tuple — the exact rendering `two_row_categories_produce_cross_product_labels` asserts must not appear. So the protocol carried a form of the labels the TUI itself considered wrong, and `improvise-wasm-client/src/render.rs` drew precisely that form: the browser showed `Food/Alice` where the TUI showed two header columns, with no groups and no collapse indicators (gotcha 44). Both fields become one `AxisFrame` per axis, carrying the five facts gotcha 44 enumerates: the categories (hence the header levels), the label *tuples* and the inline group headers, the group a position sits in, and each group's collapse state. `record_col` stays off — session-local, keys pending drill edits, correct to keep off. It is one type and not a `row_levels` beside `row_labels`, because two answers to "what are the labels" is the disease (improvise-edp). The rules that used to be written twice are methods on it and are now called from both front ends: `shows_level` (repeat suppression, improvise-jjx's fix), `group_of`, `body_from`, and `header_widths` — the row-header width rule, which existed twice already, inline in `render_grid` and again in `viewport::compute_row_header_width` (improvise-1125). `GridWidget` loses its `&View`: nothing in it reads the view any more. Evidence. `the_whole_frame_is_unchanged` — the exact-buffer characterization over a grid with row groups, column groups, a page axis, multi-level headers and a total row — passes byte for byte, so the TUI is unchanged. The new `tests/grid_frame_agreement.rs` drives the widget into a ratatui buffer and the browser client through a `Snapshot` of the *same* cache, and compares what each draws; re-introducing the slash-join on either side was injected and watched to fail before the test was kept. `tests/web_demo_model.rs::the_web_demo_has_one_category_per_visible_axis` is deleted: it guarded the gap this closes, and a consolidation that keeps its compatibility test is unfinished. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e13635638c |
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 |
||
|
|
b1f8b1563c |
feat(wasm-client): the client resolves its own cursors (improvise-bpq2)
The user's ruling is that keys become commands in the browser and the keymap is a client-only concept. What made that urgent is not latency: improvise-9x6's `a_key_resolved_on_the_host_moves_state_no_client_can_hear` shows nothing in the projection stream carries the host's own `ViewState`, so host-side resolution **diverges** — and today it does not even do that, because `Host::handle` answers `ClientFrame::Key` with an empty vec. An upstream key is a black hole, not a slow path. Every key moved here is a key that previously did nothing at all in a browser. Three cursors, walked out of `KeymapSet::default_keymaps()` binding by binding rather than command by command: - **Grid** (hjkl, arrows, 0/$/Home/End, G, PageUp/Down, Ctrl-d/u). Needed the projected `GridExtent` to clamp against, plus the window — which is the client's own fact, since it is what the client told the host in its `ClientFrame::Viewport`. `Client::set_viewport` records it; `Client::viewport` stays, works, and is documented as the one that quietly turns this off. - **Panel** (j/k/arrows in all three panels). Needed **nothing**: the ticket lists the category flattening as a gap because re-deriving it is how two ends drift, but improvise-edp already made `viewmodel::flatten_cat_tree` the one home both ends call. It is the shared derivation, not a second one. - **Tile** (h/l/arrows). Needed nothing. Each mirrors its `Cmd` line for line, including where they disagree about a move that goes nowhere — `Move` and `MoveTileCursor` emit anyway, `MovePanelCursor` does not. Unifying that would be a second derivation, which is the thing lockstep testing exists to rule out. The modifier rule is narrowed to what it was always about: `AnyChar` matches under any modifier, but it is bound **only in text-entry modes**, so `Ctrl-d` in Normal has an exact binding and mirroring it is a subset. The old blanket rule stays where it was earned. Evidence: five new lockstep tests drive the client and a real `reduce_key` over the same keypresses and compare `ViewState`, with the client's cache built by the server's own `grid_of`. Three faults injected — clamping against `row_labels.len()`, dropping the scroll offset, counting categories instead of tree rows — and all three fire. `nothing_in_a_non_text_mode_resolves_locally` became false and is replaced by a named list of the Normal-mode keys that still need the host, so closing one has to come here and say so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH |
||
|
|
e77176364c |
refactor(protocol): one home for the client/server frame envelopes
Three envelope vocabularies for two deployments become one pair in
`improvise-protocol::frame`: `ClientFrame` / `ServerFrame`.
The question the ticket said to answer first was whether the WORKER and
the SOCKET want the same vocabulary. They do, and each of the three
differences is a date rather than a decision:
- `Viewport` is deployment-independent. `improvise-ws-server::wire`'s own
argument for it never mentions a socket: the extent is not in
`ViewState`, no effect produces it, so no derivation reaches it, and a
worker hosting the same `Sessions` owes `set_extent` the same answer.
It is the handshake for the same reason on both — a `Snapshot` seeds
the VISIBLE cells.
- `Key` is deployment-independent too. A binding names an intent resolved
against a `CmdContext` a thin client does not hold (gotcha 38); that
says nothing about what the bytes travel over. The socket lacked the
variant because key forwarding was not built on it (improvise-bpq2).
- `Commands(Vec<_>)` batches and a singleton `Command(_)` cannot. The
fan-out must arrive whole and in sequence because a `GridUpdate::Cells`
that overtakes its labels is DROPPED (gotcha 33) — a property of the
fold, not of the transport. An in-process `postMessage` stream can
interleave exactly as a socket can.
The worker pair was also incoherent by omission: written before ADR 001,
it can say `Command::Model { basis, .. }` but has no `Rejected`, so a
worker speaking it would let a client compose a claim it could never be
answered on.
The two mirror tests are deleted, which is the measure of success: with
one definition `improvise_ws_server::ClientFrame` and
`improvise_wasm_client`'s are the SAME TYPE, so a rename or an added
variant is a compile error on both sides by construction rather than a
round trip somebody has to remember to extend by hand. What they were
blind to (a variant added on the server and never mirrored) cannot be
expressed. `tests/browser_round_trip.rs` remains and is now the stronger
statement: the wasm client's serialized frame is handed to a real
`Host::handle`.
`a_frame_named_the_other_envelopes_way_does_not_parse` moves to
`frame.rs` as `the_retired_spellings_do_not_parse`, keeping its
injected-fault discipline: the retired `Bootstrap`/`Command` spellings
must not come back as aliases.
`Host::handle` gains an explicit `ClientFrame::Key => Vec::new()` arm.
Behaviour is unchanged — the frame used to fail to parse and be skipped
by `next_client_msg` — but the gap is now a line of code with
improvise-bpq2 on it instead of a variant that does not exist.
The wasm client's duplicated fold surface goes with the envelopes:
`on_message`/`handle_message`/`apply` were `on_frame`/`handle_frame`/
`fold_frame` without the `Rejected` arm. `web/app.js` calls neither.
1132 passing, 0 failed (1134 before: 5 tests deleted, 3 added).
wasm32-unknown-unknown clean for all six bundle crates.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
|
||
|
|
30c4bb6629 |
feat(wasm-client): the browser-side fold and key classifier (improvise-gsw)
A new crate `improvise-wasm-client`: a `ViewState` plus one widget cache each, moved by folding the same `Command` stream the host folds, with a wasm-bindgen surface over JSON. The ticket's premise does not survive contact with the architecture (`doctrine(check_the_ticket_premise)`). It asks `on_key_event` to "resolve via the keymap and dispatch through reduce_view", but a binding names an *intent* resolved against a `CmdContext` — a `Model`, a `GridLayout`, an `App` — while a `Command` is the *resolved mutation* (gotcha 38, improvise-gxi). A thin client holds none of that, so keys travel upstream unresolved and `improvise-command` is a dev-dependency rather than a dependency: the keymap could not be used here even if the bundle could afford `App`, `Workbook`, persistence and the wizard. The one exception is minibuffer text entry, whose target buffer is `AppMode::minibuffer().buffer_key` — the same field the host's `TextEntry` builder reads (gotcha 11), so the local rule is derived from the same fact rather than copied. `tests/lockstep.rs` drives the authoritative host and the client over the same keypress in all seven text-entry modes and asserts the two `ViewState`s agree. Rejected for local resolution, with reasons in `keys.rs`: cursor movement (`GridCache` is viewport-indexed, so a client cannot clamp), panel cursors (the flattening depends on `expanded_cats`), anything modified (`AnyChar` matches under any modifier on the host), and CommandMode's Backspace (the one `TextEntry::rebind`, which leaves the mode). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH |