From d97dcdfc4637fdd9dd83a0d7ba3e8d1e136ce018 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 14 Aug 2026 13:40:24 +0200 Subject: [PATCH 01/33] docs(framing): acceptance 48 re-measured --- it is HALF implemented Ground truth before code, and the lane entry in the same commit, per the standing correction from #171 and #215 --- the one the 1b lane missed. Base `githubsucks/main` @ `72da24a`. No implementation yet. **AC48 IS NOT UNIMPLEMENTED. IT IS HALF IMPLEMENTED**, and nothing had written the halves down separately, which is how "PanelPointer drives listview rows, selection, terminal reporting and focus" could sit next to a dispatcher that does none of the first three. Landed already: click-to-focus and the terminal activation rule (`src/editor.rs:2701`); the focused-only auto-scroll clamp with a passive panel's `view_top` preserved (`:2569`, which has cited parent 48 all along); and the coalescing rules --- `Move`/`Drag` tails coalesce while press/release/context/wheel stay lossless and ordered (`pmacs-gpu/src/attach.rs:374`). Missing: listview row selection, panel selection, terminal mouse reporting, wheel replay. **AND THE REPLAY IS MOSTLY WIRING.** `apply_terminal_gesture` (`src/editor.rs:3525`) is already "the one terminal pointer path, shared by both frontend kinds" and already drives child mouse reporting, selection and scrollback; a panel terminal needs the SAME CALL, with `side_window_for` + `TerminalViewKey` + the `panel_grid_size` the dispatcher already fetches. `scroll_window` (`:3845`) is window-scoped including its cursor carry, and documents that a wheel "names the pane under the pointer and does NOT activate it". This is why a wheel-only bridge would be the wrong shape: the shared path takes every kind at once, and the wheel-only version would have to grow a second one later. **The scoping hazard, and why no ruling is needed.** `set_cursor_byte`, `begin_selection` and `clear_selection` are ACTIVE-WINDOW scoped (`src/editor_core.rs:1216`, `:4691`), so replaying naively from the panel path would move the DOCUMENT's point --- exactly what AC48's "without disturbing the document mirror" forbids. The existing activation rule already resolves it: activation runs before replay in the same dispatch, and the gestures that need the active-window API are precisely the ones that activate. The single gesture that does not --- a document panel's wheel --- needs only the window-scoped `scroll_window`. **One genuine question, opened not answered: Q#BP-R1.** Does a listview row VISIT on click, or only select? `listview.lua` binds visiting to RET/SPC (`:610`) and there is no pointer precedent anywhere in the file. Single-click-visits matches a file tree; click-selects, double-click-visits matches an editor list; AC48 says "row selection" and nothing more. This lane implements SELECTION and does not invent activation, because the difference is whether a click navigates the user's document. Gates: all nine green under `env -u TMPDIR`, log 20260814T113155Z. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 62 ++++++++++++++++++++++ docs/bottom-panel-framing.md | 99 ++++++++++++++++++++++++++++++++++++ 2 files changed, 161 insertions(+) diff --git a/docs/active-work.md b/docs/active-work.md index 6def70f..db3611c 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -270,6 +270,68 @@ hazard in a shape that looks committed. **A documented error message that never appears is worse than no documentation**, because the reader waits for a signal that is not coming. +## Panel-pointer replay (parent acceptance 48) — ACTIVE, 1b's prerequisite + +**Written with the branch's FIRST commit**, per the standing correction +from #171 and #215 — the correction the 1b lane missed, honoured here. + +- **Branch `panel-pointer-replay`**, base `githubsucks/main` @ + **`72da24a`** exactly, in worktree + `/home/jeans/Repos/personal/pmacs-panel-replay`. + **`githubsucks/panel-pointer-replay` is the authoritative tip** (the + ref, not a SHA). Recover with + `git fetch githubsucks && git checkout panel-pointer-replay`. +- **No PR yet. Checkpoint: framing revision 5 (§5a) AWAITING APPROVAL; + NO IMPLEMENTATION WRITTEN.** The first commit is the ground-truth + re-measurement, per the standing method. +- **Why this lane exists.** `PanelPointer` **replays nothing**: + `dispatch_semantic_panel_pointer` (`src/editor.rs:2674`) validates, + focuses, returns. A panel wheel is dead on both axes and so is every + gesture past focus. **GUI arc 1b is BLOCKED on this lane and rebases + onto its merge commit.** +- **No new framing document.** Acceptance 48 is already ruled in + `docs/bottom-panel-framing.md`; §5a adds ground truth to it. +- **The measurement's headline: AC48 is HALF implemented**, and nothing + had written the halves down separately. + - **DONE:** click-to-focus and the terminal activation rule + (`src/editor.rs:2701` and its `activates`); the focused-only + auto-scroll clamp with passive `view_top` preserved (`:2569`, which + already cites parent 48); the coalescing rules — `Move`/`Drag` + tails coalesce, press/release/context/wheel lossless + (`pmacs-gpu/src/attach.rs:374`). + - **MISSING:** listview row selection, panel selection, terminal + mouse reporting, wheel replay. +- **The replay is mostly WIRING; both mechanisms exist.** + `apply_terminal_gesture` (`src/editor.rs:3525`) is *"the one terminal + pointer path, shared by both frontend kinds"* and already drives + child reporting, selection and scrollback — a panel terminal needs + the same call, with `side_window_for` + `TerminalViewKey` + + `panel_grid_size` (which the dispatcher already fetches). + `scroll_window` (`:3845`) is window-scoped, cursor carry included. + **A wheel-only bridge is the wrong shape** — the shared path takes + every kind at once. +- **The scoping hazard, and why it needs no ruling.** `set_cursor_byte` + (`src/editor_core.rs:1216`), `begin_selection` (`:4691`) and + `clear_selection` are **active-window scoped**; used naively they + would move the DOCUMENT's point, which AC48 forbids. **Activation + runs before replay in the same dispatch**, and the gestures needing + that API are exactly the ones that activate. The only gesture that + does not activate — a document panel's wheel — needs only the + window-scoped `scroll_window`. +- **OPEN: Q#BP-R1** — does a listview row **visit** on click, or only + select? `listview.lua` binds visiting to RET/SPC (`:610`) and there + is no pointer precedent. **This lane implements selection and does + not invent activation.** +- **Gates:** the four `bottom_panel_*` acceptance suites plus + `PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu`. **No `--protocol`** — + `PanelPointer` and every `MouseKind` it carries already exist on the + wire. +- **Expected rebase conflict, flagged deliberately:** the `gui-stage1b` + branch inserts its own lane at this same position and corrects three + stale headers below (#239/#240 still marked OPEN, "1a is next"). + Those corrections are **left to that branch**; this lane does not + duplicate them. The conflict is a normal insertion collision. + ## `scripts/gate` TMPDIR isolation — PR #240 OPEN **Written with the branch's first commit**, per the standing correction diff --git a/docs/bottom-panel-framing.md b/docs/bottom-panel-framing.md index 35abd22..18b29e8 100644 --- a/docs/bottom-panel-framing.md +++ b/docs/bottom-panel-framing.md @@ -7,6 +7,16 @@ protocol v20, 2026-07-24. Amended by the pre-implementation dependency verification in §0.6: the folding dependency is cleared, and one geometry caller-census error is corrected.** +**Revision 5 — 2026-08-14, AWAITING APPROVAL. Adds §5a: acceptance 48 +re-measured against production at `72da24a`.** The finding is that AC48 +is **half implemented** — focus, activation, the focused-only clamp and +the coalescing rules all landed; **listview row selection, panel +selection, terminal mouse reporting and wheel replay did not**, so +`PanelPointer` reaches `dispatch_semantic_panel_pointer` and stops at +focus. No ruling in §3 or §5 changes. **One question is opened, +Q#BP-R1**, on whether a listview row visits on click; this lane +implements selection and does not answer it. + Give pmacs a **bottom panel**: a buffer displayed in a fixed-height window pinned to the bottom of the frame, resizable by dragging its divider, which feature code targets **by policy** instead of by stealing the selected window. @@ -1770,6 +1780,95 @@ cannot preserve the old panel-focused attach leak. preserving the Stage 1 unknown-value rollback assertions; the Stage 3 PR then runs the full gate suite. +## 5a. Acceptance 48 — ground truth, MEASURED at `72da24a` (2026-08-14) + +**Why this section exists.** GUI arc Stage 1b's ground-truth pass found +that `PanelPointer` **replays nothing**: `dispatch_semantic_panel_pointer` +(`src/editor.rs:2674`) validates the coord, resolves the side window, +focuses when the gesture activates, and returns `true`. Its own doc +defers replay to *"parent acceptance 48… Stage 2B-3"*. **A panel wheel +is dead today on both axes**, and so is every other panel gesture past +focus. 1b is blocked on this lane and rebases onto its merge. + +**Acceptance 48 is not unimplemented — it is HALF implemented**, and +the halves were never separated in writing. Measured clause by clause: + +| clause | status | production anchor | +|---|---|---| +| click-to-focus | **DONE** | `dispatch_semantic_panel_pointer`'s `activates` → `focus_window` (`src/editor.rs:2701`) | +| terminal panel: non-`Move` activates, hover does neither | **DONE** | the same `activates` computation, split by `is_terminal` | +| focused-only auto-scroll clamp; passive preserves `view_top` | **DONE** | `src/editor.rs:2569`, which already cites "A2A-3 / parent 48" | +| move/drag tails coalesce; press/release/context/wheel lossless and ordered | **DONE** | `pmacs-gpu/src/attach.rs:374`–`:381` — `Move` key 6, `Drag` key 7, everything else unkeyed | +| **listview row selection** | **MISSING** | — | +| **panel selection** | **MISSING** | — | +| **terminal mouse reporting** | **MISSING for panels** | the path exists: `apply_terminal_gesture` | +| **wheel moves the panel's viewport** | **MISSING** | the mechanism exists: `scroll_window` | +| without disturbing the document mirror | **the constraint on all of the above** | — | + +### The replay is mostly WIRING, and both mechanisms already exist + +**Terminals.** `apply_terminal_gesture` (`src/editor.rs:3525`) is +documented as *"The one terminal pointer path, shared by both frontend +kinds"* — TUI via crossterm, semantic frontend via +`FrontendEvent::TerminalPointer` — and it already drives child mouse +reporting, selection and scrollback. A panel terminal needs the **same +call**, not a second implementation: `side_window_for` +(`src/editor_core.rs:3146`) gives the window, `TerminalViewKey::new` +the key, and `panel_grid_size` — which the dispatcher **already +fetches** — the viewport size. **A wheel-only bridge here would be the +wrong shape**: the shared path handles every kind at once. + +**Documents.** `scroll_window` (`src/editor.rs:3845`) is window-scoped +throughout, cursor carry included, and says so: *"a wheel event names +the pane under the pointer and does NOT activate it, so the map must +come from `win_id`"*. Its cursor carry moves **that window's** point — +the panel's own, not the document mirror. + +### The scoping hazard, and why the activation rule already answers it + +**The selection and cursor API is ACTIVE-WINDOW scoped.** +`set_cursor_byte` (`src/editor_core.rs:1216`), `begin_selection` +(`:4691`) and `clear_selection` all write `active_window_mut()`. Used +naively from the panel path they would move the **document's** point — +precisely what "without disturbing the document mirror" forbids. + +**No new ruling is needed, because the ordering already rules it.** +Activation runs *before* replay in the same dispatch, and the gestures +that need the active-window API are exactly the ones that activate: + +- **Document panel** — `activates` on `Down(_)`. A drag-select is + Down → Drag → Up, and the Down focused the panel, so the later + gestures act on an active window legitimately. +- **Terminal panel** — `activates` on every non-`Move`, so reporting, + selection and wheel all run focused. This is AC48's own + controller-ownership clause. +- **The one gesture that does NOT activate** is a document panel's + wheel — and it needs only `scroll_window`, which is window-scoped. + +So the rule is: **replay after activation, and anything reachable +without activation must use a window-scoped mechanism.** Both halves +are satisfiable with what exists. + +### Q#BP-R1 — is a listview row's activation a click or a double-click? **OPEN** + +The one question the measurement could not answer from the tree. +`builtin/runtime/listview.lua` binds visiting to **RET/SPC** +(`:610`–`:611`), reading `pmacs.editor.cursor_line()` and calling +`on_visit`. There is no pointer precedent: nothing in the file mentions +a mouse. + +So a pointer gesture can *move* to a row unambiguously, but **whether a +single click also VISITS is a product decision with no existing +answer** — single-click-visits matches a file tree, click-selects / +double-click-visits matches an editor list, and AC48's wording +("drives listview row selection") names selection only. + +**This lane implements row SELECTION and does not invent activation.** +If visiting on click is wanted it should be ruled explicitly rather +than arriving as a side effect of wiring, because it is the difference +between a click that navigates the user's document and one that does +not. + ## 6. Deferred (named) Left / right / top side windows; multiple slots per side; **rehoming a leaf From c861d24e91c7407dd4f6e2d71975f25534e2ef69 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 14 Aug 2026 13:57:37 +0200 Subject: [PATCH 02/33] docs(framing): bottom-panel revision 6 --- four replay edges, and Q#BP-R1 ruled Answers review of revision 5. Still framing only; no implementation. **Q#BP-R1 IS RULED: a single click SELECTS a listview row only.** RET/SPC remain the activation path (`listview.lua:610`); no click-to-visit, no double-click-to-visit. That is acceptance 48's own wording, and it keeps document navigation from arriving as an incidental consequence of wiring replay. **REVISION 5 CONCLUDED THAT ACTIVATION ORDERING MADE REPLAY SAFE. IT IS NECESSARY BUT NOT SUFFICIENT.** The ordering holds only for gestures that activate, in a session nothing interleaves with, on a presentation that does not change mid-gesture, with modifiers intact and the mode line excluded. Each of those four provisos is an edge: **R-a --- modifiers are dropped before the shared path.** `PanelPointer` carries `mods`; the daemon destructures them into `..` (`src/daemon.rs:2425`) and the dispatcher has no modifier parameter. This breaks PRECEDENCE, not just fidelity: `apply_terminal_gesture` gates child reporting on `!shift && ... && modes.mouse_sgr` (`src/editor.rs:3534`), so Shift is the user's "select locally instead of talking to the child" override. Zeroed, a Shift-drag over a reporting terminal panel sends SGR. Row: Shift-drag selects locally and the child receives NO bytes. Mutation: drop `mods` at the boundary. **R-b --- `Drag` and `Up` do not activate**, and another frontend's input can interleave between a Down and its Drag, so a replay reading ambient active-window state acts on whatever is active then. The framing now NAMES the mechanism: an explicit side-window cell->byte adapter, and selection through a window-TARGETED path. `activate_and_position` (`src/editor.rs:3795`) is both the precedent and the trap --- its conversion is properly window-scoped, but it calls `set_active_window_id`. Rows: panel A Down -> frontend B input -> A Drag/Up changes only A; an orphan Drag/Up on a passive panel leaves the document mirror byte-identical. **R-c --- `panel_grid_size` is the FRAME, not the terminal viewport.** The panel's last row is its mode line: projection derives content as `rows - 1` (`src/editor.rs:2499`) while `panel_hit_test` reports across the whole frame (`pmacs-gpu/src/main.rs:7184`), so a `PanelPointer` can name the mode-line row. Passing the full grid to `apply_terminal_gesture` would make the mode line a child terminal cell. Terminal viewport is `rows - 1`; document replay follows the TUI's existing rule, "Mode-line click: reserved" (`src/editor.rs:3304`). Rows must distinguish content from chrome or an off-by-one passes. **R-d --- replacement leaves the frontend's gesture latch armed.** `Absent` clears `pointer_held` and `last_pointer_cell` (`pmacs-gpu/src/main.rs:6909`); `Present`->`Present` does not (`:6913`). Press on A, A replaced by B, and the latch emits a Drag or release for B with no B press --- and acceptance 49 CANNOT reject it, because the event carries B's current epochs. 49 is a staleness gate and this is not a stale event; it is a well-formed event from a gesture whose presentation no longer exists. The fix has a precedent in the same file: the DIVIDER drag latch already carries both epochs and self-invalidates (`:7288`). The pointer latch never got it. Three of the four are places where the tree already contains the right answer and the panel path does not use it, which is worth saying plainly: this is wiring that has to be done carefully, not mechanism that has to be invented. Gates: all nine green under `env -u TMPDIR`, log 20260814T115257Z. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 64 +++++++++---- docs/bottom-panel-framing.md | 172 +++++++++++++++++++++++++++++------ 2 files changed, 190 insertions(+), 46 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index db3611c..c76e8fc 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -281,9 +281,9 @@ from #171 and #215 — the correction the 1b lane missed, honoured here. **`githubsucks/panel-pointer-replay` is the authoritative tip** (the ref, not a SHA). Recover with `git fetch githubsucks && git checkout panel-pointer-replay`. -- **No PR yet. Checkpoint: framing revision 5 (§5a) AWAITING APPROVAL; - NO IMPLEMENTATION WRITTEN.** The first commit is the ground-truth - re-measurement, per the standing method. +- **No PR yet. Checkpoint: framing revision 6 (§5a) AWAITING APPROVAL; + NO IMPLEMENTATION WRITTEN.** Commit one was the ground-truth + re-measurement; **commit two answers review of it.** - **Why this lane exists.** `PanelPointer` **replays nothing**: `dispatch_semantic_panel_pointer` (`src/editor.rs:2674`) validates, focuses, returns. A panel wheel is dead on both axes and so is every @@ -295,7 +295,7 @@ from #171 and #215 — the correction the 1b lane missed, honoured here. had written the halves down separately. - **DONE:** click-to-focus and the terminal activation rule (`src/editor.rs:2701` and its `activates`); the focused-only - auto-scroll clamp with passive `view_top` preserved (`:2569`, which + auto-scroll clamp with passive `view_top` preserved (`:2568`–`:2571`, which already cites parent 48); the coalescing rules — `Move`/`Drag` tails coalesce, press/release/context/wheel lossless (`pmacs-gpu/src/attach.rs:374`). @@ -310,18 +310,50 @@ from #171 and #215 — the correction the 1b lane missed, honoured here. `scroll_window` (`:3845`) is window-scoped, cursor carry included. **A wheel-only bridge is the wrong shape** — the shared path takes every kind at once. -- **The scoping hazard, and why it needs no ruling.** `set_cursor_byte` - (`src/editor_core.rs:1216`), `begin_selection` (`:4691`) and - `clear_selection` are **active-window scoped**; used naively they - would move the DOCUMENT's point, which AC48 forbids. **Activation - runs before replay in the same dispatch**, and the gestures needing - that API are exactly the ones that activate. The only gesture that - does not activate — a document panel's wheel — needs only the - window-scoped `scroll_window`. -- **OPEN: Q#BP-R1** — does a listview row **visit** on click, or only - select? `listview.lua` binds visiting to RET/SPC (`:610`) and there - is no pointer precedent. **This lane implements selection and does - not invent activation.** +- **The scoping hazard.** `set_cursor_byte` (`src/editor_core.rs:1216`), + `begin_selection` (`:4691`) and `clear_selection` are + **active-window scoped**; used naively they would move the + DOCUMENT's point, which AC48 forbids. Activation runs before replay + in the same dispatch — **necessary but NOT sufficient**, which is + what revision 5 got wrong. +- **FOUR REPLAY EDGES (revision 6), each a place a plausible + implementation is silently wrong. Three have a precedent in the tree + the panel path simply does not use.** + - **R-a — modifiers dropped.** The daemon destructures `mods` into + `..` (`src/daemon.rs:2425`) and the dispatcher has no modifier + parameter, but `apply_terminal_gesture` gates child reporting on + `!shift && … && modes.mouse_sgr` (`src/editor.rs:3534`). **Shift is + the user's local-selection override**, so a Shift-drag over a + reporting terminal panel would send SGR to the child. Thread + `mods`; row: Shift-drag selects locally, child receives no bytes. + - **R-b — `Drag`/`Up` do not activate**, and another frontend can + interleave between them, so replay must not read ambient + active-window state. Name a **side-window cell→byte adapter** and a + window-TARGETED selection path. `activate_and_position` + (`src/editor.rs:3795`) is the precedent *and* the trap: its + conversion is window-scoped, but it calls `set_active_window_id`. + Rows: interleaved frontend B between A's Down and Drag/Up; orphan + Drag/Up on a passive panel leaves the document mirror + byte-identical. + - **R-c — `panel_grid_size` is the FRAME, not the viewport.** Content + is `rows − 1` (`src/editor.rs:2499`–`:2500`) but `panel_hit_test` + reports across the whole frame (`pmacs-gpu/src/main.rs:7184`), so a + `PanelPointer` can name the **mode-line row**. Terminal viewport is + `rows − 1`; document replay follows the TUI's *"Mode-line click: + reserved"* (`src/editor.rs:3304`–`:3306`). Rows must distinguish + content from chrome or an off-by-one passes. + - **R-d — replacement leaves the gesture latch armed.** `Absent` + clears `pointer_held`/`last_pointer_cell` + (`pmacs-gpu/src/main.rs:6909`) but **`Present`→`Present` does not** + (`:6913`). A press on A then A→B emits a Drag/release for B with no + B press, and **acceptance 49 cannot reject it** — the event carries + B's *current* epochs. The **divider** drag latch already + epoch-scopes itself (`:7288`); the pointer latch never did. Reset + on presentation-identity change, mutation-checked. +- **RULED: Q#BP-R1** — a single click **SELECTS a listview row only**. + RET/SPC remain activation (`listview.lua:610`); no click-to-visit and + no double-click-to-visit. Keeps document navigation from becoming an + incidental consequence of replay. - **Gates:** the four `bottom_panel_*` acceptance suites plus `PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu`. **No `--protocol`** — `PanelPointer` and every `MouseKind` it carries already exist on the diff --git a/docs/bottom-panel-framing.md b/docs/bottom-panel-framing.md index 18b29e8..101cc26 100644 --- a/docs/bottom-panel-framing.md +++ b/docs/bottom-panel-framing.md @@ -7,15 +7,28 @@ protocol v20, 2026-07-24. Amended by the pre-implementation dependency verification in §0.6: the folding dependency is cleared, and one geometry caller-census error is corrected.** -**Revision 5 — 2026-08-14, AWAITING APPROVAL. Adds §5a: acceptance 48 -re-measured against production at `72da24a`.** The finding is that AC48 +**Revision 6 — 2026-08-14, AWAITING APPROVAL.** Answers review of 5. +**Q#BP-R1 is RULED: a single click SELECTS a listview row only**; +RET/SPC remain activation, and this lane adds no click-to-visit. +Revision 5 concluded that the activation ordering made replay safe; +**it is necessary but not sufficient**, and §5a now carries the four +edges it missed — **R-a** modifiers dropped at the daemon boundary, +which breaks Shift's local-selection override; **R-b** `Drag`/`Up` do +not activate and another frontend can interleave, so replay needs a +named side-window cell→byte adapter and a window-targeted path; +**R-c** `panel_grid_size` includes the mode-line row while content is +`rows − 1`, so the terminal viewport must be `rows − 1` and document +replay needs the TUI's "mode-line click: reserved" rule; **R-d** +`Present`→`Present` replacement leaves the pointer latch armed, which +acceptance 49 cannot catch because the orphan gesture carries current +epochs. + +**Previously, revision 5 — SUPERSEDED. Added §5a: acceptance 48 +re-measured against production at `72da24a`.** The finding stands: AC48 is **half implemented** — focus, activation, the focused-only clamp and the coalescing rules all landed; **listview row selection, panel -selection, terminal mouse reporting and wheel replay did not**, so -`PanelPointer` reaches `dispatch_semantic_panel_pointer` and stops at -focus. No ruling in §3 or §5 changes. **One question is opened, -Q#BP-R1**, on whether a listview row visits on click; this lane -implements selection and does not answer it. +selection, terminal mouse reporting and wheel replay did not**. No +ruling in §3 or §5 changes. Give pmacs a **bottom panel**: a buffer displayed in a fixed-height window pinned to the bottom of the frame, resizable by dragging its divider, which @@ -1797,7 +1810,7 @@ the halves were never separated in writing. Measured clause by clause: |---|---|---| | click-to-focus | **DONE** | `dispatch_semantic_panel_pointer`'s `activates` → `focus_window` (`src/editor.rs:2701`) | | terminal panel: non-`Move` activates, hover does neither | **DONE** | the same `activates` computation, split by `is_terminal` | -| focused-only auto-scroll clamp; passive preserves `view_top` | **DONE** | `src/editor.rs:2569`, which already cites "A2A-3 / parent 48" | +| focused-only auto-scroll clamp; passive preserves `view_top` | **DONE** | `src/editor.rs:2568`–`:2571`, which already cites "A2A-3 / parent 48" | | move/drag tails coalesce; press/release/context/wheel lossless and ordered | **DONE** | `pmacs-gpu/src/attach.rs:374`–`:381` — `Move` key 6, `Drag` key 7, everything else unkeyed | | **listview row selection** | **MISSING** | — | | **panel selection** | **MISSING** | — | @@ -1832,9 +1845,12 @@ the panel's own, not the document mirror. naively from the panel path they would move the **document's** point — precisely what "without disturbing the document mirror" forbids. -**No new ruling is needed, because the ordering already rules it.** -Activation runs *before* replay in the same dispatch, and the gestures -that need the active-window API are exactly the ones that activate: +**Activation ordering is NECESSARY BUT NOT SUFFICIENT — revision 5 +stopped here and was wrong to.** It holds only for the gestures that +activate, in a session that nothing interleaves with, on a +presentation that does not change mid-gesture, with modifiers intact +and the mode line excluded. **Each of those four provisos is an edge +revision 6 had to add** (R-a…R-d below). The ordering itself is real: - **Document panel** — `activates` on `Down(_)`. A drag-select is Down → Drag → Up, and the Down focused the panel, so the later @@ -1845,29 +1861,125 @@ that need the active-window API are exactly the ones that activate: - **The one gesture that does NOT activate** is a document panel's wheel — and it needs only `scroll_window`, which is window-scoped. -So the rule is: **replay after activation, and anything reachable -without activation must use a window-scoped mechanism.** Both halves -are satisfiable with what exists. +So the ordering rule is: **replay after activation, and anything +reachable without activation must use a window-scoped mechanism.** +That is the floor, not the contract — R-a through R-d are the rest of +it, and three of the four are places where the tree already contains +the right precedent and the panel path simply does not use it. -### Q#BP-R1 — is a listview row's activation a click or a double-click? **OPEN** +### Q#BP-R1 — does a listview row visit on click? **RULED: no — single click SELECTS only** -The one question the measurement could not answer from the tree. -`builtin/runtime/listview.lua` binds visiting to **RET/SPC** -(`:610`–`:611`), reading `pmacs.editor.cursor_line()` and calling -`on_visit`. There is no pointer precedent: nothing in the file mentions -a mouse. +RET/SPC remain the activation path (`builtin/runtime/listview.lua:610`, +which reads `pmacs.editor.cursor_line()` and calls `on_visit`). **This +lane adds no click-to-visit and no double-click-to-visit.** That +follows acceptance 48's wording — it names row *selection* — and keeps +document navigation from becoming an incidental consequence of wiring +replay. -So a pointer gesture can *move* to a row unambiguously, but **whether a -single click also VISITS is a product decision with no existing -answer** — single-click-visits matches a file tree, click-selects / -double-click-visits matches an editor list, and AC48's wording -("drives listview row selection") names selection only. +### The four replay edges — none of which "activation ordering" covers -**This lane implements row SELECTION and does not invent activation.** -If visiting on click is wanted it should be ruled explicitly rather -than arriving as a side effect of wiring, because it is the difference -between a click that navigates the user's document and one that does -not. +§5a's first draft concluded that the activation rule made the replay +safe. **It does not, and each gap below is a place where a plausible +implementation is silently wrong.** + +#### R-a. Modifiers are dropped before they reach the shared path + +`FrontendEvent::PanelPointer` **carries `mods`**, and the daemon +**destructures them into `..`** (`src/daemon.rs:2425`); +`dispatch_semantic_panel_pointer` has no modifier parameter at all. + +**This breaks terminal precedence, not merely fidelity.** +`apply_terminal_gesture` opens with +`let shift = modifiers.contains(TerminalModifiers::SHIFT)` (`:3534`) +and gates child reporting on `!shift && … && modes.mouse_sgr`. **Shift +is the user's override for "select locally instead of talking to the +child."** Arriving with modifiers zeroed, a Shift-drag over a +reporting terminal panel sends SGR to the child instead of selecting. + +**Thread `mods` through** the destructure, the dispatcher signature and +into `apply_terminal_gesture`. **Row:** with mouse reporting enabled, +a Shift-drag selects locally and the child receives **no bytes**. +*Mutation: drop `mods` at the daemon boundary — the child receives +bytes and the row fails.* + +#### R-b. Activation does not make the document path target-safe + +`activates` is true for a document panel's `Down(_)` only, so **`Drag` +and `Up` do not activate**, and **another frontend's input can +interleave between them** — sessions are independent and nothing +freezes the active window for the duration of a gesture. A replay that +reads ambient active-window state for `Drag`/`Up` therefore acts on +whatever is active *then*, which may be a different window entirely. + +**The framing names the mechanism rather than leaving it to the +implementation: an explicit side-window cell→byte adapter, and +selection routed through a window-TARGETED path.** The precedent and +the trap are the same function — `activate_and_position` +(`src/editor.rs:3795`) is already window-scoped in its conversion (it +takes `win_id` and uses *that* window's `view_top` and fold map, per +the round-3 F1 correction) **but it also calls +`set_active_window_id`**, and the selection APIs it feeds +(`set_cursor_byte`, `begin_selection`) write `active_window_mut()`. +Panel replay needs the conversion **without** the ambient write. + +Two rows, both of which a naive implementation fails: + +- **Interleaved frontend:** panel A `Down` → **frontend B input** → + panel A `Drag`/`Up`. **Only A's panel changes.** +- **Orphan gesture:** a `Drag`/`Up` against a **passive** panel with no + preceding `Down`. **The document mirror is byte-identical + afterwards** — cursor, selection and `view_top`. + +#### R-c. `panel_grid_size` is the FRAME, not the terminal viewport + +The panel's last row is its **mode line**. Projection says so +explicitly: `content = Rect::new(0, 0, size.rows.saturating_sub(1), +size.cols)` against `outer` at full height (`src/editor.rs:2499`– +`:2500`). But **`panel_hit_test` reports cells across the whole +frame** — it passes `frame.size` (`pmacs-gpu/src/main.rs:7184`) — so a +`PanelPointer` **can name the mode-line row**. + +Passing `panel_grid_size` straight to `apply_terminal_gesture` as the +viewport therefore **makes the mode line a child terminal cell**: the +child is told about a row it does not own, and every coordinate below +it is off by the same row when the size is used for clamping. + +- **Terminal panels:** the viewport is **`rows − 1`**. +- **Document panels:** the mode line needs an **explicit rule**, and + the TUI already has one — `dispatch_mouse` returns early on + `local_row >= inner_rows` with the comment *"Mode-line click: + reserved."* (`src/editor.rs:3304`–`:3306`). **Panel replay follows it**: a + mode-line gesture is reserved, not a click at the nearest content + cell. + +**Rows must distinguish content from chrome**: a gesture on the last +row is inert (and reaches no child), while the same gesture one row up +replays normally. Without that pair, an off-by-one passes. + +#### R-d. Panel replacement leaves the frontend's gesture latch armed + +`PanelFramePayload::Absent` clears `pointer_held` and +`last_pointer_cell` (`pmacs-gpu/src/main.rs:6909`–`:6910`). **The +`Present` arm does not** (`:6913` onward) — it validates, swaps the +frame, rebuilds buffers, and leaves the latch exactly as it was. + +So: press on panel **A**, A is replaced by **B**, and the held latch +emits a `Drag` or release **for B with no B press**. **Acceptance 49 +cannot reject it** — the event carries B's *current* epochs, so it is +not stale by any test 49 applies. 49 is a staleness gate, and this is +not a stale event; it is a **well-formed event from a gesture that +belongs to a presentation that no longer exists.** + +**Reset the gesture latch on presentation-identity change.** The +precedent is in the same file: the **divider** drag latch already +carries `panel_epoch`/`geometry_epoch` and self-invalidates when the +presented frame's epochs differ (`:7288`). The pointer latch simply +never got the same treatment. + +*Mutation: omit the reset on `Present`→`Present` — the orphan-drag row +fails.* Distinct from R-b's orphan row: that one arrives from a +**passive** panel, this one from a **replaced** one, and an +implementation can fix either alone. ## 6. Deferred (named) From d9a23f30eb002c6fad97848404fca5f8f465325b Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 14 Aug 2026 14:13:37 +0200 Subject: [PATCH 03/33] docs(framing): bottom-panel revision 7 --- per-kind mode line, geometry identity, and three half-fixes Answers review of revision 6. Still framing only; no implementation. Three of the five are corrections to rules 6 introduced. **R-c's "the last row is inert" WAS WRONG.** The TUI precedent I cited is per KIND, not per row: `inner_rows` guards `Down(Left)` (:3303), `Drag(Left)` (:3331) and `Down(Right)` (:3348), and deliberately does NOT guard `Up(Left)` (:3339) or the wheel (:3358/:3362). A blanket inert rule breaks two things at once --- a wheel over the mode line stops scrolling, and a gesture begun in content and released over the mode line NEVER TERMINATES. And a receiver-only rule cannot work at all here, because the producer arms first: `PanelCell` spans the whole frame, so a mode-line `Down` runs `set_panel_pointer_held(true)` locally (`pmacs-gpu/src/main.rs:2878`) before the daemon sees anything. Dragging into content then emits an orphan `Drag` the daemon cannot tell from a real one. R-c now carries a per-kind producer/receiver table, both crossings as witnesses --- mode-line->content must emit no Drag, content->mode-line must terminate --- and rules the wheel explicitly. **R-d COVERED PANEL IDENTITY AND MISSED GEOMETRY IDENTITY.** The two epochs move independently by design: `panel_epoch` is stable across ordinary frames while `geometry_epoch` moves on any new declaration "including a font or scale change that leaves `CellSize` identical" (`pmacs-protocol/src/panel.rs:61`). `next_geometry_declaration` (`pmacs-gpu/src/main.rs:6847`) advances it and clears neither pointer field, so a held gesture resumes UNDER A NEW GRID carrying epochs that are current and valid --- acceptance 49 rejects stale epochs and this one is not stale. R-d also constrained only one of the two latch fields. Clearing `pointer_held` alone kills the orphan drag but leaves `last_pointer_cell`, and `panel_motion_is_new` (`:7238`) then suppresses the successor's first same-cell `Move` as a duplicate. Four mutations now, D1-D4, including the NEGATIVE one: an ordinary same-identity refresh must not cancel a live gesture, or D1/D2 are satisfiable by resetting on every frame and selection stops working. **R-a WITNESSED ONLY THE TERMINAL.** `dispatch_pointer`'s `Down` arm reads `extending = mods.contains(SHIFT)` and either keeps the anchor or collapses the selection (`src/editor.rs:3673`), so Shift-click in a document or listview panel is selection extension. Threading modifiers into `apply_terminal_gesture` alone would have passed the proposed row while leaving document panels broken --- a witness certifying half a fix. Two rows now, A1 terminal and A2 document, with separate mutations: a single "drop mods at the boundary" bites both and proves the boundary matters, not that each CONSUMER is wired. **R-b'S ROWS WERE SATISFIABLE BY DOING NOTHING.** "Only A's panel changes" passes for an implementation that drops the tail entirely. B1 now pins A's anchor at the Down cell's byte and A's cursor at the Drag cell's byte; B2 proves `Up` collapses an empty click. B4 and B5 carry Q#BP16's existing multi-click and Context statements, which replay is where they first become executable. B6 is a listview visit sentinel: the row is selected and `on_visit` does NOT run, so Q#BP-R1's ruling is falsifiable rather than merely asserted. Gates: all nine green under `env -u TMPDIR`, log 20260814T120856Z. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 27 +++-- docs/bottom-panel-framing.md | 185 +++++++++++++++++++++++++++++------ 2 files changed, 176 insertions(+), 36 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index c76e8fc..70b4d06 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -281,9 +281,12 @@ from #171 and #215 — the correction the 1b lane missed, honoured here. **`githubsucks/panel-pointer-replay` is the authoritative tip** (the ref, not a SHA). Recover with `git fetch githubsucks && git checkout panel-pointer-replay`. -- **No PR yet. Checkpoint: framing revision 6 (§5a) AWAITING APPROVAL; +- **No PR yet. Checkpoint: framing revision 7 (§5a) AWAITING APPROVAL; NO IMPLEMENTATION WRITTEN.** Commit one was the ground-truth - re-measurement; **commit two answers review of it.** + re-measurement; 6 added the four replay edges; **7 answers review of + 6** — R-c is per-KIND with a producer rule, R-d covers geometry + identity and both latch fields, R-a gains the document Shift + contrast, R-b's rows now pin results. - **Why this lane exists.** `PanelPointer` **replays nothing**: `dispatch_semantic_panel_pointer` (`src/editor.rs:2674`) validates, focuses, returns. A panel wheel is dead on both axes and so is every @@ -339,17 +342,27 @@ from #171 and #215 — the correction the 1b lane missed, honoured here. is `rows − 1` (`src/editor.rs:2499`–`:2500`) but `panel_hit_test` reports across the whole frame (`pmacs-gpu/src/main.rs:7184`), so a `PanelPointer` can name the **mode-line row**. Terminal viewport is - `rows − 1`; document replay follows the TUI's *"Mode-line click: - reserved"* (`src/editor.rs:3304`–`:3306`). Rows must distinguish - content from chrome or an off-by-one passes. + `rows − 1`. **The document rule is PER KIND, not "the row is + inert"** — the TUI guards `Down(Left)`/`Drag(Left)`/`Down(Right)` + and deliberately not `Up(Left)` or the wheel, so a blanket rule + would stop mode-line scrolling and leave a content-started gesture + unterminated. **The producer must also not arm** on a mode-line + press (`pmacs-gpu/src/main.rs:2878`); a receiver-only rule cannot + stop the resulting orphan. - **R-d — replacement leaves the gesture latch armed.** `Absent` clears `pointer_held`/`last_pointer_cell` (`pmacs-gpu/src/main.rs:6909`) but **`Present`→`Present` does not** (`:6913`). A press on A then A→B emits a Drag/release for B with no B press, and **acceptance 49 cannot reject it** — the event carries B's *current* epochs. The **divider** drag latch already - epoch-scopes itself (`:7288`); the pointer latch never did. Reset - on presentation-identity change, mutation-checked. + epoch-scopes itself (`:7288`); the pointer latch never did. **Both + epochs**: a font/scale change advances `geometry_epoch` while + `panel_epoch` holds (`pmacs-protocol/src/panel.rs:61`) and clears + neither field, so a held gesture resumes under a new grid with + valid epochs. **Both fields**: clearing only `pointer_held` leaves + the successor's first same-cell `Move` suppressed as a duplicate + (`:7238`). Four mutations, including the negative one — an ordinary + same-identity refresh must NOT cancel a live gesture. - **RULED: Q#BP-R1** — a single click **SELECTS a listview row only**. RET/SPC remain activation (`listview.lua:610`); no click-to-visit and no double-click-to-visit. Keeps document navigation from becoming an diff --git a/docs/bottom-panel-framing.md b/docs/bottom-panel-framing.md index 101cc26..f00c0ff 100644 --- a/docs/bottom-panel-framing.md +++ b/docs/bottom-panel-framing.md @@ -7,7 +7,37 @@ protocol v20, 2026-07-24. Amended by the pre-implementation dependency verification in §0.6: the folding dependency is cleared, and one geometry caller-census error is corrected.** -**Revision 6 — 2026-08-14, AWAITING APPROVAL.** Answers review of 5. +**Revision 7 — 2026-08-14, AWAITING APPROVAL.** Answers review of 6. +Five contract gaps, three of them corrections to 6's own rules: + +- **R-c's "the last row is inert" was wrong.** The TUI's rule is **per + KIND**: `inner_rows` guards `Down(Left)`/`Drag(Left)`/`Down(Right)` + and deliberately does not guard `Up(Left)` or the wheel. A blanket + rule would stop mode-line scrolling and leave a content-started + gesture unterminated. **And the producer arms before the receiver can + refuse** — a mode-line `Down` sets `pointer_held` locally + (`pmacs-gpu/src/main.rs:2878`), so a receiver-only rule cannot + prevent the orphan. R-c now carries a per-kind producer/receiver + table, both crossings, and an explicit wheel ruling. +- **R-d covered panel identity but not GEOMETRY identity.** A font or + scale change advances `geometry_epoch` while `panel_epoch` holds, and + the transition clears neither pointer field, so a held gesture + resumes under a new grid with *current, valid* epochs. Four + mutations now, including the **negative** one: an ordinary + same-identity refresh must not cancel a live gesture. +- **R-d also needed `last_pointer_cell` constrained separately** — + clearing only `pointer_held` leaves the successor's first same-cell + `Move` suppressed as a duplicate. +- **R-a witnessed only the terminal.** The document path consumes Shift + too, for the selection anchor, so threading modifiers into + `apply_terminal_gesture` alone would pass the proposed row while + breaking Shift-click in document and listview panels. +- **R-b's rows were satisfiable by doing nothing.** They now pin A's + anchor and cursor after the Drag, prove `Up` collapses an empty + click, and carry Q#BP16's multi-click and Context semantics plus a + listview visit sentinel for Q#BP-R1. + +**Previously, revision 6 — SUPERSEDED.** Answered review of 5. **Q#BP-R1 is RULED: a single click SELECTS a listview row only**; RET/SPC remain activation, and this lane adds no click-to-visit. Revision 5 concluded that the activation ordering made replay safe; @@ -1896,11 +1926,27 @@ is the user's override for "select locally instead of talking to the child."** Arriving with modifiers zeroed, a Shift-drag over a reporting terminal panel sends SGR to the child instead of selecting. -**Thread `mods` through** the destructure, the dispatcher signature and -into `apply_terminal_gesture`. **Row:** with mouse reporting enabled, -a Shift-drag selects locally and the child receives **no bytes**. -*Mutation: drop `mods` at the daemon boundary — the child receives -bytes and the row fails.* +**And the terminal is not the only consumer.** The ordinary document +path reads Shift too: `dispatch_pointer`'s `Down` arm computes +`extending = mods.contains(SHIFT)` and `keep_anchor`, then either +**extends from the previous cursor** or collapses the selection to the +clicked byte (`src/editor.rs:3673`–`:3687`). So Shift-click in a +**document or listview panel** is selection extension, and threading +modifiers *only* into `apply_terminal_gesture` would **pass the +terminal row while leaving document panels broken** — the exact shape +of a witness that certifies half a fix. + +**Thread `mods` through** the destructure, the dispatcher signature, +and into **both** consumers. Two rows, and both mutations: + +| # | row | mutation | +|---|---|---| +| A1 | terminal panel, reporting enabled: **Shift**-drag selects locally, child receives **no bytes** | drop `mods` before `apply_terminal_gesture` → the child receives bytes | +| A2 | document/listview panel: **Shift**-click **extends** the selection from the prior cursor; unmodified click collapses it | drop `mods` before the document path → Shift-click collapses, A2 fails while A1 still passes | + +A2's mutation is deliberately separate from A1's: a single "drop +`mods`" at the boundary bites both, which proves the boundary matters +but not that **each consumer** is wired. #### R-b. Activation does not make the document path target-safe @@ -1922,13 +1968,28 @@ the round-3 F1 correction) **but it also calls (`set_cursor_byte`, `begin_selection`) write `active_window_mut()`. Panel replay needs the conversion **without** the ambient write. -Two rows, both of which a naive implementation fails: +**The witnesses must pin the RESULT, not just the absence of collateral +damage.** "Only A's panel changes" is satisfied by an implementation +that drops the tail entirely and changes nothing anywhere. -- **Interleaved frontend:** panel A `Down` → **frontend B input** → - panel A `Drag`/`Up`. **Only A's panel changes.** -- **Orphan gesture:** a `Drag`/`Up` against a **passive** panel with no - preceding `Down`. **The document mirror is byte-identical - afterwards** — cursor, selection and `view_top`. +| # | row | what it pins | +|---|---|---| +| B1 | panel A `Down` → **frontend B input** → panel A `Drag` | A's **anchor is the Down cell's byte and A's cursor is the Drag cell's byte**, exactly; B's window and the document mirror are unchanged | +| B2 | panel A `Down` → `Up` at the **same** cell | the empty selection **collapses** — an Up that does nothing leaves a stale one-byte region | +| B3 | orphan `Drag`/`Up` on a **passive** panel, no preceding `Down` | the document mirror is **byte-identical**: cursor, selection, `view_top` | +| B4 | **repeated left `Down`s** at one cell | the existing daemon click state reads a **multi-click**, per Q#BP16 — so replay must not swallow or coalesce them | +| B5 | `Down(Right)` on a panel cell | the **context menu** opens, per Q#BP16 — a right press is not a selection gesture | +| B6 | click a **listview** row | the row is **selected** and `on_visit` **does not run** — a sentinel proving Q#BP-R1's ruling holds through replay | + +B4 and B5 are not new contracts: Q#BP16 already states that repeated +left `Down`s are what the click state reads as a multi-click and that +`Down(Right)` is the context gesture, *"so neither may collapse"* +(§3, Q#BP16). They are in this matrix because **replay is where those +statements first become executable** — until now nothing replayed, so +nothing could contradict them. + +B6 is the sentinel for the ruling above: selection without activation +is only meaningful if something fails when a click visits. #### R-c. `panel_grid_size` is the FRAME, not the terminal viewport @@ -1945,16 +2006,49 @@ child is told about a row it does not own, and every coordinate below it is off by the same row when the size is used for clamping. - **Terminal panels:** the viewport is **`rows − 1`**. -- **Document panels:** the mode line needs an **explicit rule**, and - the TUI already has one — `dispatch_mouse` returns early on - `local_row >= inner_rows` with the comment *"Mode-line click: - reserved."* (`src/editor.rs:3304`–`:3306`). **Panel replay follows it**: a - mode-line gesture is reserved, not a click at the nearest content - cell. +- **Document panels:** the mode line needs an explicit rule. -**Rows must distinguish content from chrome**: a gesture on the last -row is inert (and reaches no child), while the same gesture one row up -replays normally. Without that pair, an off-by-one passes. +**"The last row is inert" is WRONG, and revision 6 said it.** The TUI's +rule — which is the precedent — is **per kind**, not per row. +`inner_rows` guards `Down(Left)` (`src/editor.rs:3303`), `Drag(Left)` +(`:3331`) and `Down(Right)` (`:3348`), and **`Up(Left)` (`:3339`), +`ScrollUp` (`:3358`) and `ScrollDown` (`:3362`) are deliberately NOT +guarded.** A blanket +"inert" rule would break two things at once: a wheel over the mode line +would stop scrolling, and a gesture that begins in content and releases +over the mode line would **never terminate**. + +**And the producer arms before the receiver can refuse.** `PanelCell` +comes from `panel_hit_test`, which spans the whole frame, so a +mode-line `Down` runs `set_panel_pointer_held(true)` **locally** +(`pmacs-gpu/src/main.rs:2878`–`:2880`) before any daemon decision. +Dragging from there into content then emits a `Drag` with no accepted +`Down` — an orphan the daemon cannot distinguish from a real one. **A +receiver-only rule cannot fix this**; the producer must not arm on a +mode-line press. + +| kind on the mode-line row | producer | receiver | +|---|---|---| +| `Down(Left)`, `Down(Right)` | **do not arm, do not send** | reserved — drop | +| `Drag(Left)` | not sent (never armed) | reserved — drop | +| `Up(Left)` | **send** — it terminates a gesture begun in content | **process**, terminating the gesture | +| wheel | **send** | **process** — scrolls the panel, per the TUI | + +**Both crossings need witnesses, and they fail in opposite +directions:** + +- **Mode line → content:** press on the mode line, drag into content. + **No `Drag` reaches the daemon**, because nothing armed. *Mutation: + arm on a mode-line press — the orphan appears.* +- **Content → mode line:** press in content, release over the mode + line. **The gesture terminates** — the selection is committed, the + latch clears. *Mutation: reserve `Up` as well — the gesture hangs, + latched, and the next unrelated motion continues a selection the user + ended.* + +**The wheel is ruled explicitly**: a wheel over the mode line scrolls +the panel exactly as one over content does. It is not a click, it +carries no position semantics, and the TUI does not guard it. #### R-d. Panel replacement leaves the frontend's gesture latch armed @@ -1970,15 +2064,48 @@ not stale by any test 49 applies. 49 is a staleness gate, and this is not a stale event; it is a **well-formed event from a gesture that belongs to a presentation that no longer exists.** -**Reset the gesture latch on presentation-identity change.** The -precedent is in the same file: the **divider** drag latch already -carries `panel_epoch`/`geometry_epoch` and self-invalidates when the -presented frame's epochs differ (`:7288`). The pointer latch simply -never got the same treatment. +**And panel identity is only half of it.** The two epochs move +independently, by design (`pmacs-protocol/src/panel.rs:61` onward): +`panel_epoch` is *"stable across ordinary frames of one continuously +present window/buffer"*, while `geometry_epoch` *"moves whenever the +frontend declares new effective cell geometry — including a font or +scale change that leaves `CellSize` identical"*. A font or scale change +therefore advances `geometry_epoch` with `panel_epoch` untouched, and +`next_geometry_declaration` (`pmacs-gpu/src/main.rs:6847`) advances it +**without clearing either pointer field**. -*Mutation: omit the reset on `Present`→`Present` — the orphan-drag row -fails.* Distinct from R-b's orphan row: that one arrives from a -**passive** panel, this one from a **replaced** one, and an +So a held gesture **resumes under a new grid** — new cell advance, new +row heights, the same cells meaning different text — carrying epochs +that are *current and valid*. Acceptance 49's geometry-race check +cannot help: it rejects events bearing a **stale** `geometry_epoch`, +and this one bears the new one. + +**Reset the gesture latch on a change of EITHER identity.** The +precedent is in the same file: the **divider** drag latch already +carries both epochs and self-invalidates when the presented frame +differs in either (`:7288`). The pointer latch never got it. + +**Both fields, and both must be constrained separately.** Clearing only +`pointer_held` kills the orphan drag but leaves `last_pointer_cell` +set, and `panel_motion_is_new` (`:7238`–`:7242`) then **suppresses B's +first same-cell `Move` as a duplicate** — the successor's opening +motion silently vanishes. The fields fail differently and need +independently discriminating legs. + +| # | mutation | must bite | +|---|---|---| +| D1 | no reset on `Present`→`Present` (panel identity) | the replaced-panel orphan-drag row | +| D2 | no reset on `geometry_epoch` change | the font/scale held-gesture row | +| D3 | reset clears `pointer_held` only | the successor's first same-cell `Move` row | +| D4 | reset on **every** frame | the negative row below | + +**The negative leg is required**, or D1/D2 are satisfiable by resetting +unconditionally: an **ordinary same-identity frame refresh must NOT +cancel a live gesture**. A panel repaints constantly during a drag; +resetting on each frame would make selection impossible. + +R-d's orphan is distinct from R-b's: **R-b's arrives from a passive +panel, R-d's from a replaced or re-declared one**, and an implementation can fix either alone. ## 6. Deferred (named) From 4833a73dfeb88218763129a093535aece98774da Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 14 Aug 2026 14:29:23 +0200 Subject: [PATCH 04/33] docs(framing): bottom-panel revision 8 --- target x origin, and a negative row standing alone Answers review of revision 7. Still framing only; no implementation. **R-c NEEDED TARGET x GESTURE-ORIGIN, NOT KIND ALONE.** The per-kind rule I took from the TUI is DOCUMENT-ONLY: for a terminal window the TUI rejects every kind on the mode line before any per-kind match (`src/editor.rs:3273`) and hands `dispatch_terminal_mouse` a content-sized viewport (`:3272`). Terminals never see a chrome coordinate. And passing one is not merely untidy, it is wrong twice: `apply_terminal_gesture`'s reporting branch is bounds-checked on `coord.row < screen_size.rows` (`:3560`), and a chrome row equals `screen_size.rows`, so the check FAILS and the gesture falls into the LOCAL branch. A reporting child therefore receives a `Down` and then no `Up`, and a chrome wheel silently becomes local scrollback instead of behaving like a content wheel. The table is now split by target, and a content-originated gesture terminates at its LAST VALID CONTENT COORDINATE. **Q#BP-R2 is ruled: a chrome wheel over a terminal panel CLAMPS.** This diverges from the TUI, which drops it, and is flagged as the one product call inside R-c. The panel's mode line is the band's own chrome, one row, and a document panel already scrolls on a chrome wheel; dropping only for terminals would make the same pixel behave differently depending on which buffer happens to be shown. Overrule if TUI parity is preferred --- nothing else in R-c depends on it. **R-c2 --- THE PRODUCER NEVER REMEMBERS THE `Down` CELL.** `set_panel_pointer_held` clears `last_pointer_cell` (`pmacs-gpu/src/main.rs:7250`) and only motion refills it, while `panel_release_cell` falls back to exactly that field. Today the fallback is rarely reached because chrome IS a `PanelCell`; the moment R-c stops that, a `Down` in content followed immediately by a release over chrome --- no intervening motion --- has nothing to fall back to. Retain the `Down` cell at arm time. For a reporting terminal the row asserts the exact child release BYTES, because a latch that clears while the child never hears the release is the failure being guarded. **A1 WAS A NEGATIVE ROW STANDING ALONE.** It asserts bytes that must not appear, so disabling child reporting entirely satisfies it --- as does never reaching `apply_terminal_gesture`. The whole edge matrix could have stayed green against a replay that never reports anything. A3-A5 add the positive half on the SAME terminal: exact SGR sequences for `Down`/`Drag`/`Up`, exact wheel bytes when reporting, and the non-reporting wheel's local scrollback effect. **Four witness seams tightened.** B2 was checking the wrong thing: `active_region()` returns `None` "if no region is set OR IT IS EMPTY" (`src/editor_core.rs:4684`), so it answers None both before and after the `Up` and cannot see the residue --- which is an active-but-EMPTY selection whose stale anchor would capture the next shift-motion. It reads the owning window's raw `selection` now. B4 pins the exact selected word rather than that click state "reads a multi-click". B6 runs its visit sentinel through single AND double click, since the ruling forbids both. D4 uses a CHANGED frame with unchanged epochs --- the focus repaint the `Down` itself causes --- because a byte-identical duplicate returns early ("A duplicate does no work", `:6918`) and would never reach the reset code it is meant to constrain. Gates: all nine green under `env -u TMPDIR`, log 20260814T122442Z. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 12 ++- docs/bottom-panel-framing.md | 179 +++++++++++++++++++++++++++++++---- 2 files changed, 166 insertions(+), 25 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 70b4d06..694c6a9 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -281,12 +281,16 @@ from #171 and #215 — the correction the 1b lane missed, honoured here. **`githubsucks/panel-pointer-replay` is the authoritative tip** (the ref, not a SHA). Recover with `git fetch githubsucks && git checkout panel-pointer-replay`. -- **No PR yet. Checkpoint: framing revision 7 (§5a) AWAITING APPROVAL; +- **No PR yet. Checkpoint: framing revision 8 (§5a) AWAITING APPROVAL; NO IMPLEMENTATION WRITTEN.** Commit one was the ground-truth re-measurement; 6 added the four replay edges; **7 answers review of - 6** — R-c is per-KIND with a producer rule, R-d covers geometry - identity and both latch fields, R-a gains the document Shift - contrast, R-b's rows now pin results. + 6; **8 answers review of 7** — R-c is target × gesture-ORIGIN + (terminals reject all chrome kinds and raw chrome coords fail the + reporting bounds check), R-c2 retains the `Down` cell, A3–A5 add + positive SGR controls, and four witness seams are tightened. **New + ruling Q#BP-R2**: a chrome wheel over a terminal panel clamps into + content rather than dropping — a deliberate divergence from the TUI, + flagged for overrule. - **Why this lane exists.** `PanelPointer` **replays nothing**: `dispatch_semantic_panel_pointer` (`src/editor.rs:2674`) validates, focuses, returns. A panel wheel is dead on both axes and so is every diff --git a/docs/bottom-panel-framing.md b/docs/bottom-panel-framing.md index f00c0ff..007325f 100644 --- a/docs/bottom-panel-framing.md +++ b/docs/bottom-panel-framing.md @@ -7,8 +7,39 @@ protocol v20, 2026-07-24. Amended by the pre-implementation dependency verification in §0.6: the folding dependency is cleared, and one geometry caller-census error is corrected.** -**Revision 7 — 2026-08-14, AWAITING APPROVAL.** Answers review of 6. -Five contract gaps, three of them corrections to 6's own rules: +**Revision 8 — 2026-08-14, AWAITING APPROVAL.** Answers review of 7: + +- **R-c is target × gesture-ORIGIN, not kind alone.** The TUI's + per-kind rule is **document-only** — for terminals it rejects every + kind on the mode line (`src/editor.rs:3273`) and passes a + content-sized viewport. Raw chrome coordinates are actively unsafe + there: `apply_terminal_gesture`'s reporting branch is bounds-checked + (`:3560`), so a chrome row **falls through to the LOCAL branch** — a + reporting child gets `Down` and no `Up`, and a chrome wheel becomes + local scrollback. **A content-originated gesture terminates at its + last valid CONTENT coordinate.** New: **Q#BP-R2**, ruling that a + chrome wheel over a terminal panel **clamps** rather than dropping — + a deliberate divergence from the TUI, flagged for overrule. +- **R-c2: the producer never remembers the `Down` cell.** Arming clears + `last_pointer_cell` (`pmacs-gpu/src/main.rs:7250`) and only motion + refills it, so once chrome stops being a `PanelCell`, a `Down` + + immediate release has **no fallback coordinate**. Retain it at arm + time; for a reporting terminal the row asserts the **exact child + release bytes**. +- **A1 was a negative row standing alone** — disabling reporting + entirely satisfies it. **A3–A5** add positive controls on the same + terminal: exact SGR bytes for `Down`/`Drag`/`Up` and for the wheel, + plus the non-reporting wheel's local scrollback effect. +- **Four witness seams tightened.** B2 reads the **raw `selection`**, + because `active_region()` answers `None` on both sides of the `Up` + (`src/editor_core.rs:4684`); B4 pins the **exact selected word**; B6 + runs its sentinel through **single and double** click; D4 uses a + **changed** frame with unchanged epochs — the `Down`'s own focus + repaint — since a byte-identical duplicate is suppressed before the + reset could run (`pmacs-gpu/src/main.rs:6918`). + +**Previously, revision 7 — SUPERSEDED.** Five contract gaps, three of +them corrections to 6's own rules: - **R-c's "the last row is inert" was wrong.** The TUI's rule is **per KIND**: `inner_rows` guards `Down(Left)`/`Drag(Left)`/`Down(Right)` @@ -1948,6 +1979,23 @@ A2's mutation is deliberately separate from A1's: a single "drop `mods`" at the boundary bites both, which proves the boundary matters but not that **each consumer** is wired. +**A1 is a NEGATIVE row and cannot stand alone.** It asserts bytes that +must *not* appear, and **disabling child reporting entirely satisfies +it** — as does never reaching `apply_terminal_gesture` at all. The +whole edge matrix could stay green against a replay that never reports. +Positive controls, on the **same terminal** as A1 so the pair is a +genuine discriminator: + +| # | row | mutation | +|---|---|---| +| A3 | unmodified content `Down`/`Drag`/`Up`, reporting enabled → the child receives the **exact SGR byte sequences** for each | bypass `apply_terminal_gesture` → no bytes, A3 fails while A1 still passes | +| A4 | unmodified content **wheel**, reporting enabled → the child receives the **exact SGR wheel bytes** | as A3 | +| A5 | same wheel, reporting **disabled** → **no bytes**, and the view's **local scrollback moves** | route the wheel around the shared path → the scrollback does not move | + +A3–A5 pin the *positive* half: that replay reaches the shared path and +the child hears what it should. A1 then pins the one case where it must +not. + #### R-b. Activation does not make the document path target-safe `activates` is true for a document panel's `Down(_)` only, so **`Drag` @@ -1975,11 +2023,25 @@ that drops the tail entirely and changes nothing anywhere. | # | row | what it pins | |---|---|---| | B1 | panel A `Down` → **frontend B input** → panel A `Drag` | A's **anchor is the Down cell's byte and A's cursor is the Drag cell's byte**, exactly; B's window and the document mirror are unchanged | -| B2 | panel A `Down` → `Up` at the **same** cell | the empty selection **collapses** — an Up that does nothing leaves a stale one-byte region | +| B2 | panel A `Down` → `Up` at the **same** cell | the window's **raw `selection` is `None`** afterwards — see below | | B3 | orphan `Drag`/`Up` on a **passive** panel, no preceding `Down` | the document mirror is **byte-identical**: cursor, selection, `view_top` | -| B4 | **repeated left `Down`s** at one cell | the existing daemon click state reads a **multi-click**, per Q#BP16 — so replay must not swallow or coalesce them | +| B4 | **repeated left `Down`s** at one cell | the **exact word** at that cell is selected after the second `Down` — the multi-click actually resolved, per Q#BP16 | | B5 | `Down(Right)` on a panel cell | the **context menu** opens, per Q#BP16 — a right press is not a selection gesture | -| B6 | click a **listview** row | the row is **selected** and `on_visit` **does not run** — a sentinel proving Q#BP-R1's ruling holds through replay | +| B6 | **single** click, then **repeated/double** click, on a listview row | the row is **selected** and the `on_visit` sentinel **never fires**, in *both* — Q#BP-R1 forbids double-click-to-visit too | + +**B2 must read the raw selection, not `active_region()`.** That helper +returns `None` *"if no region is set **or it is empty**"* +(`src/editor_core.rs:4684`–`:4688`), so it answers `None` **both before +and after** the `Up` and cannot see the residue at all. The residue +being guarded against is an **active-but-empty selection** — a +`Some(Selection { anchor })` whose anchor equals the cursor — which +would make the next shift-motion extend from a stale anchor. Inspect +the **owning window's** `selection` field directly. + +**B6 runs both click shapes** because the ruling forbids +double-click-to-visit as well as single. A sentinel that only tries a +single click leaves the more tempting implementation — visit on the +second click, like a file manager — completely uncovered. B4 and B5 are not new contracts: Q#BP16 already states that repeated left `Down`s are what the click state reads as a multi-click and that @@ -2027,28 +2089,94 @@ Dragging from there into content then emits a `Drag` with no accepted receiver-only rule cannot fix this**; the producer must not arm on a mode-line press. -| kind on the mode-line row | producer | receiver | -|---|---|---| -| `Down(Left)`, `Down(Right)` | **do not arm, do not send** | reserved — drop | -| `Drag(Left)` | not sent (never armed) | reserved — drop | -| `Up(Left)` | **send** — it terminates a gesture begun in content | **process**, terminating the gesture | -| wheel | **send** | **process** — scrolls the panel, per the TUI | +**Kind alone is still not enough. The rule is target × gesture +ORIGIN**, and revision 7's table had neither axis complete. + +**The TUI's per-kind rule is DOCUMENT-ONLY.** For a terminal window it +rejects **every** kind on the mode line before any per-kind match: +`if local_row >= inner_rows || … { self.mouse_click = None; return; }` +(`src/editor.rs:3273`), and it passes the content-sized viewport +`CellSize::new(inner_rows, …)` (`:3272`) to `dispatch_terminal_mouse`. +Terminals never see a chrome coordinate at all. + +**And raw chrome coordinates are actively unsafe for terminals**, +because `apply_terminal_gesture`'s reporting branch is bounds-checked: +`coord.row < screen_size.rows && coord.col < screen_size.cols` +(`src/editor.rs:3560`–`:3561`). A chrome row equals `screen_size.rows`, +so the check **fails and the gesture falls through to the LOCAL +branch**. Two concrete wrongnesses follow: a reporting child gets a +`Down` and then **no `Up`** (its release became a local +`finish_selection`), and a chrome wheel becomes **local scrollback** +instead of behaving as a content wheel does. + +| target | kind | producer | receiver | +|---|---|---|---| +| **document** | `Down(Left)`, `Down(Right)` | do not arm, do not send | reserved — drop | +| **document** | `Drag(Left)`, chrome-originated | not sent (never armed) | reserved — drop | +| **document** | `Drag(Left)`, **content-originated** | **sent** — the gesture is live | **process at the last valid CONTENT coordinate** | +| **document** | `Up(Left)` | **send** | **process** — terminates the gesture | +| **document** | wheel | **send** | **process** — scrolls the panel | +| **terminal** | any kind, chrome-**originated** | do not arm, do not send | drop, matching the TUI | +| **terminal** | `Drag`/`Up`, **content-originated**, now over chrome | **send** | **replay at the last valid CONTENT coordinate** — never the chrome row | +| **terminal** | wheel over chrome | **send, clamped to content** | as a content wheel: reported when eligible, else local scrollback | + +**A content-originated gesture terminates normally, at its last valid +content coordinate.** That is the whole of the crossing rule, and it is +what keeps a reporting child from being told about a row it does not +own while still receiving its release. + +#### Q#BP-R2 — a chrome wheel over a TERMINAL panel: clamp, or drop? **RULED: clamp** + +The one product call inside R-c, flagged because it **diverges from the +TUI deliberately**. The TUI drops terminal mode-line wheels; this rules +that a panel clamps them into content instead. + +The reason is that the panel's mode line is not a window's mode line +among many — it is **the band's own chrome**, one row, and the document +panel already scrolls on a chrome wheel (the TUI does not guard the +wheel for documents). Dropping for terminals would make **the same +pixel behave differently depending on the buffer kind currently shown +there**, which is not a distinction a user is holding in mind while +scrolling. **Overrule this if you would rather have TUI parity**; it is +a one-line difference in the clamp and nothing else in R-c depends on +it. **Both crossings need witnesses, and they fail in opposite directions:** -- **Mode line → content:** press on the mode line, drag into content. +- **Chrome → content:** press on the mode line, drag into content. **No `Drag` reaches the daemon**, because nothing armed. *Mutation: - arm on a mode-line press — the orphan appears.* -- **Content → mode line:** press in content, release over the mode - line. **The gesture terminates** — the selection is committed, the - latch clears. *Mutation: reserve `Up` as well — the gesture hangs, - latched, and the next unrelated motion continues a selection the user - ended.* + arm on a chrome press — the orphan appears.* +- **Content → chrome:** press in content, release over the mode line. + **The gesture terminates at the last content coordinate.** For a + **document** panel the selection commits and the latch clears; for a + **reporting terminal** the child receives the release **at the + content row**. *Mutation: reserve `Up` for documents — the gesture + hangs latched; pass the raw chrome coordinate for terminals — the + child receives no `Up` at all, because the bounds check drops it into + the local branch.* -**The wheel is ruled explicitly**: a wheel over the mode line scrolls -the panel exactly as one over content does. It is not a click, it -carries no position semantics, and the TUI does not guard it. +#### R-c2. The producer does not remember where the gesture began + +`set_panel_pointer_held` **clears `last_pointer_cell`** +(`pmacs-gpu/src/main.rs:7250`), and only `panel_motion_is_new` +(`:7238`) ever fills it. `panel_release_cell` falls back to that field +when the release is not over a cell (`:7225`–`:7230`). + +Today that fallback is rarely reached, because chrome *is* a +`PanelCell`. **Once R-c stops chrome being one, it becomes the normal +path** — and it is empty. A `Down` in content followed **immediately** +by a release over chrome or outside the band, with **no intervening +motion**, has no coordinate to fall back to, so the `Up` is either +dropped or sent with nothing. + +**Retain the `Down` cell when arming.** Row: `Down` in content → +release over chrome, **no intervening motion** → the `Up` carries the +**`Down` cell's** coordinate. *Mutation: keep clearing the cell on arm +— the release has no coordinate.* **For a reporting terminal the row +asserts the exact child release BYTES**, not merely that the frontend +latch cleared: a latch that clears while the child never hears the +release is the failure this row exists for. #### R-d. Panel replacement leaves the frontend's gesture latch armed @@ -2104,6 +2232,15 @@ unconditionally: an **ordinary same-identity frame refresh must NOT cancel a live gesture**. A panel repaints constantly during a drag; resetting on each frame would make selection impossible. +**D4's frame must be a CHANGED frame with unchanged epochs**, and the +natural one is **the focus repaint the `Down` itself causes** — the +panel activates, its mode line and cursor render differently, and the +frame arrives mid-gesture with both epochs identical. A byte-identical +duplicate would not exercise the rule at all: production returns early +on one, *"A duplicate does no work — not even a reshape"* +(`pmacs-gpu/src/main.rs:6918`–`:6919`), so the reset code is never +reached and D4 passes against a broken implementation. + R-d's orphan is distinct from R-b's: **R-b's arrives from a passive panel, R-d's from a replaced or re-declared one**, and an implementation can fix either alone. From 7d6b6da366e65640ea0ed5aa0eb72faf7ea1df17 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 14 Aug 2026 14:39:47 +0200 Subject: [PATCH 05/33] docs(framing): bottom-panel revision 9 --- Q#BP-R2 overruled, and R-c2 stops fighting a tested contract Answers review of revision 8. Still framing only; no implementation. Both items reverse a decision revision 8 made. **Q#BP-R2 IS OVERRULED: a terminal-chrome wheel is CONSUMED, not clamped.** I argued clamping on consistency grounds and missed that SGR wheel input is COORDINATE-BEARING: `encode_mouse` takes a `coord` and writes `coord.col + 1` / `coord.row + 1` into the emitted sequence (`src/terminal/input.rs:102`, `:146`) --- which A4, added one revision earlier, pins exactly. Clamping therefore fabricates a hit on the terminal's final content row, and an application that routes wheel input by position acts on a cell the user never pointed at. The consistency I was buying is cosmetic; the price is a synthetic coordinate handed to a program as real. The asymmetry with `Up` is the part worth keeping in the record: `Up` must be normalized because an unterminated gesture HANGS --- the daemon holds a button down forever --- while a wheel tick is self-contained and dropping one strands nothing. Liveness is what earns normalization, and the wheel has no liveness obligation. Consumed, and never fallen through to the document; TUI parity restored. **R-c2 WOULD HAVE BROKEN A TESTED GUARANTEE.** Revision 8 said to retain the `Down` cell in `last_pointer_cell`. That field is cleared on press deliberately, and a live test says why: "the first drag after a press must reach the daemon even at the cell the press landed on" (`pmacs-gpu/src/main.rs:19841`). Storing `Down` there makes the press's own cell the dedupe baseline and suppresses exactly that `Drag`. Ruled: a separate `gesture_last_content_cell`. The two fields have different jobs and conflating them was the error --- `last_pointer_cell` answers "is this motion worth sending?", the new one answers "where did this gesture last legitimately point?" --- different lifetimes, different resets. Written on arm and on each accepted content motion; reset on release and on BOTH identity changes; never consulted by `panel_motion_is_new`. A second mutation guards the separation itself: point the dedupe at the new field and the existing first-`Drag`-after-press assertion must fail. The alternative --- ruling `Down` the dedupe baseline and retiring the guarantee --- is recorded as considered and rejected. It would need a mutation showing the first same-cell `Drag` is redundant, and it is not obviously so, since that event is what establishes the daemon's drag state. **And the crossing table follows the choice.** Revision 8 promised a content-originated `Drag` over chrome would be sent. It should not be: once normalized, its coordinate is usually the one already reported and the ordinary dedupe suppresses it, correctly --- promising delivery would oblige the producer to defeat its own dedupe for a daemon state that is identical either way. `Up` is the load-bearing crossing event and the only one promised unconditionally. Gates: all nine green under `env -u TMPDIR`, log 20260814T123502Z. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 13 +++- docs/bottom-panel-framing.md | 135 +++++++++++++++++++++++++++-------- 2 files changed, 118 insertions(+), 30 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 694c6a9..3cb9d0b 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -281,7 +281,7 @@ from #171 and #215 — the correction the 1b lane missed, honoured here. **`githubsucks/panel-pointer-replay` is the authoritative tip** (the ref, not a SHA). Recover with `git fetch githubsucks && git checkout panel-pointer-replay`. -- **No PR yet. Checkpoint: framing revision 8 (§5a) AWAITING APPROVAL; +- **No PR yet. Checkpoint: framing revision 9 (§5a) AWAITING APPROVAL; NO IMPLEMENTATION WRITTEN.** Commit one was the ground-truth re-measurement; 6 added the four replay edges; **7 answers review of 6; **8 answers review of 7** — R-c is target × gesture-ORIGIN @@ -290,7 +290,16 @@ from #171 and #215 — the correction the 1b lane missed, honoured here. positive SGR controls, and four witness seams are tightened. **New ruling Q#BP-R2**: a chrome wheel over a terminal panel clamps into content rather than dropping — a deliberate divergence from the TUI, - flagged for overrule. + flagged for overrule — **and overruled in 9**. + **Revision 9** reverses two of 8's decisions: a terminal-chrome wheel + is **CONSUMED, not clamped** (SGR wheel input is coordinate-bearing, + so clamping fabricates a hit the user never made, and a wheel has no + liveness obligation), and the `Down`-cell fallback lives in a + **separate `gesture_last_content_cell`** rather than + `last_pointer_cell`, which is cleared on press precisely so the first + same-cell `Drag` reaches the daemon (`pmacs-gpu/src/main.rs:19841`). + **`Up` is the only crossing event promised unconditionally**; a + crossing `Drag` is normalized and then deduped. - **Why this lane exists.** `PanelPointer` **replays nothing**: `dispatch_semantic_panel_pointer` (`src/editor.rs:2674`) validates, focuses, returns. A panel wheel is dead on both axes and so is every diff --git a/docs/bottom-panel-framing.md b/docs/bottom-panel-framing.md index 007325f..765cffa 100644 --- a/docs/bottom-panel-framing.md +++ b/docs/bottom-panel-framing.md @@ -7,7 +7,31 @@ protocol v20, 2026-07-24. Amended by the pre-implementation dependency verification in §0.6: the folding dependency is cleared, and one geometry caller-census error is corrected.** -**Revision 8 — 2026-08-14, AWAITING APPROVAL.** Answers review of 7: +**Revision 9 — 2026-08-14, AWAITING APPROVAL.** Answers review of 8. +Both items reverse a decision revision 8 made: + +- **Q#BP-R2 is OVERRULED — a terminal-chrome wheel is CONSUMED, not + clamped.** SGR wheel input is **coordinate-bearing**: `encode_mouse` + writes `coord.col + 1` / `coord.row + 1` into the sequence + (`src/terminal/input.rs:102`, `:146`), which A4 pins exactly. Clamping + **fabricates a hit on the final content row**, so a + position-routing application acts on a cell the user never pointed + at. And unlike `Up`, a wheel has **no liveness obligation** — + dropping one strands nothing. Consumed, never fallen through to the + document; TUI parity restored. +- **R-c2's field choice is corrected: a SEPARATE + `gesture_last_content_cell`.** Revision 8 said to retain the `Down` + cell in `last_pointer_cell`, which would break a **tested + guarantee** — that field is cleared on press precisely so the first + same-cell `Drag` reaches the daemon + (`pmacs-gpu/src/main.rs:19841`). Dedupe baseline and termination + fallback are different jobs with different lifetimes. +- **The crossing table follows:** a content-originated `Drag` over + chrome is **normalized and then subject to the ordinary dedupe**, not + promised as sent. **`Up` is the load-bearing crossing event** and the + only one promised unconditionally. + +**Previously, revision 8 — SUPERSEDED.** Answered review of 7: - **R-c is target × gesture-ORIGIN, not kind alone.** The TUI's per-kind rule is **document-only** — for terminals it rejects every @@ -2113,33 +2137,49 @@ instead of behaving as a content wheel does. |---|---|---|---| | **document** | `Down(Left)`, `Down(Right)` | do not arm, do not send | reserved — drop | | **document** | `Drag(Left)`, chrome-originated | not sent (never armed) | reserved — drop | -| **document** | `Drag(Left)`, **content-originated** | **sent** — the gesture is live | **process at the last valid CONTENT coordinate** | +| **document** | `Drag(Left)`, **content-originated** | normalized to the last valid CONTENT cell, then **subject to the ordinary dedupe** | process when it arrives | | **document** | `Up(Left)` | **send** | **process** — terminates the gesture | | **document** | wheel | **send** | **process** — scrolls the panel | | **terminal** | any kind, chrome-**originated** | do not arm, do not send | drop, matching the TUI | -| **terminal** | `Drag`/`Up`, **content-originated**, now over chrome | **send** | **replay at the last valid CONTENT coordinate** — never the chrome row | -| **terminal** | wheel over chrome | **send, clamped to content** | as a content wheel: reported when eligible, else local scrollback | +| **terminal** | `Drag`, content-originated, now over chrome | normalized, then **subject to the ordinary dedupe** | process when it arrives | +| **terminal** | `Up`, content-originated, now over chrome | **always sent** | **replay at the last valid CONTENT coordinate** — never the chrome row | +| **terminal** | wheel over chrome | **do not send — consume** | n/a; never falls through to the document (Q#BP-R2) | -**A content-originated gesture terminates normally, at its last valid -content coordinate.** That is the whole of the crossing rule, and it is -what keeps a reporting child from being told about a row it does not -own while still receiving its release. +**`Up` is the load-bearing crossing event, and the only one promised +unconditionally.** A content-originated gesture terminates at its last +valid content coordinate — that is what keeps a reporting child from +being told about a row it does not own while still receiving its +release. -#### Q#BP-R2 — a chrome wheel over a TERMINAL panel: clamp, or drop? **RULED: clamp** +**A crossing `Drag` is NOT promised as sent.** Once normalized, its +coordinate is frequently the one already reported, and the ordinary +motion dedupe suppresses it — correctly. Promising delivery would +oblige the producer to defeat its own dedupe for no gain: the daemon's +state after a suppressed same-cell `Drag` is identical. -The one product call inside R-c, flagged because it **diverges from the -TUI deliberately**. The TUI drops terminal mode-line wheels; this rules -that a panel clamps them into content instead. +#### Q#BP-R2 — a chrome wheel over a TERMINAL panel **RULED: CONSUME, do not clamp** -The reason is that the panel's mode line is not a window's mode line -among many — it is **the band's own chrome**, one row, and the document -panel already scrolls on a chrome wheel (the TUI does not guard the -wheel for documents). Dropping for terminals would make **the same -pixel behave differently depending on the buffer kind currently shown -there**, which is not a distinction a user is holding in mind while -scrolling. **Overrule this if you would rather have TUI parity**; it is -a one-line difference in the clamp and nothing else in R-c depends on -it. +Revision 8 ruled *clamp*, on a consistency argument. **That was wrong, +and the reason is that SGR wheel input is COORDINATE-BEARING.** +`encode_mouse` takes a `coord` and writes `coord.col + 1` and +`coord.row + 1` into the sequence (`src/terminal/input.rs:102`, +`:146`–`:147`) — which A4 now pins exactly. **Clamping would fabricate +a hit on the terminal's final content row**, so an application that +routes wheel input by position could act on a cell the user never +pointed at. The consistency gain is cosmetic; the cost is a synthetic +coordinate delivered to a program as though it were real. + +**And the wheel carries no liveness obligation.** `Up` must be +normalized because a gesture left un-terminated hangs — the daemon +holds a button down forever. A wheel tick is self-contained: dropping +one ends nothing and strands nothing. + +**Ruling: a wheel over a terminal panel's chrome is CONSUMED** — not +reported, not scrolled locally, and **not fallen through to the +document**. The band owns the pixel either way. This also restores TUI +parity, which revision 8 traded away for the weaker argument. + +Document chrome is unchanged: a wheel there still scrolls the panel. **Both crossings need witnesses, and they fail in opposite directions:** @@ -2170,13 +2210,52 @@ by a release over chrome or outside the band, with **no intervening motion**, has no coordinate to fall back to, so the `Up` is either dropped or sent with nothing. -**Retain the `Down` cell when arming.** Row: `Down` in content → -release over chrome, **no intervening motion** → the `Up` carries the -**`Down` cell's** coordinate. *Mutation: keep clearing the cell on arm -— the release has no coordinate.* **For a reporting terminal the row -asserts the exact child release BYTES**, not merely that the frontend -latch cleared: a latch that clears while the child never hears the -release is the failure this row exists for. +**Revision 8 said "retain the `Down` cell in that field". That would +break a tested guarantee.** `last_pointer_cell` is cleared on press +*deliberately*, and a live test says why: + +> *"A press or release re-arms it: the first drag after a press must +> reach the daemon even at the cell the press landed on."* +> (`pmacs-gpu/src/main.rs:19841`–`:19844`) + +Storing the `Down` cell there would make the press's own cell the +dedupe baseline and **suppress that first `Drag`**. + +**Ruled: a SEPARATE field, `gesture_last_content_cell`.** The two have +different jobs and conflating them was the error — +`last_pointer_cell` is a **wire dedupe baseline**, answering *"is this +motion worth sending?"*; the new field is a **termination fallback**, +answering *"where did this gesture last legitimately point?"*. They +have different lifetimes and different reset rules, so one field cannot +serve both without one job corrupting the other. + +- **Written** on arm (the `Down` cell) and on every accepted content + motion. +- **Reset** alongside the rest of the gesture state: on release, and on + **both** identity changes (R-d's D1 and D2). +- **Never consulted** by `panel_motion_is_new`, which keeps its own + baseline and its existing behaviour unchanged. + +*The alternative was considered and rejected:* ruling that `Down` +becomes the dedupe baseline, retiring the guarantee and its test. It +would need a justified mutation showing the first same-cell `Drag` is +redundant, and it is not obviously so — the daemon's drag state is +established by that event. **Preserving a tested contract beats +retiring one to save a field.** + +Row: `Down` in content → release over chrome, **no intervening +motion** → the `Up` carries the **`Down` cell's** coordinate. +*Mutation: reset `gesture_last_content_cell` on arm instead of writing +the `Down` cell — the release has no coordinate.* **For a reporting +terminal the row asserts the exact child release BYTES**, not merely +that the frontend latch cleared: a latch that clears while the child +never hears the release is the failure this row exists for. + +**A second mutation guards the separation itself:** make +`panel_motion_is_new` consult `gesture_last_content_cell`. The existing +first-`Drag`-after-press assertion (`:19841`) must fail — which is what +proves the new field did not quietly become the dedupe baseline after +all. #### R-d. Panel replacement leaves the frontend's gesture latch armed From ec127fc8fac95a1825c1e160e9f780b558b06a78 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 14 Aug 2026 16:28:18 +0200 Subject: [PATCH 06/33] docs(framing): bottom-panel revision 10 --- the ruling was on the wrong side of the seam Answers review of revision 9. Still framing only; no implementation. Q#BP-R2's outcome is unchanged; its ENFORCEMENT POINT moves. **THE GPU CANNOT KNOW WHETHER A PANEL HOLDS A TERMINAL.** `PanelFrame` carries `buffer_id`, both epochs, `size`, `cells`, `cursor` and `focused` --- and no target-kind discriminator (`pmacs-protocol/src/panel.rs:73`). The panel is an OPAQUE CELL PROJECTION to the frontend. `state.terminal` (`pmacs-gpu/src/main.rs:1964`) cannot answer either: it describes the primary full-window terminal, not the side-window. So revision 9's producer rule --- "terminal chrome wheel: do not send" --- was unimplementable without a new wire field, and this lane is explicitly non-protocol-bearing. I wrote a rule the frontend has no information to obey. The correction is structural, not cosmetic. **Every producer rule is now target-blind**, and the table is split by SEAM SIDE rather than by target: producer claims the chrome wheel and sends `PanelPointer` with its valid frame coordinate, for EVERY panel daemon after validation and resolving the side window --- document panel -> `scroll_window`; terminal panel -> CONSUME, producing no child bytes, no local scrollback, and no document fallthrough The other producer rules collapse safely because both targets want the same behaviour there: a chrome press is reserved by documents and dropped by terminals --- both drop --- and a crossing `Up` must arrive for either, to terminate a selection or deliver a release. The wheel was the sole divergence, which is exactly why it was the rule that could not survive on the producer side. The chrome coordinate does cross the wire now, and that is fine: it is a valid frame cell, coord validation accepts it, `scroll_window` is a window-level move that never reads it, and the terminal branch never forwards it to a child. Nothing is fabricated and nothing reaches an application --- which was the whole objection to clamping. **Witness: ONE frontend across a document->terminal replacement.** Document chrome wheel scrolls the panel; after replacement the terminal chrome wheel changes nothing. Doing it in one frontend across a replacement is what makes it a control rather than two unrelated observations --- geometry, pointer position and producer path are identical, and only the target differs. Mutation: let the terminal branch call `apply_terminal_gesture`, and the chrome coordinate fails its reporting bounds check, falls into the local branch, and the row catches the accidental local scrollback that revision 8's clamp would have shipped on purpose. Gates: all nine green under `env -u TMPDIR`, log 20260814T142336Z. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 12 +++- docs/bottom-panel-framing.md | 108 ++++++++++++++++++++++++++++++----- 2 files changed, 106 insertions(+), 14 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 3cb9d0b..fc95bb4 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -281,7 +281,7 @@ from #171 and #215 — the correction the 1b lane missed, honoured here. **`githubsucks/panel-pointer-replay` is the authoritative tip** (the ref, not a SHA). Recover with `git fetch githubsucks && git checkout panel-pointer-replay`. -- **No PR yet. Checkpoint: framing revision 9 (§5a) AWAITING APPROVAL; +- **No PR yet. Checkpoint: framing revision 10 (§5a) AWAITING APPROVAL; NO IMPLEMENTATION WRITTEN.** Commit one was the ground-truth re-measurement; 6 added the four replay edges; **7 answers review of 6; **8 answers review of 7** — R-c is target × gesture-ORIGIN @@ -300,6 +300,16 @@ from #171 and #215 — the correction the 1b lane missed, honoured here. same-cell `Drag` reaches the daemon (`pmacs-gpu/src/main.rs:19841`). **`Up` is the only crossing event promised unconditionally**; a crossing `Drag` is normalized and then deduped. + **Revision 10** keeps Q#BP-R2's outcome and moves its **enforcement + point**: the GPU **cannot know a panel holds a terminal** — + `PanelFrame` has no target-kind field + (`pmacs-protocol/src/panel.rs:73`) and `state.terminal` is the + primary full-window terminal — so a producer-side rule needed a new + wire field this lane must not add. **The producer is target-blind** + and sends the chrome wheel for every panel; **the daemon** resolves + the side window and decides: document → `scroll_window`, terminal → + consume. Witness: one frontend across a document→terminal + replacement. - **Why this lane exists.** `PanelPointer` **replays nothing**: `dispatch_semantic_panel_pointer` (`src/editor.rs:2674`) validates, focuses, returns. A panel wheel is dead on both axes and so is every diff --git a/docs/bottom-panel-framing.md b/docs/bottom-panel-framing.md index 765cffa..3c45220 100644 --- a/docs/bottom-panel-framing.md +++ b/docs/bottom-panel-framing.md @@ -7,8 +7,29 @@ protocol v20, 2026-07-24. Amended by the pre-implementation dependency verification in §0.6: the folding dependency is cleared, and one geometry caller-census error is corrected.** -**Revision 9 — 2026-08-14, AWAITING APPROVAL.** Answers review of 8. -Both items reverse a decision revision 8 made: +**Revision 10 — 2026-08-14, AWAITING APPROVAL.** Answers review of 9, +which put a correct ruling **on the wrong side of the seam**. + +**The GPU cannot know whether a panel holds a terminal.** `PanelFrame` +carries `buffer_id`, both epochs, `size`, `cells`, `cursor` and +`focused` and **no target-kind discriminator** +(`pmacs-protocol/src/panel.rs:73`); `state.terminal` +(`pmacs-gpu/src/main.rs:1964`) is the **primary full-window** terminal, +not the side-window projection. So revision 9's producer rule +*"terminal chrome wheel: do not send"* was unimplementable without a +new wire field, which this lane must not add. + +**Q#BP-R2's OUTCOME is unchanged; its ENFORCEMENT POINT moves.** The +producer is now target-blind throughout — it claims the chrome wheel +and sends it for every panel — and the daemon, which resolves the side +window and knows the buffer kind, decides: document → `scroll_window`; +terminal → **consume**, with no child bytes, no local scrollback and no +document fallthrough. The producer/receiver tables are split by seam +side accordingly, and the witness is **one frontend across a +document→terminal replacement**, so only the target differs. + +**Previously, revision 9 — SUPERSEDED.** Both items reversed a decision +revision 8 made: - **Q#BP-R2 is OVERRULED — a terminal-chrome wheel is CONSUMED, not clamped.** SGR wheel input is **coordinate-bearing**: `encode_mouse` @@ -2133,17 +2154,53 @@ branch**. Two concrete wrongnesses follow: a reporting child gets a `finish_selection`), and a chrome wheel becomes **local scrollback** instead of behaving as a content wheel does. -| target | kind | producer | receiver | -|---|---|---|---| -| **document** | `Down(Left)`, `Down(Right)` | do not arm, do not send | reserved — drop | -| **document** | `Drag(Left)`, chrome-originated | not sent (never armed) | reserved — drop | -| **document** | `Drag(Left)`, **content-originated** | normalized to the last valid CONTENT cell, then **subject to the ordinary dedupe** | process when it arrives | -| **document** | `Up(Left)` | **send** | **process** — terminates the gesture | -| **document** | wheel | **send** | **process** — scrolls the panel | -| **terminal** | any kind, chrome-**originated** | do not arm, do not send | drop, matching the TUI | -| **terminal** | `Drag`, content-originated, now over chrome | normalized, then **subject to the ordinary dedupe** | process when it arrives | -| **terminal** | `Up`, content-originated, now over chrome | **always sent** | **replay at the last valid CONTENT coordinate** — never the chrome row | -| **terminal** | wheel over chrome | **do not send — consume** | n/a; never falls through to the document (Q#BP-R2) | +##### The producer is TARGET-BLIND, and the table must respect that + +**Revision 9 split the producer column by target. The GPU cannot make +that split.** `PanelFrame` carries `buffer_id`, both epochs, `size`, +`cells`, `cursor` and `focused` — **and no target-kind discriminator** +(`pmacs-protocol/src/panel.rs:73` onward). The panel is an **opaque +cell projection** to the frontend. `state.terminal` +(`pmacs-gpu/src/main.rs:1964`) describes the **primary full-window** +terminal, not the side-window, so it cannot answer the question either. + +A producer rule reading "terminal chrome wheel: do not send" is +therefore **unimplementable without a new wire field**, and this lane +is explicitly non-protocol-bearing. **Every producer rule must be +target-blind; every target-dependent decision belongs to the daemon**, +which resolves the side window and knows the buffer kind +(`is_terminal(buffer_id)`). + +**Producer — by kind and origin only:** + +| kind | origin | producer | +|---|---|---| +| `Down(Left)`, `Down(Right)` | chrome | **do not arm, do not send** | +| `Drag(Left)` | chrome | not sent — never armed | +| `Drag(Left)` | content, now over chrome | normalize to the last valid CONTENT cell, then **ordinary dedupe** | +| `Up(Left)` | content, now over chrome | **always send**, normalized | +| wheel | chrome | **claim it and send**, carrying its valid frame coordinate | + +These collapse safely because the two targets want the **same producer +behaviour** everywhere except the wheel: a chrome press is reserved by +documents and dropped by terminals — both drop; a crossing `Up` must +arrive for both, to terminate a selection or to deliver a release. The +wheel is the sole divergence, and it is now decided receiver-side. + +**Receiver — after validation and resolving the side window:** + +| kind | document panel | terminal panel | +|---|---|---| +| chrome `Down`/`Drag` | reserved — drop | drop, matching the TUI | +| crossing `Drag` (normalized) | process when it arrives | process at the content coordinate | +| crossing `Up` (normalized) | terminate the gesture | **replay at the last valid CONTENT coordinate** — never the chrome row | +| chrome wheel | **process through `scroll_window`** | **CONSUME** — no child bytes, no local scrollback, no document fallthrough (Q#BP-R2) | + +**The chrome wheel carries a chrome coordinate over the wire, and that +is fine**: it is a valid frame cell, the daemon's coord validation +accepts it, `scroll_window` is a window-level move that does not read +it, and the terminal branch never forwards it to a child. **The +coordinate is never fabricated and never reaches an application.** **`Up` is the load-bearing crossing event, and the only one promised unconditionally.** A content-originated gesture terminates at its last @@ -2181,6 +2238,31 @@ parity, which revision 8 traded away for the weaker argument. Document chrome is unchanged: a wheel there still scrolls the panel. +**Enforced in the DAEMON, not the frontend.** Revision 9 wrote this as +a producer rule, which the GPU cannot implement — it has no way to know +the panel holds a terminal (see the seam note above). The producer +claims the chrome wheel and sends it for **every** panel; the daemon +resolves the side window and consumes it when the buffer is a terminal. + +**Witness — one frontend across a document→terminal replacement**, so +the two outcomes are separated by nothing but the buffer kind: + +1. panel shows a **document**; wheel over chrome → **the panel + scrolls**. +2. the panel is replaced by a **terminal**; wheel over chrome → + **nothing changes** — no child bytes, no scrollback movement, no + document scroll. + +*Mutation: let the terminal branch call `apply_terminal_gesture` — the +chrome coordinate fails its reporting bounds check, falls into the +local branch, and the row catches the **accidental local scrollback** +that revision 8's clamp would have shipped deliberately.* + +Doing it in one frontend across a replacement is what makes it a +control rather than two unrelated observations: the geometry, the +pointer position and the producer path are identical, and only the +target differs. + **Both crossings need witnesses, and they fail in opposite directions:** From 112fcf593f31866acfdeda7ae961ded4e50b9105 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 14 Aug 2026 16:41:02 +0200 Subject: [PATCH 07/33] docs(framing): bottom-panel revision 11 --- consume the chrome wheel before ACTIVATION Answers review of revision 10. Still framing only; no implementation. **"CONSUME BEFORE `apply_terminal_gesture`" WAS NOT EARLY ENOUGH.** `activates` is `!matches!(kind, Move)` for a terminal panel (`src/editor.rs:2695`), which INCLUDES the wheel, and focus plus `active_frontend` are written at `:2699` --- ahead of any replay decision. A consume check below that block leaves the wheel CHANGING FOCUS while scrolling nothing and claiming no controller: exactly the half-state AC48's activate-then-claim rule exists to prevent. The panel steals focus and does not move. A terminal-chrome wheel is therefore not a terminal gesture at all, and the dispatcher must treat it that way. The order is now stated: 1. authenticate and validate (the existing ladder) 2. resolve the side window and its buffer kind 3. terminal + chrome wheel -> CONSUME IMMEDIATELY, before `focus_window`, `active_frontend`, any controller claim, any command-chain mutation, and the shared terminal path 4. otherwise, the existing activation and replay rules Step 3 is implementable where it belongs: `is_terminal` is resolved from `buffer_id` at `:2687`, before the `core` borrow and before the activation block, so the kind is already in hand. **The witness gains focus and controller assertions, and they are load-bearing.** The document->terminal replacement row now asserts the focused window and terminal controller identity are unchanged, on top of no child bytes, no scrollback and no document movement. Two mutations bite different halves: - terminal branch calls `apply_terminal_gesture` -> the chrome coordinate fails the reporting bounds check, falls into the local branch, and the row catches the accidental local scrollback - the consume check sits BELOW activation -> nothing scrolls, so every movement assertion still passes and ONLY the focus and controller assertions catch it That second mutation is the reason the new assertions exist. Without them the row stays green against an implementation that steals focus on every chrome wheel over a terminal, because the half-state is invisible to any assertion about movement --- nothing moves either way. Gates: all nine green under `env -u TMPDIR`, log 20260814T143558Z. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 12 +++++- docs/bottom-panel-framing.md | 73 +++++++++++++++++++++++++++++++----- 2 files changed, 75 insertions(+), 10 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index fc95bb4..980d262 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -281,7 +281,7 @@ from #171 and #215 — the correction the 1b lane missed, honoured here. **`githubsucks/panel-pointer-replay` is the authoritative tip** (the ref, not a SHA). Recover with `git fetch githubsucks && git checkout panel-pointer-replay`. -- **No PR yet. Checkpoint: framing revision 10 (§5a) AWAITING APPROVAL; +- **No PR yet. Checkpoint: framing revision 11 (§5a) AWAITING APPROVAL; NO IMPLEMENTATION WRITTEN.** Commit one was the ground-truth re-measurement; 6 added the four replay edges; **7 answers review of 6; **8 answers review of 7** — R-c is target × gesture-ORIGIN @@ -310,6 +310,16 @@ from #171 and #215 — the correction the 1b lane missed, honoured here. the side window and decides: document → `scroll_window`, terminal → consume. Witness: one frontend across a document→terminal replacement. + **Revision 11** fixes the ORDERING: the terminal-chrome wheel is + consumed **before activation**, not merely before + `apply_terminal_gesture`. `activates` is `!matches!(kind, Move)` for + a terminal (`src/editor.rs:2695`), so the wheel already writes focus + and `active_frontend` at `:2699` ahead of any replay decision — a + consume check below that would change focus while scrolling nothing + and claiming no controller. Four-step order, consumption at step 3; + the witness now asserts **focus and controller identity unchanged**, + which is what catches the consume-below-activation mutation, since + that mutation moves nothing. - **Why this lane exists.** `PanelPointer` **replays nothing**: `dispatch_semantic_panel_pointer` (`src/editor.rs:2674`) validates, focuses, returns. A panel wheel is dead on both axes and so is every diff --git a/docs/bottom-panel-framing.md b/docs/bottom-panel-framing.md index 3c45220..cbb8c68 100644 --- a/docs/bottom-panel-framing.md +++ b/docs/bottom-panel-framing.md @@ -7,8 +7,25 @@ protocol v20, 2026-07-24. Amended by the pre-implementation dependency verification in §0.6: the folding dependency is cleared, and one geometry caller-census error is corrected.** -**Revision 10 — 2026-08-14, AWAITING APPROVAL.** Answers review of 9, -which put a correct ruling **on the wrong side of the seam**. +**Revision 11 — 2026-08-14, AWAITING APPROVAL.** Answers review of 10. + +**The terminal-chrome wheel must be consumed BEFORE ACTIVATION**, not +merely before `apply_terminal_gesture`. `activates` is +`!matches!(kind, Move)` for a terminal panel +(`src/editor.rs:2695`), so the wheel already activates: focus and +`active_frontend` are written at `:2699` ahead of any replay decision. +A consume check below that block would leave the wheel **changing focus +while scrolling nothing and claiming no controller** — the precise +half-state AC48's activate-then-claim rule exists to prevent. §5a now +states the four-step order, with consumption at step 3, and the +document→terminal witness asserts **focus and controller identity +unchanged** alongside no bytes, no scrollback and no document +movement — assertions that are load-bearing, because the +consume-below-activation mutation moves nothing and is invisible +without them. + +**Previously, revision 10 — SUPERSEDED.** Answered review of 9, which +put a correct ruling **on the wrong side of the seam**. **The GPU cannot know whether a panel holds a terminal.** `PanelFrame` carries `buffer_id`, both epochs, `size`, `cells`, `cursor` and @@ -2194,7 +2211,7 @@ wheel is the sole divergence, and it is now decided receiver-side. | chrome `Down`/`Drag` | reserved — drop | drop, matching the TUI | | crossing `Drag` (normalized) | process when it arrives | process at the content coordinate | | crossing `Up` (normalized) | terminate the gesture | **replay at the last valid CONTENT coordinate** — never the chrome row | -| chrome wheel | **process through `scroll_window`** | **CONSUME** — no child bytes, no local scrollback, no document fallthrough (Q#BP-R2) | +| chrome wheel | **process through `scroll_window`** | **CONSUME, before activation** — no focus change, no controller claim, no child bytes, no local scrollback, no document fallthrough (Q#BP-R2) | **The chrome wheel carries a chrome coordinate over the wire, and that is fine**: it is a valid frame cell, the daemon's coord validation @@ -2244,19 +2261,57 @@ the panel holds a terminal (see the seam note above). The producer claims the chrome wheel and sends it for **every** panel; the daemon resolves the side window and consumes it when the buffer is a terminal. +##### And it is consumed BEFORE ACTIVATION, not merely before replay + +**"Consume before `apply_terminal_gesture`" is not early enough.** +Every non-`Move` terminal panel gesture activates the side window +first, and `activates` is computed as `!matches!(kind, Move)` +(`src/editor.rs:2695`–`:2698`), which **includes the wheel**. Focus and +`active_frontend` are written at `:2699`–`:2701`, *before* any replay +decision. + +A consume check placed after that block leaves the exact half-state +AC48's activate-then-claim rule exists to prevent: **the wheel changes +FOCUS while scrolling nothing and claiming no controller.** The panel +steals focus and does not move. + +**A terminal-chrome wheel is not a terminal gesture at all**, and the +dispatcher must treat it that way. The order is: + +1. Authenticate and validate the panel event (the existing ladder). +2. Resolve the side window and determine its **buffer kind**. +3. **If terminal + chrome wheel: consume IMMEDIATELY** — before + `focus_window`, before `active_frontend`, before any controller + claim, before any command-chain mutation, and before the shared + terminal path. +4. Otherwise, the existing activation and replay rules. + +Step 3 is implementable where it needs to be: `is_terminal` is resolved +from `buffer_id` **before** the activation block, so the kind is +already known at that point. + **Witness — one frontend across a document→terminal replacement**, so the two outcomes are separated by nothing but the buffer kind: 1. panel shows a **document**; wheel over chrome → **the panel scrolls**. 2. the panel is replaced by a **terminal**; wheel over chrome → - **nothing changes** — no child bytes, no scrollback movement, no - document scroll. + **nothing changes at all** — no child bytes, no scrollback movement, + no document scroll, **and the focused window and terminal controller + identity are unchanged**. -*Mutation: let the terminal branch call `apply_terminal_gesture` — the -chrome coordinate fails its reporting bounds check, falls into the -local branch, and the row catches the **accidental local scrollback** -that revision 8's clamp would have shipped deliberately.* +Two mutations, biting different halves: + +| mutation | must fail | +|---|---| +| the terminal branch calls `apply_terminal_gesture` | the chrome coordinate fails its reporting bounds check, drops into the local branch, and the row catches the **accidental local scrollback** revision 8's clamp would have shipped deliberately | +| the consume check sits **below** the activation block | nothing scrolls, so bytes/scrollback/document all still pass — **only the focus and controller assertions catch it** | + +**The second mutation is why the focus and controller assertions are +load-bearing.** Without them the row is green against an +implementation that steals focus on every chrome wheel over a +terminal — the half-state is invisible to any assertion about +movement, because nothing moves either way. Doing it in one frontend across a replacement is what makes it a control rather than two unrelated observations: the geometry, the From 78e5bec2d4ef69eb69a09e0e7c61f6a93a8b5131 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 14 Aug 2026 16:49:54 +0200 Subject: [PATCH 08/33] docs(framing): bottom-panel revision 12 --- the passive precondition, and two assertions that are not interchangeable Answers review of revision 11. Still framing only; no implementation. **"FOCUS UNCHANGED" IS VACUOUS IF THE PANEL IS ALREADY FOCUSED.** With the terminal side window active, the below-activation mutation calls `focus_window` on the window that is ALREADY active --- nothing changes, and the row stays green against the exact bug it exists to catch. I added the assertion in revision 11 and did not give it a state in which it could fail. Leg 2 now starts passive, and says so as assertions rather than setup: the primary document window is active, the terminal side window is distinct from it and passive, and the terminal controller identity is captured as a baseline. Asserted rather than arranged, because each is the kind of detail a later edit changes silently, and each failure mode is a witness that passes while proving nothing. **AND THE TWO ASSERTIONS ARE NOT INTERCHANGEABLE**, which revision 11's prose blurred by naming them together as though either would do: consume check below activation -> `focus_window` runs, the panel takes focus, and NOTHING scrolls, so every movement assertion still passes -> caught by FOCUS, and focus alone terminal branch calls `apply_terminal_gesture` -> `claims_control` is `!matches!(kind, Move)` (`:3555`), so a wheel CLAIMS THE PANEL'S CONTROLLER at `:3571`, before any local handling at `:3575`; the chrome coordinate then fails the reporting bounds check and drops into the local branch, which may also move scrollback -> caught by CONTROLLER IDENTITY, plus scrollback when it moves Activation alone claims nothing --- `focus_window` and `claim_terminal_controller` are separate acts --- so a row asserting only controller identity misses the ordering bug entirely, and one asserting only focus misses a replay that quietly claims the terminal. Gates: all nine green under `env -u TMPDIR`, log 20260814T144513Z. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 16 +++++++--- docs/bottom-panel-framing.md | 58 +++++++++++++++++++++++++++++------- 2 files changed, 59 insertions(+), 15 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 980d262..9c7713d 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -281,7 +281,7 @@ from #171 and #215 — the correction the 1b lane missed, honoured here. **`githubsucks/panel-pointer-replay` is the authoritative tip** (the ref, not a SHA). Recover with `git fetch githubsucks && git checkout panel-pointer-replay`. -- **No PR yet. Checkpoint: framing revision 11 (§5a) AWAITING APPROVAL; +- **No PR yet. Checkpoint: framing revision 12 (§5a) AWAITING APPROVAL; NO IMPLEMENTATION WRITTEN.** Commit one was the ground-truth re-measurement; 6 added the four replay edges; **7 answers review of 6; **8 answers review of 7** — R-c is target × gesture-ORIGIN @@ -317,9 +317,17 @@ from #171 and #215 — the correction the 1b lane missed, honoured here. and `active_frontend` at `:2699` ahead of any replay decision — a consume check below that would change focus while scrolling nothing and claiming no controller. Four-step order, consumption at step 3; - the witness now asserts **focus and controller identity unchanged**, - which is what catches the consume-below-activation mutation, since - that mutation moves nothing. + the witness now asserts **focus and controller identity unchanged**. + **Revision 12** makes that setup discriminating: leg 2 must **start + PASSIVE** — primary document window active, terminal side window + distinct and passive, controller baseline captured — because "focus + unchanged" is vacuous if the panel is already focused, and the + below-activation mutation would then call `focus_window` on the + already-active window and pass. The two assertions are **not + interchangeable**: **focus** catches the ordering mutation; + **controller identity** catches the shared-path mutation, since + `apply_terminal_gesture` claims at `src/editor.rs:3571` before local + handling and activation alone claims nothing. - **Why this lane exists.** `PanelPointer` **replays nothing**: `dispatch_semantic_panel_pointer` (`src/editor.rs:2674`) validates, focuses, returns. A panel wheel is dead on both axes and so is every diff --git a/docs/bottom-panel-framing.md b/docs/bottom-panel-framing.md index cbb8c68..f6513e6 100644 --- a/docs/bottom-panel-framing.md +++ b/docs/bottom-panel-framing.md @@ -7,7 +7,21 @@ protocol v20, 2026-07-24. Amended by the pre-implementation dependency verification in §0.6: the folding dependency is cleared, and one geometry caller-census error is corrected.** -**Revision 11 — 2026-08-14, AWAITING APPROVAL.** Answers review of 10. +**Revision 12 — 2026-08-14, AWAITING APPROVAL.** Answers review of 11, +whose witness had a **vacuous precondition**. + +"Focus unchanged" proves nothing if the terminal panel is **already +focused**: the below-activation mutation then calls `focus_window` on +the already-active side window, nothing changes, and the row passes. +Leg 2 must **start passive**, and assert it — primary document window +active, terminal side window distinct and passive, controller baseline +captured. The prose is also narrowed: **focus** catches the +below-activation ordering mutation; **controller identity** catches the +shared-path mutation, since `apply_terminal_gesture` claims the +controller at `src/editor.rs:3571` before local handling. Activation +alone claims nothing, so the two assertions are not interchangeable. + +**Previously, revision 11 — SUPERSEDED.** Answered review of 10. **The terminal-chrome wheel must be consumed BEFORE ACTIVATION**, not merely before `apply_terminal_gesture`. `activates` is @@ -2300,18 +2314,40 @@ the two outcomes are separated by nothing but the buffer kind: no document scroll, **and the focused window and terminal controller identity are unchanged**. -Two mutations, biting different halves: +##### Leg 2 must START PASSIVE, or "focus unchanged" is vacuous -| mutation | must fail | -|---|---| -| the terminal branch calls `apply_terminal_gesture` | the chrome coordinate fails its reporting bounds check, drops into the local branch, and the row catches the **accidental local scrollback** revision 8's clamp would have shipped deliberately | -| the consume check sits **below** the activation block | nothing scrolls, so bytes/scrollback/document all still pass — **only the focus and controller assertions catch it** | +**A focus assertion proves nothing if the panel already has focus.** +With the terminal side window active, moving the consume check below +activation calls `focus_window` on **the window that is already +active** — nothing changes, and the row stays green against the very +mutation it exists to catch. -**The second mutation is why the focus and controller assertions are -load-bearing.** Without them the row is green against an -implementation that steals focus on every chrome wheel over a -terminal — the half-state is invisible to any assertion about -movement, because nothing moves either way. +**Preconditions, asserted before the wheel, not merely arranged:** + +- the **primary document window is active**; +- the **terminal side window is distinct from it and PASSIVE**; +- the **terminal controller identity is captured as a baseline**. + +Asserted rather than assumed, because each is exactly the kind of setup +detail a later edit silently changes — and each failure mode is a +witness that passes while proving nothing. + +**With that setup the two mutations separate cleanly, and each is +caught by a DIFFERENT assertion:** + +| mutation | what it does | caught by | +|---|---|---| +| the consume check sits **below** the activation block | `focus_window` runs and the panel takes focus; nothing scrolls, so every movement assertion still passes | **focus** — and focus alone | +| the terminal branch calls `apply_terminal_gesture` | `claims_control` is `!matches!(kind, Move)` (`src/editor.rs:3555`), so a wheel **claims the panel's controller at `:3571`, before any local handling** at `:3575`; the chrome coordinate then fails the reporting bounds check and drops into the local branch, which may also move scrollback | **controller identity** — plus scrollback when it moves | + +**The two assertions are not interchangeable**, which revision 11's +prose blurred by naming them together. **Focus catches the ordering +mutation; controller identity catches the shared-path mutation.** +Activation alone does not claim a controller — `focus_window` and +`claim_terminal_controller` are separate acts — so a row asserting only +controller identity would miss the below-activation ordering bug +entirely, and one asserting only focus would miss a replay that quietly +claims the terminal. Doing it in one frontend across a replacement is what makes it a control rather than two unrelated observations: the geometry, the From 515a4515be8bc3ef19a1fe258b32dc5cce1bcf24 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 14 Aug 2026 16:55:24 +0200 Subject: [PATCH 09/33] docs(framing): mark bottom-panel revision 12 approved SS5a is the acceptance-48 ground truth and its rulings are settled: Q#BP-R1 (a single click selects a listview row only), Q#BP-R2 (a terminal-chrome wheel is consumed daemon-side, before activation), the R-a..R-d replay edges, and the witness matrices A1-A5, B1-B6, D1-D4. Also records what the eight rounds cost, because the pattern repeated: every round found a witness or a rule that would have PASSED against a broken implementation. A `#[cfg(test)]` fixture cited as a production handler. Activation ordering mistaken for a safety proof. A mode-line rule that was per-row when the tree's is per-kind, and document-only at that. A producer rule the frontend had no information to obey. A consume check that would have left focus stolen. And twice, an assertion added without a state in which it could fail. No round found a design disagreement. The rulings from 8 and 9 survived intact; what changed each time was WHERE a rule lived or WHAT A WITNESS COULD SEE. Implementation begins. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 4 ++-- docs/bottom-panel-framing.md | 22 ++++++++++++++++++++-- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 9c7713d..4becad9 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -281,8 +281,8 @@ from #171 and #215 — the correction the 1b lane missed, honoured here. **`githubsucks/panel-pointer-replay` is the authoritative tip** (the ref, not a SHA). Recover with `git fetch githubsucks && git checkout panel-pointer-replay`. -- **No PR yet. Checkpoint: framing revision 12 (§5a) AWAITING APPROVAL; - NO IMPLEMENTATION WRITTEN.** Commit one was the ground-truth +- **No PR yet. Checkpoint: framing revision 12 (§5a) APPROVED + 2026-08-14; IMPLEMENTATION BEGINS.** Nothing gates this lane now. Commit one was the ground-truth re-measurement; 6 added the four replay edges; **7 answers review of 6; **8 answers review of 7** — R-c is target × gesture-ORIGIN (terminals reject all chrome kinds and raw chrome coords fail the diff --git a/docs/bottom-panel-framing.md b/docs/bottom-panel-framing.md index f6513e6..48e04b4 100644 --- a/docs/bottom-panel-framing.md +++ b/docs/bottom-panel-framing.md @@ -7,8 +7,26 @@ protocol v20, 2026-07-24. Amended by the pre-implementation dependency verification in §0.6: the folding dependency is cleared, and one geometry caller-census error is corrected.** -**Revision 12 — 2026-08-14, AWAITING APPROVAL.** Answers review of 11, -whose witness had a **vacuous precondition**. +**Revision 12 — 2026-08-14, APPROVED.** §5a is the acceptance-48 +ground truth and its rulings are settled: **Q#BP-R1** (a single click +selects a listview row only), **Q#BP-R2** (a terminal-chrome wheel is +consumed, daemon-side, before activation), the **R-a…R-d** replay +edges, and the witness matrices A1–A5, B1–B6, D1–D4. + +**§5a took eight review rounds (5–12), and the pattern is worth +keeping: every round found a witness or a rule that would have passed +against a broken implementation.** A test fixture cited as a production +handler; activation ordering mistaken for a safety proof; a mode-line +rule that was per-row when the tree's is per-kind, and document-only at +that; a producer rule the frontend had no information to obey; a +consume check that would have left focus stolen; and — twice — an +assertion added without a state in which it could fail. **No round +found a design disagreement.** The rulings from 8 and 9 survived +intact, and what changed each time was *where* a rule lived or *what a +witness could see*. + +Revision 12 answered review of 11, whose witness had a **vacuous +precondition**. "Focus unchanged" proves nothing if the terminal panel is **already focused**: the below-activation mutation then calls `focus_window` on From 4bd3a76618ebb14dc68834dbbf203a72b2c533e8 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 14 Aug 2026 17:24:04 +0200 Subject: [PATCH 10/33] feat(panel): replay PanelPointer --- the wiring, the seam, and the producer half First implementation commit for parent acceptance 48. The daemon-side replay and every producer-side rule land here; the daemon-side witness matrices (A1-A5, B1-B6, and Q#BP-R2's document->terminal row) are the next commit and are NOT claimed yet. **MODIFIERS NOW CROSS THE SEAM (R-a).** The daemon destructured `mods` into `..` and the dispatcher had no parameter for them, which inverted two behaviours rather than degrading them: `apply_terminal_gesture` gates child reporting on `!shift`, so Shift is the user's "select locally instead of talking to the child" override, and the document path reads Shift to extend the selection. **THE REPLAY IS THE SHARED PATHS, NOT NEW ONES.** A terminal panel goes through `apply_terminal_gesture` --- "the one terminal pointer path, shared by both frontend kinds" --- with the side window's `TerminalViewKey` and a viewport of `rows - 1`, never the full grid: the frame would make the mode line a child cell and put every clamp a row out. A document panel scrolls through the window-scoped `scroll_window` and replays selection through new window-TARGETED writers. Those writers exist because the selection API is active-window scoped. `Drag` and `Up` do not activate, and another frontend can interleave between a `Down` and its tail, so a replay reading `active_window_mut()` would act on whatever happened to be active then. `panel_cell_byte` converts against the SIDE window's own `view_top` and fold map without `activate_and_position`'s `set_active_window_id`. The one place the ambient helper is used is the double-click word selection, two statements after the `Down` activated that window synchronously, and it says so. **Q#BP-R2 IS ORDERED, NOT MERELY PLACED.** A terminal panel's chrome wheel is consumed before `focus_window`, before `active_frontend`, before any controller claim and before the shared path --- `activates` is `!Move` for a terminal, so a check any lower would leave the wheel changing FOCUS while scrolling nothing. Producer half, all target-blind because `PanelFrame` carries no target-kind field: - a press on the band's MODE LINE neither sends nor arms. Arming would let a drag into content emit a `Drag` with no accepted `Down`, which no receiver-side rule can undo. - `gesture_last_content_cell`, a TERMINATION FALLBACK distinct from the dedupe baseline. `last_pointer_cell` is cleared on press precisely so the first drag after a press reaches the daemon (asserted at `main.rs:19841`); storing the press cell there would suppress it. The new field is written on arm and on each accepted content motion, cleared on release and on either identity change, and `panel_motion_is_new` never consults it. - a crossing `Drag` is normalized and then deduped; `Up` is always sent, always at a content coordinate. - the gesture latch now dies on a change of EITHER identity --- panel or geometry --- and survives a same-identity repaint. Six mutations, each biting its own row: M-P1 arm on a chrome press -> the producer arming row M-P2 release reads the dedupe field -> the chrome and no-motion rows M-D1 no reset on panel epoch -> the identity row M-D2 no reset on geometry epoch -> the identity row M-D3 reset clears `pointer_held` only -> the identity row M-D4 reset on every frame -> the identity row's negative leg M-P2 caught a defect in my own witness before it caught the code: the no-intervening-motion row called `panel_motion_is_new` BEFORE asserting the release, which populated the very field the mutation reads, so a conflated implementation passed. The probe now runs after the assertion, and the row is named for a scenario it actually performs. One existing test moved with the contract rather than against it: `a_held_button_makes_panel_motion_a_drag_and_a_release_lands_outside` poked `panel_motion_is_new` and expected the release to follow it. It now drives both fields as the production motion path does; its assertion, and the dedupe guarantee it protects, are unchanged. Gates: all nine green under `env -u TMPDIR`, log 20260814T151901Z. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- pmacs-gpu/src/main.rs | 369 +++++++++++++++++++++++++++++++++++++++++- src/daemon.rs | 10 +- src/editor.rs | 233 ++++++++++++++++++++++++-- 3 files changed, 599 insertions(+), 13 deletions(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index c42fd94..b06a7b1 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -2131,6 +2131,16 @@ struct PanelBand { /// because the first drag after a press must reach the daemon even at the /// cell the press landed on. last_pointer_cell: Option, + /// Where the live gesture last legitimately pointed **inside content**, + /// used to normalize a release that lands on chrome or outside the band + /// (parent 48 R-c2). + /// + /// **Deliberately NOT `last_pointer_cell`.** That field is a wire + /// DEDUPE BASELINE and is cleared on press precisely so the first drag + /// after a press reaches the daemon; storing the press cell there would + /// suppress it. This one is a TERMINATION FALLBACK with a different + /// lifetime, and `panel_motion_is_new` never consults it. + gesture_last_content_cell: Option, /// Whether the pointer is currently over the divider strip, which /// decides the `RowResize` cursor icon. hover_divider: bool, @@ -2753,6 +2763,25 @@ impl App { // so reporting a drag as a hover makes a selection // drag silently do nothing. let kind = state.panel_motion_kind(); + let is_chrome = state.panel_cell_is_chrome(coord); + if !is_chrome { + let state = self.state.as_mut().expect("checked above"); + state.panel.gesture_last_content_cell = Some(coord); + } + let state = self.state.as_mut().expect("checked above"); + if is_chrome { + // A crossing drag is normalized to the last content cell + // and then deduped like any other motion — usually + // suppressed, because that cell was already reported. + let Some(normalized) = state.panel.gesture_last_content_cell else { + return; + }; + if state.panel_motion_is_new(normalized) { + let mods = translate_mods(self.modifiers); + self.send_panel_pointer_at_cell(Some(normalized), kind, mods); + } + return; + } if state.panel_motion_is_new(coord) { let mods = translate_mods(self.modifiers); self.send_panel_pointer_at(x, y, kind, mods); @@ -2875,9 +2904,22 @@ impl App { // no preceding `Down`, and not arming at all means // `Drag(Left)` is never emitted and panel selection // cannot work at all. - if let PointerSurface::PanelCell(_) = panel_surface { + if let PointerSurface::PanelCell(coord) = panel_surface { + // Parent 48 R-c: a press on the band's MODE LINE is + // reserved, and must not arm. Arming would let a drag + // into content emit a `Drag` with no accepted `Down` — + // an orphan the daemon cannot tell from a real gesture, + // and one no receiver-side rule can prevent, because the + // frontend has already latched. + if state.panel_cell_is_chrome(coord) { + return; + } let state = self.state.as_mut().expect("checked above"); state.set_panel_pointer_held(true); + // R-c2: the TERMINATION FALLBACK, not the dedupe + // baseline. `set_panel_pointer_held` just cleared the + // latter on purpose. + state.panel.gesture_last_content_cell = Some(coord); self.send_panel_pointer_at( x, y, @@ -4343,6 +4385,116 @@ mod input_routing_tests { ); } + /// Parent 48 R-c, the PRODUCER half — a press on the band's mode line + /// must neither send nor arm, and the row above it must do both. + /// + /// This drives the real `MouseInput` path and reads the wire, because + /// the hazard is precisely that the frontend latches BEFORE the daemon + /// can refuse: `panel_hit_test` reports across the whole frame, so a + /// chrome press is indistinguishable from a content press to the + /// arming code, and once armed, a drag into content emits a `Drag` + /// with no accepted `Down`. No receiver-side rule can undo that. + /// + /// The content leg is not decoration: without it, an implementation + /// that never arms anywhere passes the chrome half. + #[test] + fn a_press_on_the_bands_mode_line_neither_sends_nor_arms() { + let mut h = EffectHarness::new(); + let rows = 4; + { + // Inlined rather than shared: `present_panel` lives in the + // other test module. Same shape — wire on, one declaration, + // one `Present`. + let state = h.app.state.as_mut().expect("harness state"); + state.set_panel_wire(PANEL_MIN_VERSION); + // The harness already declared during setup, so the `Surface` + // trigger dedups; reuse the standing declaration rather than + // asserting a second one. + let geometry_epoch = state + .next_geometry_declaration(GeometryTrigger::Surface) + .map_or(state.panel.geometry_epoch, |(epoch, _)| epoch); + assert_ne!( + geometry_epoch, 0, + "a declaration must exist to present against" + ); + let cols = state.declared_cell_total().0.cols.max(1); + let frame = pmacs_protocol::panel::PanelFrame { + buffer_id: BufferId::from_raw(77), + panel_epoch: 1, + geometry_epoch, + size: CellSize::new(rows, cols), + cells: vec![pmacs_protocol::Cell::default(); (rows * cols) as usize], + cursor: None, + focused: true, + }; + assert!( + state.apply_panel_payload(pmacs_protocol::panel::PanelFramePayload::Present(frame)), + "installing a first frame changes the band" + ); + } + let (ox, oy, _, band_h) = h + .app + .state + .as_ref() + .expect("harness state") + .panel_content_rect() + .expect("a presented band has a rect"); + let row_h = band_h / rows as f32; + let press_at = |h: &mut EffectHarness, y: f32| { + h.feed(&WindowEvent::CursorMoved { + device_id: DeviceId::dummy(), + position: PhysicalPosition::new(f64::from(ox) + 0.5, f64::from(y)), + }); + h.feed(&WindowEvent::MouseInput { + device_id: DeviceId::dummy(), + state: ElementState::Pressed, + button: MouseButton::Left, + }) + }; + let panel_events = |step: &Step| { + step.outbound + .iter() + .filter(|event| matches!(event, pmacs_protocol::FrontendEvent::PanelPointer { .. })) + .count() + }; + + // Chrome: the band's LAST row. + let step = press_at(&mut h, oy + band_h - 0.5); + assert_eq!( + panel_events(&step), + 0, + "a press on the mode line is reserved and reaches no daemon" + ); + assert!( + !h.app + .state + .as_ref() + .expect("harness state") + .panel + .pointer_held, + "and it must not ARM — an armed chrome press turns the next \ + motion into a `Drag` with no accepted `Down`" + ); + + // Content: one row up, same column, same gesture. + let step = press_at(&mut h, oy + band_h - row_h - 0.5); + assert_eq!( + panel_events(&step), + 1, + "the row above chrome is content and must still work — without \ + this leg, never arming anywhere passes the half above" + ); + assert!( + h.app + .state + .as_ref() + .expect("harness state") + .panel + .pointer_held, + "a content press arms the gesture" + ); + } + /// P2, pointer — **the row that shows a route cannot stand in for an /// effect.** A wheel carries only a delta; whether it becomes a /// viewport update, a panel event, a terminal event or nothing at @@ -6908,6 +7060,7 @@ impl State { self.panel.hover_divider = false; self.panel.pointer_held = false; self.panel.last_pointer_cell = None; + self.panel.gesture_last_content_cell = None; had } PanelFramePayload::Present(frame) => { @@ -6919,6 +7072,28 @@ impl State { // A duplicate does no work — not even a reshape. return false; } + // Parent 48 R-d: a held gesture belongs to the presentation + // it began on. `Absent` clears the latch, but a + // `Present` → `Present` REPLACEMENT did not, so a press on + // panel A could emit a drag or release for B with no B + // press — and acceptance 49 cannot reject that, because the + // event carries B's CURRENT epochs. Geometry counts too: a + // font or scale change moves `geometry_epoch` while + // `panel_epoch` holds, and the gesture would resume under a + // new grid. + // + // Only on a CHANGE of identity. A panel repaints constantly + // during a drag, and resetting on every frame would make + // selection impossible. + let identity_changed = self.panel.presented().is_some_and(|current| { + current.panel_epoch != frame.panel_epoch + || current.geometry_epoch != frame.geometry_epoch + }); + if identity_changed { + self.panel.pointer_held = false; + self.panel.last_pointer_cell = None; + self.panel.gesture_last_content_cell = None; + } let plan = TerminalPaintPlan::build_grid( frame.size, &frame.cells, @@ -7223,12 +7398,29 @@ impl State { /// A panel selection drag routinely ends past the band's edge, and /// dropping that release leaves the daemon holding a button down forever. fn panel_release_cell(&self, x: f32, y: f32) -> Option { + // Parent 48 R-c/R-c2: `Up` is the load-bearing crossing event — a + // gesture whose release is dropped leaves the daemon holding a + // button down forever. It is therefore always sent, and always at + // a CONTENT coordinate: chrome and outside-the-band both fall back + // to where the gesture last legitimately pointed, which is set at + // press time so a release with no intervening motion still has one. match self.classify_pointer_surface(x, y) { - PointerSurface::PanelCell(coord) => Some(coord), - _ => self.panel.last_pointer_cell, + PointerSurface::PanelCell(coord) if !self.panel_cell_is_chrome(coord) => Some(coord), + _ => self.panel.gesture_last_content_cell, } } + /// Whether `coord` is the band's MODE-LINE row (parent 48 R-c). + /// + /// The daemon projects panel content as `rows - 1` and paints the last + /// row as chrome, but `panel_hit_test` reports across the whole frame, + /// so a hit is not automatically a content cell. + fn panel_cell_is_chrome(&self, coord: CellCoord) -> bool { + self.panel + .presented() + .is_some_and(|frame| coord.row + 1 >= frame.size.rows) + } + /// Whether a panel motion at `coord` carries anything new, and latch it. /// /// Sub-cell motion resolves to the same cell and says nothing the daemon @@ -7248,6 +7440,10 @@ impl State { fn set_panel_pointer_held(&mut self, held: bool) { self.panel.pointer_held = held; self.panel.last_pointer_cell = None; + // The termination fallback dies with the gesture (parent 48 R-c2). + // Safe in both directions: the press path rewrites it immediately + // after arming, and a release READS it before this runs. + self.panel.gesture_last_content_cell = None; } /// Begin a divider drag at surface pixel `y`, if the pointer is on the @@ -19809,6 +20005,167 @@ mod tests { } } + /// Parent 48 R-c — the band's MODE-LINE row is chrome, and the + /// producer must not arm a gesture there. + /// + /// `panel_hit_test` reports across the whole frame, so a chrome press + /// looks exactly like a content press to the arming code. If it armed, + /// dragging into content would emit a `Drag` with no accepted `Down` — + /// an orphan the daemon cannot distinguish from a real gesture, and one + /// no receiver-side rule can prevent, because the latch is already set. + #[test] + fn a_press_on_the_bands_mode_line_neither_arms_nor_reports_content() { + let Some(mut state) = headless_or_skip(800, 600, "alpha") else { + return; + }; + let frame = present_panel(&mut state, 4); + let last = frame.size.rows - 1; + + assert!( + state.panel_cell_is_chrome(pmacs_protocol::CellCoord::new(last, 0)), + "the band's last row is its mode line" + ); + assert!( + !state.panel_cell_is_chrome(pmacs_protocol::CellCoord::new(last - 1, 0)), + "the row above it is content — without this the row proves nothing" + ); + + // A release that lands on chrome normalizes to the last CONTENT + // cell rather than reporting the mode line to the daemon. + state.set_panel_pointer_held(true); + state.panel.gesture_last_content_cell = Some(pmacs_protocol::CellCoord::new(1, 2)); + let (ox, oy, _, h) = state + .panel_content_rect() + .expect("a presented band has a rect"); + let on_chrome_y = oy + h - 0.5; + assert_eq!( + state.panel_release_cell(ox + 0.5, on_chrome_y), + Some(pmacs_protocol::CellCoord::new(1, 2)), + "`Up` is the load-bearing crossing event: it must arrive, and at \ + a CONTENT coordinate — a chrome row would fail the terminal \ + reporting bounds check and drop into local handling" + ); + } + + /// Parent 48 R-c2 — a release with NO intervening motion still carries + /// a coordinate. + /// + /// The press cell is remembered in `gesture_last_content_cell`, not in + /// `last_pointer_cell`: that one is cleared on press precisely so the + /// first drag after a press reaches the daemon, and storing the press + /// cell there would suppress it. + #[test] + fn a_release_with_no_intervening_motion_carries_the_press_cell() { + let Some(mut state) = headless_or_skip(800, 600, "alpha") else { + return; + }; + present_panel(&mut state, 4); + let press = pmacs_protocol::CellCoord::new(1, 3); + + state.set_panel_pointer_held(true); + state.panel.gesture_last_content_cell = Some(press); + + assert_eq!( + state.panel.last_pointer_cell, None, + "arming clears the DEDUPE baseline — the tested guarantee that \ + the first drag after a press is not suppressed" + ); + + // Released outside the band entirely, with NO motion in between — + // asserted BEFORE any motion probe below, because a probe would + // populate `last_pointer_cell` and let a conflated implementation + // pass. (It did, on the first run of this row.) + assert_eq!( + state.panel_release_cell(0.0, 0.0), + Some(press), + "without the press cell retained, this release has no coordinate \ + and the daemon is left holding a button down forever" + ); + + // Separation, checked after: the dedupe must not consult the + // fallback, so a drag at the press cell still reaches the daemon. + assert!( + state.panel_motion_is_new(press), + "the first drag after a press must not be suppressed" + ); + } + + /// Parent 48 R-d — a held gesture belongs to the presentation it began + /// on, and BOTH identities end it. + /// + /// `Absent` already cleared the latch; a `Present` → `Present` + /// replacement did not, and acceptance 49 cannot catch the resulting + /// orphan because it carries the successor's CURRENT epochs. Geometry + /// counts too: a font or scale change moves `geometry_epoch` while + /// `panel_epoch` holds. + #[test] + fn a_change_of_either_panel_identity_ends_a_held_gesture() { + let Some(mut state) = headless_or_skip(800, 600, "alpha") else { + return; + }; + let frame = present_panel(&mut state, 4); + + let arm = |state: &mut State| { + state.set_panel_pointer_held(true); + state.panel.gesture_last_content_cell = Some(pmacs_protocol::CellCoord::new(1, 1)); + }; + + // D4, the NEGATIVE leg, first: a CHANGED frame at the SAME identity + // must not cancel a live gesture. A panel repaints constantly during + // a drag, and a reset-every-frame implementation would make + // selection impossible while passing D1 and D2. + arm(&mut state); + let mut refreshed = frame.clone(); + refreshed.focused = !frame.focused; + assert!( + state.apply_panel_payload(PanelFramePayload::Present(refreshed)), + "a focus repaint is a real frame, not a suppressed duplicate" + ); + assert!( + state.panel.pointer_held, + "a same-identity repaint must NOT end the gesture" + ); + assert!(state.panel.gesture_last_content_cell.is_some()); + + // D1 — panel identity. + arm(&mut state); + let mut replaced = frame.clone(); + replaced.panel_epoch = frame.panel_epoch + 1; + assert!(state.apply_panel_payload(PanelFramePayload::Present(replaced))); + assert!( + !state.panel.pointer_held, + "a replacement panel never saw the press" + ); + assert_eq!(state.panel.gesture_last_content_cell, None); + assert_eq!( + state.panel.last_pointer_cell, None, + "and the dedupe baseline goes too, or the successor's first \ + same-cell motion is silently suppressed as a duplicate" + ); + + // D2 — geometry identity, with panel identity UNCHANGED. This is the + // font/scale case: the gesture would otherwise resume under a new + // grid carrying epochs that are current and perfectly valid. + let base = state + .panel + .presented() + .cloned() + .expect("a frame is present"); + arm(&mut state); + let mut regeometried = base.clone(); + regeometried.geometry_epoch = base.geometry_epoch + 1; + assert_eq!( + regeometried.panel_epoch, base.panel_epoch, + "the point of this leg is that PANEL identity holds" + ); + assert!(state.apply_panel_payload(PanelFramePayload::Present(regeometried))); + assert!( + !state.panel.pointer_held, + "a new grid is a new gesture context, even under the same panel" + ); + assert_eq!(state.panel.gesture_last_content_cell, None); + } + /// F1 — a held left button makes motion a `Drag(Left)`, and the dedupe /// re-arms on every press and release. #[test] @@ -19849,7 +20206,13 @@ mod tests { state.panel_release_cell(ox + 0.5, oy + 0.5), Some(pmacs_protocol::CellCoord::new(0, 0)) ); + // The dedupe baseline and the TERMINATION FALLBACK are separate + // fields (parent 48 R-c2): `panel_motion_is_new` owns the first and + // a release reads only the second, so drive both exactly as the + // production motion path does. Conflating them is what would + // suppress the first drag after a press, asserted above. state.panel_motion_is_new(pmacs_protocol::CellCoord::new(1, 7)); + state.panel.gesture_last_content_cell = Some(pmacs_protocol::CellCoord::new(1, 7)); assert_eq!( state.panel_release_cell(ox + 0.5, 0.0), Some(pmacs_protocol::CellCoord::new(1, 7)), diff --git a/src/daemon.rs b/src/daemon.rs index c9d5973..4d57f97 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -2428,6 +2428,13 @@ fn handle_dispatcher_event( buffer_id, coord, kind, + // Parent 48 R-a: modifiers are NOT decoration here. + // `apply_terminal_gesture` gates child reporting on + // `!shift`, so Shift is the user's "select locally + // instead of talking to the child" override, and the + // document path reads Shift to extend the selection. + // Dropping them into `..` inverted both. + mods, .. } => { // Bottom panel Q#BP16 — a gesture the frontend @@ -2447,7 +2454,8 @@ fn handle_dispatcher_event( panel_epoch, ) { - editor.dispatch_semantic_panel_pointer(source, buffer_id, coord, kind); + editor + .dispatch_semantic_panel_pointer(source, buffer_id, coord, kind, mods); } } FrontendEvent::Pointer { diff --git a/src/editor.rs b/src/editor.rs index b08c26a..e45388c 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -2672,38 +2672,253 @@ impl EditorState { /// /// Returns whether the gesture was accepted. pub fn dispatch_semantic_panel_pointer( - &self, + &mut self, frontend_id: FrontendId, buffer_id: crate::buffer::BufferId, coord: CellCoord, kind: pmacs_protocol::MouseKind, + mods: pmacs_protocol::Modifiers, ) -> bool { + use pmacs_protocol::MouseKind as PKind; + let Some(size) = self.core.borrow().panel_grid_size(frontend_id) else { return false; }; if coord.row >= size.rows || coord.col >= size.cols { return false; } - let is_terminal = self.terminal_manager.borrow().is_terminal(buffer_id); - let mut core = self.core.borrow_mut(); - let Some(side) = core.side_window_for(frontend_id) else { - return false; - }; - if core.windows.get(&side).map(|window| window.buffer_id) != Some(buffer_id) { + // Parent 48 R-c: the panel's LAST ROW IS ITS MODE LINE. Projection + // derives content as `rows - 1` while the frontend hit-tests the + // whole frame, so a `PanelPointer` can legitimately name chrome — + // and chrome is never a content cell for either target. + let content_rows = size.rows.saturating_sub(1); + if content_rows == 0 { return false; } + let on_chrome = coord.row >= content_rows; + let is_wheel = matches!( + kind, + PKind::ScrollUp | PKind::ScrollDown | PKind::ScrollLeft | PKind::ScrollRight + ); + + let is_terminal = self.terminal_manager.borrow().is_terminal(buffer_id); + let side = { + let core = self.core.borrow(); + let Some(side) = core.side_window_for(frontend_id) else { + return false; + }; + if core.windows.get(&side).map(|window| window.buffer_id) != Some(buffer_id) { + return false; + } + side + }; + + // Q#BP-R2, step 3 of the ordering: a terminal panel's CHROME WHEEL + // is not a terminal gesture at all, so it is consumed HERE — + // before `focus_window`, before `active_frontend`, before any + // controller claim, and before the shared terminal path. + // + // Placing this below the activation block would leave the wheel + // CHANGING FOCUS while scrolling nothing and claiming no + // controller: `activates` is `!Move` for a terminal, so the wheel + // already activates. That half-state is what the + // activate-then-claim rule exists to prevent. + if is_terminal && on_chrome && is_wheel { + return true; + } + if on_chrome { + if is_terminal { + // TUI parity: a terminal never sees a chrome coordinate + // (`dispatch_mouse` rejects every kind above `inner_rows` + // for a terminal window). The wheel left above; the rest + // stops here. + return true; + } + // Document chrome mirrors the TUI's PER-KIND rule: presses and + // motion are reserved, while `Up` and the wheel fall through — + // an `Up` must still terminate a gesture begun in content, and + // a chrome wheel still scrolls. + if matches!(kind, PKind::Down(_) | PKind::Drag(_) | PKind::Move) { + return true; + } + } + let activates = if is_terminal { - !matches!(kind, pmacs_protocol::MouseKind::Move) + !matches!(kind, PKind::Move) } else { - matches!(kind, pmacs_protocol::MouseKind::Down(_)) + matches!(kind, PKind::Down(_)) }; if activates { + let mut core = self.core.borrow_mut(); core.focus_window(frontend_id, side); core.active_frontend = frontend_id; } + + if is_terminal { + // The ONE terminal pointer path, shared with the TUI and the + // document terminal. The viewport is `content_rows`, never the + // full grid: passing the frame would make the mode line a child + // cell and put every clamp one row out. + let viewport = CellSize::new(content_rows, size.cols); + let key = TerminalViewKey::new(frontend_id, side, buffer_id); + self.apply_terminal_gesture(key, viewport, coord, kind, mods, (coord.row, coord.col)); + return true; + } + + self.replay_panel_document_gesture(frontend_id, side, coord, kind, mods); true } + /// Replay one accepted gesture into a DOCUMENT panel (parent 48). + /// + /// Every write here is addressed to `side`, never to the ambient + /// active window. `Drag` and `Up` do not activate, and another + /// frontend's input can interleave between a `Down` and its tail, so + /// a replay reading `active_window_mut()` would act on whatever + /// happened to be active at that moment (R-b). + fn replay_panel_document_gesture( + &mut self, + frontend_id: FrontendId, + side: WindowId, + coord: CellCoord, + kind: pmacs_protocol::MouseKind, + mods: pmacs_protocol::Modifiers, + ) { + use pmacs_protocol::{MouseButton as PButton, MouseKind as PKind}; + + match kind { + PKind::ScrollUp => self.scroll_window(side, -SCROLL_LINES), + PKind::ScrollDown => self.scroll_window(side, SCROLL_LINES), + + PKind::Down(PButton::Left) => { + self.core.borrow_mut().break_command_chain(frontend_id); + let is_double = self.is_double_click(frontend_id, side, coord); + let Some(byte) = self.panel_cell_byte(side, coord) else { + return; + }; + let extending = mods.contains(pmacs_protocol::Modifiers::SHIFT); + let prev = self.core.borrow().windows[&side].cursor; + let keep_anchor = extending + && self + .core + .borrow() + .windows + .get(&side) + .is_some_and(|w| w.selection.is_some()); + self.panel_set_cursor(side, byte); + if is_double && !extending { + // Repeated left `Down`s are a multi-click (Q#BP16), and + // the second selects the word — the same rule the TUI + // applies, resolved against the SIDE window. + // Safe to use the active-window helper HERE and only + // here: `Down` activated the side window two statements + // ago, synchronously, so active == side. The tail + // (`Drag`/`Up`) does not activate and uses the + // window-targeted writers instead. + if self.core.borrow_mut().select_word_at_cursor() { + self.mouse_click = None; + return; + } + } + if extending { + if !keep_anchor { + self.panel_set_selection(side, Some(prev)); + } + } else { + self.panel_set_selection(side, Some(byte)); + } + self.mouse_click = Some(MouseClickState { + frontend_id, + window_id: side, + cell: coord, + at: Instant::now(), + }); + } + PKind::Drag(PButton::Left) => { + self.mouse_click = None; + self.core.borrow_mut().break_command_chain(frontend_id); + if let Some(byte) = self.panel_cell_byte(side, coord) { + self.panel_set_cursor(side, byte); + } + } + PKind::Up(PButton::Left) => { + // A click without a drag leaves an ACTIVE BUT EMPTY + // selection whose stale anchor would capture the next + // shift-motion, so it is cleared rather than left set. + let mut core = self.core.borrow_mut(); + if let Some(window) = core.windows.get_mut(&side) + && window + .selection + .is_some_and(|selection| selection.anchor == window.cursor) + { + window.selection = None; + } + } + PKind::Down(PButton::Right) => { + self.core.borrow_mut().break_command_chain(frontend_id); + if let Some(byte) = self.panel_cell_byte(side, coord) { + self.panel_set_cursor(side, byte); + } + self.open_context_menu(side, coord.row, coord.col, (coord.row, coord.col)); + } + // Claimed and dropped, for two different reasons kept in one + // arm because their bodies are identical: horizontal panel + // scrolling belongs to GUI arc Stage 1b's B-rows rather than + // parent 48, bare `Move` neither focuses nor claims, and the + // remaining buttons have no panel semantics at all. + PKind::ScrollLeft + | PKind::ScrollRight + | PKind::Move + | PKind::Down(_) + | PKind::Up(_) + | PKind::Drag(_) => {} + } + } + + /// Byte under a panel cell, resolved against the SIDE window's own + /// `view_top` and fold map. + /// + /// Deliberately not `activate_and_position`: that helper converts + /// correctly but also calls `set_active_window_id`, and a panel tail + /// must not re-activate a window on a frontend whose active window may + /// have moved since the `Down` (R-b). + fn panel_cell_byte(&self, win_id: WindowId, coord: CellCoord) -> Option { + let core = self.core.borrow(); + let window = core.windows.get(&win_id)?; + let buffer_id = window.buffer_id; + let view_top = window.view_top; + let folds = core.fold_map_for_window(win_id); + let display_row = match folds.as_ref() { + Some(map) => map.nth_visible_from(view_top, coord.row as usize), + None => view_top.saturating_add(coord.row as usize), + }; + let display_row = u32::try_from(display_row).ok()?; + let target = crate::view::DisplayCoord::new(display_row, coord.col); + let registry = core.registry.clone(); + let reg = registry.borrow(); + let buf = reg.get(buffer_id).ok()?; + core.windows[&win_id] + .text_view + .display_to_pos(buf, target, core.layout_ctx(win_id)) + } + + /// Move ONE window's point, never the ambient active window's. + fn panel_set_cursor(&self, win_id: WindowId, byte: u64) { + let mut core = self.core.borrow_mut(); + if let Some(window) = core.windows.get_mut(&win_id) { + window.cursor = byte; + window.goal_col = None; + } + } + + /// Set or clear ONE window's selection anchor. + fn panel_set_selection(&self, win_id: WindowId, anchor: Option) { + let mut core = self.core.borrow_mut(); + if let Some(window) = core.windows.get_mut(&win_id) { + window.selection = anchor.map(|anchor| crate::window::Selection { anchor }); + } + } + /// Precompute owned terminal view snapshots before entering paint borrows. pub fn prepare_terminal_views( &mut self, From 3dff79a0ec4e2c6d8766b3b7432891923acc9978 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 14 Aug 2026 17:50:54 +0200 Subject: [PATCH 11/33] fix(panel): D2 was broken on the real sequence, and Q#BP-R3 is ruled Two of review's findings: the framing blocker, and a live defect in the commit before this one. The remaining six are queued. **I REPORTED M-D2 AS BITING AND IT DID NOT.** `presented()` filters on `frame.geometry_epoch == self.panel.geometry_epoch`, and a geometry change advances that field FIRST --- so by the time the matching frame arrives, `presented()` already answers `None` and my `is_some_and` predicate skipped the reset entirely. The shipped D2 did nothing on the production sequence. The witness could not see it because it invented a higher-epoch frame without driving `next_geometry_declaration`, leaving `self.panel.geometry_epoch` untouched so `presented()` still matched. A test that skips the step which breaks the code cannot fail on it. The predicate now compares against the RETAINED frame (`self.panel.frame`), which survives the epoch advance, and the witness drives `GeometryTrigger::Metrics` for real --- asserting along the way that `presented()` IS `None` in that window, so the trap is pinned rather than merely avoided. Restoring the `presented()` predicate now fails the row. **M-D3 WAS ALSO UNCONSTRAINED**, for a smaller reason: arming clears `last_pointer_cell`, and the leg only armed, so the field was already `None` before the replacement and deleting its reset changed nothing. The arm helper now seeds the baseline with one accepted motion --- what a real gesture would have produced --- and after replacement the row requires `panel_motion_is_new` at that same cell to return true. Deleting only that line now fails. **Q#BP-R3 IS RULED: current-state hit semantics, narrowly, with the token named as follow-up.** `PanelPointer` carries epochs and a cell but nothing identifying the frame CONTENT the user saw, and `panel_epoch` is stable across ordinary frames by design. So a document wheel moves `view_top` daemon-side, and a click sent before the new frame lands is inverted through the NEW `view_top` --- selecting a row the user never saw, with every validation passing. Closing it properly needs a per-frame token on `PanelFrame` echoed by `PanelPointer`: a WIRE CHANGE, and this lane is non-protocol-bearing with 1b blocked behind it. A daemon-only mitigation was considered and does not work --- inverting against the last EMITTED frame still cannot tell which frame the user SAW, and the failing window is identical. So the lane accepts current-state semantics and says so: the window is narrow and self-inflicted (the same frontend must move the view and then click within one round trip), the magnitude is bounded by `SCROLL_LINES`, and the TUI is structurally unaffected. The token is recorded as a named follow-up for the next protocol-bearing slice, so it is inherited rather than rediscovered. Overrule stated explicitly: the trade is a narrow same-frontend mis-hit now, against serializing this lane and 1b behind a v25 wire change. Gates: all nine green under `env -u TMPDIR`, log 20260814T154611Z. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/bottom-panel-framing.md | 62 ++++++++++++++++++++++++++++++++++++ pmacs-gpu/src/main.rs | 62 +++++++++++++++++++++++++++++------- 2 files changed, 112 insertions(+), 12 deletions(-) diff --git a/docs/bottom-panel-framing.md b/docs/bottom-panel-framing.md index 48e04b4..e45bdff 100644 --- a/docs/bottom-panel-framing.md +++ b/docs/bottom-panel-framing.md @@ -2031,6 +2031,68 @@ follows acceptance 48's wording — it names row *selection* — and keeps document navigation from becoming an incidental consequence of wiring replay. +#### Q#BP-R3 — a panel cell has no frame-content provenance **RULED: current-state hit semantics, narrowly, with the token named as follow-up** + +**The hole.** `PanelPointer` carries `geometry_epoch`, `panel_epoch`, +`buffer_id` and a `coord` — **and nothing identifying the frame CONTENT +the user was looking at** (`pmacs-protocol/src/message.rs:500`). +`panel_epoch` is deliberately *"stable across ordinary frames of one +continuously present window/buffer"* (`panel.rs:61`), so an ordinary +repaint changes no epoch at all. + +**The race that follows is real.** A document-panel wheel moves +`view_top` **daemon-side**. The daemon repaints and emits a new frame. +Before that frame reaches the GPU, the user clicks. Every validation +passes — same buffer, same epochs — and the daemon inverts the cell +through its **current** `view_top`, selecting a row the user never saw. +Off by up to `SCROLL_LINES` (3). **No existing gate can reject it**, +because nothing about the event is stale by any test the ladder +applies. + +**Why the epochs cannot be stretched to cover it.** Moving +`panel_epoch` on content change would invalidate a gesture on every +repaint, which breaks drags outright — the field is stable *by design*, +and that design is what makes selection possible. + +**Why a fix is not free.** Closing it properly needs a **per-frame +token** on `PanelFrame`, echoed by `PanelPointer` — **a wire change**, +which makes it a protocol-bearing slice. This lane is explicitly +non-protocol-bearing, and GUI arc 1b is blocked behind it. + +**A daemon-only mitigation was considered and does not work.** Having +the daemon invert against the `view_top` it used for its **last emitted +frame** sounds like it removes the wire dependency, but it does not: +the daemon still cannot know **which** emitted frame the user saw, and +the failing window — frames emitted after the one on screen — is +exactly the same one. Without a token echoed back, the information does +not exist on the receiving side. + +**Ruling: this lane accepts CURRENT-STATE hit semantics**, and says so +rather than leaving it undiscovered: + +- A panel cell is resolved against the daemon's state **at the moment + the event is processed**, not against the frame the frontend painted. +- **The window is narrow and self-inflicted**: it requires the same + frontend to change the panel's view and then click inside one + round-trip. It cannot arise from another frontend's activity, because + a foreign edit that moves `view_top` is not a thing panels do. +- **The magnitude is bounded** by whatever moved the view — one wheel + step, `SCROLL_LINES` rows. +- **The TUI is unaffected.** It has no round trip; the hazard is + structural to a remote frontend inverting cells against mutable + daemon state. + +**Named follow-up, not a shrug: `PanelFrame` gains a content token and +`PanelPointer` echoes it, in the next protocol-bearing slice.** The +daemon then drops a gesture whose token no longer matches — the same +shape as the epoch ladder, one level finer. It is recorded here so the +next wire slice inherits it rather than rediscovering the race. + +**Overrule this if you would rather block the lane on a protocol +slice.** The trade is explicit: a narrow, bounded, same-frontend +mis-hit now, against serializing this lane and 1b behind a v25 wire +change. + ### The four replay edges — none of which "activation ordering" covers §5a's first draft concluded that the activation rule made the replay diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index b06a7b1..cbc7a7c 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -7085,7 +7085,14 @@ impl State { // Only on a CHANGE of identity. A panel repaints constantly // during a drag, and resetting on every frame would make // selection impossible. - let identity_changed = self.panel.presented().is_some_and(|current| { + // + // Compared against the RETAINED frame, never `presented()`: + // that accessor filters on `geometry_epoch == self.panel + // .geometry_epoch`, and a geometry change advances the field + // FIRST, so by the time the matching frame arrives + // `presented()` is already `None` and an `is_some_and` + // predicate skips the reset — exactly the case D2 covers. + let identity_changed = self.panel.frame.as_ref().is_some_and(|current| { current.panel_epoch != frame.panel_epoch || current.geometry_epoch != frame.geometry_epoch }); @@ -20105,9 +20112,20 @@ mod tests { }; let frame = present_panel(&mut state, 4); + let live = pmacs_protocol::CellCoord::new(1, 1); let arm = |state: &mut State| { state.set_panel_pointer_held(true); - state.panel.gesture_last_content_cell = Some(pmacs_protocol::CellCoord::new(1, 1)); + state.panel.gesture_last_content_cell = Some(live); + // SEED THE DEDUPE BASELINE TOO. Arming clears it, so a leg that + // only arms leaves `last_pointer_cell` already `None` and its + // reset is unconstrained — deleting that line stays green. One + // accepted motion is what a real gesture would have produced by + // the time a replacement arrives. + assert!( + state.panel_motion_is_new(live), + "the first motion after a press is never a duplicate" + ); + assert_eq!(state.panel.last_pointer_cell, Some(live)); }; // D4, the NEGATIVE leg, first: a CHANGED frame at the SAME identity @@ -20137,23 +20155,43 @@ mod tests { "a replacement panel never saw the press" ); assert_eq!(state.panel.gesture_last_content_cell, None); - assert_eq!( - state.panel.last_pointer_cell, None, - "and the dedupe baseline goes too, or the successor's first \ - same-cell motion is silently suppressed as a duplicate" + assert_eq!(state.panel.last_pointer_cell, None); + assert!( + state.panel_motion_is_new(live), + "the dedupe baseline goes too: the successor's first motion at \ + the predecessor's cell must reach the daemon, not be suppressed \ + as a duplicate of a gesture that belonged to another panel" ); // D2 — geometry identity, with panel identity UNCHANGED. This is the // font/scale case: the gesture would otherwise resume under a new // grid carrying epochs that are current and perfectly valid. - let base = state - .panel - .presented() - .cloned() - .expect("a frame is present"); + // + // **Driven through the real declaration path.** Inventing a + // higher-epoch frame is not the production sequence: a font or + // scale change advances `self.panel.geometry_epoch` FIRST, and only + // then does the matching frame arrive. That ordering is what broke + // the first implementation — `presented()` filters on the epoch, so + // it answers `None` in exactly this window — and a witness that + // skips the declaration cannot see it. + let base = state.panel.frame.clone().expect("a frame is retained"); arm(&mut state); + let (next_epoch, total) = state + .next_geometry_declaration(GeometryTrigger::Metrics) + .expect("a metrics change re-declares geometry"); + assert_ne!(next_epoch, base.geometry_epoch, "the declaration advanced"); + assert!( + state.panel.presented().is_none(), + "and the retained frame no longer answers `presented()` — the \ + window in which a `presented()`-based reset silently skips" + ); let mut regeometried = base.clone(); - regeometried.geometry_epoch = base.geometry_epoch + 1; + regeometried.geometry_epoch = next_epoch; + regeometried.size = CellSize::new(base.size.rows, total.cols.max(1)); + regeometried.cells = vec![ + pmacs_protocol::Cell::default(); + (regeometried.size.rows * regeometried.size.cols) as usize + ]; assert_eq!( regeometried.panel_epoch, base.panel_epoch, "the point of this leg is that PANEL identity holds" From 7e107bd1378c9bcbe3a48281b03ac9456e3132ae Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 14 Aug 2026 18:02:24 +0200 Subject: [PATCH 12/33] docs(framing): Q#BP-R3 overruled --- the lane blocks on a cell-mapping generation Records the overrule and frames the slice (SS5b). No implementation; the lane is PAUSED behind it. **ALL THREE OF MY BOUNDS WERE WRONG**, and each is recorded because each is why the trade looked cheap: "self-inflicted" another frontend, or a background process, can edit the same buffer --- the mapping moves with `view_top` untouched and the clicking frontend did nothing "bounded by 3 rows" unbounded: ticks, paging, folds, edits and reloads accumulate before the new frame is seen "one round trip" until the frontend PRESENTS the replacement frame; a slow or backed-up frontend widens the window arbitrarily With the narrowness gone there is nothing left to trade on. **AND THE FIX IS A GENERATION, NOT A TOKEN.** A per-frame token would invalidate a gesture on every repaint --- the exact mistake `panel_epoch` exists to avoid, and it would break drags outright. `mapping_generation` identifies the INVERSE MAPPING: it moves on viewport, folds, wrap/gutter geometry and buffer content, and holds across focus, styling, cursor motion and selection-only changes. The stability half is load-bearing rather than an optimisation --- a drag provokes selection repaints on every motion, and a generation that moved with them would cancel the drag after one step. SS5b frames the slice: appended variants (`PresentMapped`, `PanelPointerMapped`) rather than widened structs, because postcard encodes positionally and a shipped variant's field list is frozen; bilateral gating, with neither side assuming the other's support and a bare `PanelPointer` from a new-enough peer NOT silently upgraded; and a drop-before-mutation check at the same rung of the ladder as the epoch gates, one level finer. Dropping mid-drag is correct: if the mapping changed, the cells being dragged across no longer mean what they meant. This is where the hole always was --- `buffer_id` catches replacement, `panel_epoch` catches close/reopen, `geometry_epoch` catches a declaration race, and nothing catches "the text under that cell changed". Consequence: SS5b takes the next protocol version and GUI arc 1e's `OpenTarget` moves to the one after. That edit is OWED BY THE 1b BRANCH, which already revises `docs/gui-stage1-input-framing.md` heavily; making it here would collide at the rebase 1b is scheduled for, so it is recorded as required rather than made. Gates: all nine green under `env -u TMPDIR`, log 20260814T155745Z. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 23 +++++- docs/bottom-panel-framing.md | 152 ++++++++++++++++++++++++++++++++++- 2 files changed, 171 insertions(+), 4 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 4becad9..f84aee4 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -281,8 +281,27 @@ from #171 and #215 — the correction the 1b lane missed, honoured here. **`githubsucks/panel-pointer-replay` is the authoritative tip** (the ref, not a SHA). Recover with `git fetch githubsucks && git checkout panel-pointer-replay`. -- **No PR yet. Checkpoint: framing revision 12 (§5a) APPROVED - 2026-08-14; IMPLEMENTATION BEGINS.** Nothing gates this lane now. Commit one was the ground-truth +- **No PR yet. Checkpoint: framing revision 13 AWAITING APPROVAL; + IMPLEMENTATION PAUSED.** §5a's replay contract is approved at + revision 12 and partly implemented, but **Q#BP-R3 was OVERRULED** and + the lane now **blocks on a protocol-bearing cell-mapping + generation**, framed in **§5b**. +- **THE BLOCKER, and why the earlier acceptance failed.** A + `PanelPointer` names a cell; nothing on the wire says which inverse + mapping the frontend saw, so the daemon inverts against whatever is + current. Revision 12 accepted that on three bounds and **all three + were wrong**: a **foreign** edit moves the mapping with `view_top` + untouched, the error is **unbounded** once ticks/folds/edits/reloads + accumulate, and the window lasts until the frontend **presents** the + new frame. §5b adds a **cell-mapping generation** — moves with the + inverse mapping, stable across focus/styling/cursor/selection-only + repaints so drags survive — as **appended** wire variants with + bilateral gating. +- **Chain: §5b (protocol) → panel replay → GUI arc 1b.** §5b takes the + next protocol version, so **1e's `OpenTarget` moves to the one + after**. **That edit is OWED BY THE 1b BRANCH**, which already + revises `docs/gui-stage1-input-framing.md` (`:1011`, `:1221` still + say v25); making it here would collide at 1b's scheduled rebase. Commit one was the ground-truth re-measurement; 6 added the four replay edges; **7 answers review of 6; **8 answers review of 7** — R-c is target × gesture-ORIGIN (terminals reject all chrome kinds and raw chrome coords fail the diff --git a/docs/bottom-panel-framing.md b/docs/bottom-panel-framing.md index e45bdff..f5907d2 100644 --- a/docs/bottom-panel-framing.md +++ b/docs/bottom-panel-framing.md @@ -7,7 +7,27 @@ protocol v20, 2026-07-24. Amended by the pre-implementation dependency verification in §0.6: the folding dependency is cleared, and one geometry caller-census error is corrected.** -**Revision 12 — 2026-08-14, APPROVED.** §5a is the acceptance-48 +**Revision 13 — 2026-08-14, AWAITING APPROVAL. Q#BP-R3 is OVERRULED: +the lane BLOCKS on a protocol-bearing cell-mapping generation (new +§5b).** Revision 12 accepted current-state hit semantics on three +bounds, and all three were wrong — a **foreign** edit moves the mapping +with `view_top` untouched, the error is **unbounded** once ticks, folds, +edits or reloads accumulate, and the stale window lasts until the +frontend **presents** the replacement frame rather than one round trip. +With the narrowness gone there is nothing to trade on. + +The follow-up is a **cell-mapping generation, not a per-frame token**: +it moves when the inverse mapping moves (viewport, folds, wrap/gutter +geometry, buffer content) and holds across focus, styling, cursor and +selection-only repaints, so ordinary drags survive. §5b frames it — +appended variants rather than widened structs, bilateral version +gating, and a drop-before-mutation check. **GUI arc 1e's `OpenTarget` +moves to the following protocol version.** + +**Everything else in revision 12 stands**, including Q#BP-R1, Q#BP-R2 +and the R-a…R-d edges. + +**Previously, revision 12 — APPROVED.** §5a is the acceptance-48 ground truth and its rulings are settled: **Q#BP-R1** (a single click selects a listview row only), **Q#BP-R2** (a terminal-chrome wheel is consumed, daemon-side, before activation), the **R-a…R-d** replay @@ -2031,7 +2051,38 @@ follows acceptance 48's wording — it names row *selection* — and keeps document navigation from becoming an incidental consequence of wiring replay. -#### Q#BP-R3 — a panel cell has no frame-content provenance **RULED: current-state hit semantics, narrowly, with the token named as follow-up** +#### Q#BP-R3 — a panel cell has no frame-content provenance **RULED: BLOCK on a protocol-bearing mapping generation** + +**Revision 12's answer — accept current-state semantics — is +OVERRULED, and all three of its bounds were wrong.** They are recorded +because each was the reason the trade looked cheap: + +| the bound I claimed | why it does not hold | +|---|---| +| "self-inflicted — the same frontend must move the view and then click" | **another frontend, or a background process, can edit the same buffer.** The cell→byte mapping changes with `view_top` untouched, and the clicking frontend did nothing | +| "bounded by `SCROLL_LINES`" | **unbounded.** Multiple wheel ticks, paging, folds, edits or a reload can all land before the new frame is seen | +| "one round trip" | **until the frontend actually PRESENTS the replacement frame** — a slow or backed-up frontend widens the window arbitrarily | + +With those gone there is no narrowness left to trade on, so **the lane +blocks on the wire fix** rather than shipping a mis-hit whose size and +cause are both unbounded. + +**And the follow-up is NOT a per-frame token.** A token that changed on +every frame would invalidate a gesture on every repaint, which is the +same mistake `panel_epoch` deliberately avoids — it would break drags +outright. What is needed is a **CELL-MAPPING GENERATION**: an identity +of the *inverse mapping*, not of the frame. + +- **It changes when the inverse mapping changes**: viewport (`view_top` + or grid size), folds, wrapping or gutter geometry, or buffer content. +- **It is stable across repaints that cannot move a cell's byte**: + focus, styling and theme, cursor movement, and selection-only + changes. That stability is what lets an ordinary drag survive the + repaints it provokes. + +See **§5b** for the slice. + +#### Superseded — revision 12's reasoning, kept for the record **The hole.** `PanelPointer` carries `geometry_epoch`, `panel_epoch`, `buffer_id` and a `coord` — **and nothing identifying the frame CONTENT @@ -2577,6 +2628,103 @@ R-d's orphan is distinct from R-b's: **R-b's arrives from a passive panel, R-d's from a replaced or re-declared one**, and an implementation can fix either alone. +## 5b. The cell-mapping generation — a protocol-bearing slice (Q#BP-R3) + +**Status: framing, AWAITING APPROVAL. Nothing implemented.** This slice +**blocks** panel-pointer replay, which in turn blocks GUI arc 1b. + +### What it fixes + +A `PanelPointer` names a **cell**; the daemon must invert that to a +**byte**. Nothing on the wire says which inverse mapping the frontend +was looking at, so the daemon inverts against whatever is current. The +mapping can move for reasons the clicking frontend neither caused nor +can observe — **a foreign edit**, a fold, a reload — and the epochs do +not move with it, so every existing gate accepts the gesture. + +This is the one hole in the ladder: `buffer_id` catches replacement, +`panel_epoch` catches close/reopen, `geometry_epoch` catches a +declaration race, and **nothing catches "the text under that cell +changed"**. + +### The generation, and why not a token + +**A per-frame token is the wrong object.** Panels repaint constantly — +focus, cursor blink, styling — and a token that moved with the frame +would invalidate a live drag on the next repaint. That is precisely why +`panel_epoch` is stable across ordinary frames, and the same reasoning +applies one level down. + +**`mapping_generation` is an identity of the INVERSE MAPPING.** + +| changes it | leaves it alone | +|---|---| +| `view_top` | focus gained or lost | +| panel grid size | styling, theme, face changes | +| fold state | cursor movement | +| wrap mode, gutter geometry | selection-only changes | +| **buffer content — any edit, from any source** | a re-emitted identical frame | + +**The stability half is load-bearing, not an optimisation.** A drag +provokes selection repaints on every motion; if those moved the +generation, the drag would cancel itself after one step. + +### Wire shape — appended variants, never widened + +Postcard encodes enums **positionally**, so a variant's field list is +frozen once shipped. Both messages therefore gain **new variants +alongside the existing ones**: + +- `PanelFramePayload::PresentMapped { .. }` beside `Present`, carrying + the frame plus its `mapping_generation`. +- `FrontendEvent::PanelPointerMapped { .. }` beside `PanelPointer`, + echoing the generation the frontend was displaying. + +The existing variants stay **byte-frozen** and keep their current +meaning, and the frozen-byte pin moves to what is then the previous +final variant of each enum. + +### Bilateral gating + +Both directions gate on the negotiated version, and **neither side may +assume the other's support**: + +- The daemon sends `PresentMapped` **only** to a peer whose negotiated + version reaches the new floor; every older peer keeps receiving + `Present`. +- The frontend sends `PanelPointerMapped` **only** when the session + negotiated it; otherwise it sends `PanelPointer` as today. +- **A daemon that receives the bare `PanelPointer` from a + new-enough peer must not silently upgrade it.** The gesture carries + no generation, so it is handled under the old semantics — the + version gate decides the shape, not a guess about intent. + +### Enforcement + +On `PanelPointerMapped`, the daemon compares the echoed generation with +the panel's current one and **drops the gesture before any mutation** +when they differ — the same position in the ladder as the epoch checks, +one level finer. + +**Dropping mid-drag is correct.** If the mapping changed, the cells the +user is dragging across no longer mean what they meant when the drag +began, and continuing would select text they never pointed at. + +### Consequence for the GUI arc + +This slice takes the **next** protocol version, so **GUI arc 1e's +`OpenTarget` moves to the one after it**. The arc's serialization rule +is unchanged — protocol-bearing slices run alone — and this is simply a +new one inserted ahead of 1e. + +**The edit belongs to the 1b branch, not to this one.** +`docs/gui-stage1-input-framing.md` says *"1e — `OpenTarget` (v25)"* +(`:1011`) and *"floor: v25, after v24, serialized"* (`:1221`), and that +document is heavily revised on `gui-stage1b-pointer-scroll` +(revisions 13–18). Changing it here would collide at the rebase 1b is +already scheduled for. **It is recorded as required rather than made**, +so the version bump lands with the branch that owns the file. + ## 6. Deferred (named) Left / right / top side windows; multiple slots per side; **rehoming a leaf From 3cd7b8aa22de96e9484cff7479d433f268905bce Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 20 Aug 2026 20:04:38 +0200 Subject: [PATCH 13/33] docs(framing): SS5a revision 14 --- the lane is unblocked, and it inherits a changed substrate Revision 13 blocked this lane on a protocol-bearing mapping generation. That slice merged as #242 (47b5463), so the block is DISCHARGED and revision 13 stands as history rather than as a gate. Also fixes a defect I introduced in the merge b758c2e: it kept BOTH copies of SS5b, this branch's stale pre-split one and main's authoritative one. I discarded the uncommitted stub edit as obsolete and missed that its deletion half was still owed. Exactly one SS5a and one SS5b now remain. Q#BP-R4 is new, and the merge created it. SS5b and this lane agreed on `bool` while disagreeing on its meaning --- accepted-as-a-gesture versus consumed-here --- so a press on the band's mode line currently ARMS the accepted-gesture latch for a gesture that never began in content. That is the defect class SS5b's review round four fixed, re-entering by merge rather than by edit. RULED: a three-state PanelPointerOutcome (Refused/Consumed/Accepted), with an asymmetric latch rule --- arm only on Accepted + Down(Left), consume on any Up(Left) that was not Refused, because a button-up ends the gesture wherever it lands while a refused release cannot be known to concern it at all. The asymmetry is what earns the third state: it makes all three outcomes behaviourally distinct at the latch, so P1-P4 each have something to bite. A two-state bool with the corrected meaning would be right today and would let the next author restore the collision without touching a test. Revision 14 also takes the rows SS5b's split table assigned here, rules a bounded per-frontend pending-release SLOT for the cancellation record SS5b leaves nowhere to wait --- with the no-arming-over-pending invariant asserted rather than assumed --- and records that the four transitions SS5b left inert become defects the moment this lane attaches effects. Records a re-measurement obligation with evidence rather than suspicion: every production anchor in SS5a has moved, four of them measured here, and the DONE/MISSING verdicts are not assumed to have survived a dispatcher SS5b rewrote. Coherence: journey steps 5(c) and 8(b), and unlike SS5b this lane MOVES the grade --- it supplies what SS5a's own table lists as missing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 36 ++++- docs/bottom-panel-framing.md | 258 ++++++++++++++++++++++++----------- 2 files changed, 208 insertions(+), 86 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 0aead7b..e5e9960 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -281,11 +281,37 @@ from #171 and #215 — the correction the 1b lane missed, honoured here. **`githubsucks/panel-pointer-replay` is the authoritative tip** (the ref, not a SHA). Recover with `git fetch githubsucks && git checkout panel-pointer-replay`. -- **No PR yet. Checkpoint: framing revision 13 AWAITING APPROVAL; - IMPLEMENTATION PAUSED.** §5a's replay contract is approved at - revision 12 and partly implemented, but **Q#BP-R3 was OVERRULED** and - the lane now **blocks on a protocol-bearing cell-mapping - generation**, framed in **§5b**. +- **No PR yet. Checkpoint: framing revision 14 AWAITING APPROVAL; + IMPLEMENTATION STILL PAUSED, now on approval rather than on a + blocker.** §5a's replay contract is approved at revision 12; revision + 13 ruled Q#BP-R3 and blocked the lane on a protocol-bearing mapping + generation; **that block is DISCHARGED** — the slice merged as #242 + (`47b5463`). +- **MERGED main into this branch** at `b758c2e` rather than rebasing: + the lane's 12 commits include 10 framing revisions over the same + 800–1000 line doc regions, so a rebase meant twelve rounds of + large-block resolution — the operation that produced a committed + diff3 marker on the last lane. Workspace compiles clean, **1964 lib + and 284 GPU tests pass**. Base is no longer `72da24a`; read it with + `git merge-base githubsucks/main HEAD`. +- **THE MERGE CREATED ONE DEFECT AND SURFACED ONE COLLISION.** + - **Defect, fixed:** the merge kept BOTH copies of §5b — this + branch's stale pre-split one and main's authoritative one. I + discarded the uncommitted stub edit as obsolete and missed that its + **deletion** half was still owed. Removed; exactly one §5a and one + §5b remain. + - **Collision, ruled by revision 14 and NOT yet fixed in code:** + §5b and this lane gave `dispatch_semantic_panel_pointer`'s `bool` + different meanings — accepted-as-a-gesture versus consumed-here. A + mode-line press therefore **arms the latch** on this branch today. + **Q#BP-R4** rules a three-state `PanelPointerOutcome`, with an + asymmetric latch rule: arm only on `Accepted` + `Down(Left)`, + consume on any `Up(Left)` that was not `Refused`. +- **Revision 14 also carries** the rows §5b's split table assigned + here, a **pending-release slot** for the cancellation record §5b + leaves nowhere to wait, the **four transitions** that strand a live + gesture once effects attach, and a **re-measurement obligation**: + every production anchor in §5a has moved, evidenced in the revision. - **THE BLOCKER, and why the earlier acceptance failed.** A `PanelPointer` names a cell; nothing on the wire says which inverse mapping the frontend saw, so the daemon inverts against whatever is diff --git a/docs/bottom-panel-framing.md b/docs/bottom-panel-framing.md index 3c86649..44fc20d 100644 --- a/docs/bottom-panel-framing.md +++ b/docs/bottom-panel-framing.md @@ -1970,6 +1970,18 @@ cannot preserve the old panel-focused attach leak. ## 5a. Acceptance 48 — ground truth, MEASURED at `72da24a` (2026-08-14) +**Status: revision 14 — AWAITING APPROVAL.** Revision 13 ruled Q#BP-R3 +and **blocked** this lane on a protocol-bearing mapping generation. +That slice was framed as §5b, approved at revision 16, and **merged as +#242 (`47b5463`)**, so **revision 13's block is DISCHARGED** and its +ruling stands as history rather than as a gate. Revision 14 answers +what the lane inherits from a substrate that changed underneath it: +the meaning of the dispatcher's answer (**Q#BP-R4**, new), the rows +§5b's split table assigned here, the cancellation record's missing +resting place, four transitions that strand a live gesture, and a +ground truth whose every anchor has moved. **The measurement date in +this heading is now false and revision 14 owes its replacement.** + **Why this section exists.** GUI arc Stage 1b's ground-truth pass found that `PanelPointer` **replays nothing**: `dispatch_semantic_panel_pointer` (`src/editor.rs:2674`) validates the coord, resolves the side window, @@ -2628,105 +2640,189 @@ R-d's orphan is distinct from R-b's: **R-b's arrives from a passive panel, R-d's from a replaced or re-declared one**, and an implementation can fix either alone. -## 5b. The cell-mapping generation — a protocol-bearing slice (Q#BP-R3) +### Revision 14 — what §5b changed underneath this lane -**Status: framing, AWAITING APPROVAL. Nothing implemented.** This slice -**blocks** panel-pointer replay, which in turn blocks GUI arc 1b. +#### Q#BP-R4 — what does `dispatch_semantic_panel_pointer`'s answer MEAN? **RULED: a three-state outcome, not a bool** -### What it fixes +**This question did not exist before the merge, and it was created by +two branches agreeing on a type while disagreeing on its meaning.** -A `PanelPointer` names a **cell**; the daemon must invert that to a -**byte**. Nothing on the wire says which inverse mapping the frontend -was looking at, so the daemon inverts against whatever is current. The -mapping can move for reasons the clicking frontend neither caused nor -can observe — **a foreign edit**, a fold, a reload — and the epochs do -not move with it, so every existing gate accepts the gesture. - -This is the one hole in the ladder: `buffer_id` catches replacement, -`panel_epoch` catches close/reopen, `geometry_epoch` catches a -declaration race, and **nothing catches "the text under that cell -changed"**. - -### The generation, and why not a token - -**A per-frame token is the wrong object.** Panels repaint constantly — -focus, cursor blink, styling — and a token that moved with the frame -would invalidate a live drag on the next repaint. That is precisely why -`panel_epoch` is stable across ordinary frames, and the same reasoning -applies one level down. - -**`mapping_generation` is an identity of the INVERSE MAPPING.** - -| changes it | leaves it alone | +| branch | what `true` meant | |---|---| -| `view_top` | focus gained or lost | -| panel grid size | styling, theme, face changes | -| fold state | cursor movement | -| wrap mode, gutter geometry | selection-only changes | -| **buffer content — any edit, from any source** | a re-emitted identical frame | +| §5b | the gesture was **ACCEPTED** — and §5b's review round 4 made the accepted-gesture latch follow exactly this answer | +| this lane | the event was **CONSUMED HERE** — including the chrome swallows Q#BP-R2 and R-c introduced | -**The stability half is load-bearing, not an optimisation.** A drag -provokes selection repaints on every motion; if those moved the -generation, the drag would cancel itself after one step. +Both were right in their own tree. Merged, they are one `bool` that +means two things, and the daemon reads it as §5b's. **The consequence +is live in `b758c2e`:** a press on the band's **mode line** returns +`true`, so the latch **arms** for a gesture that never began in +content. That is the precise defect class §5b's round 4 found and +fixed, re-entering by merge rather than by edit. -### Wire shape — appended variants, never widened +**Three candidates were considered.** -Postcard encodes enums **positionally**, so a variant's field list is -frozen once shipped. Both messages therefore gain **new variants -alongside the existing ones**: +| candidate | why not | +|---|---| +| keep `bool`, make chrome swallows return `false` | behaviourally correct **today**, and that is the whole problem — it silently merges "refused as malformed" with "handled and deliberately stopped", so the next author restores the collision without touching a test | +| keep `bool`, re-derive "was this content?" in the daemon | **violates the seam §5b established.** One authoritative derivation, read through one accessor, is why the mapping generation works at all; a second derivation beside the dispatcher is the same hole in a new place | +| **a three-state outcome** | **RULED** | -- `PanelFramePayload::PresentMapped { .. }` beside `Present`, carrying - the frame plus its `mapping_generation`. -- `FrontendEvent::PanelPointerMapped { .. }` beside `PanelPointer`, - echoing the generation the frontend was displaying. +```rust +/// What the dispatcher did with a panel gesture. +pub enum PanelPointerOutcome { + /// Neither this panel's nor well-formed: no grid, out of grid, no + /// side window, or a buffer that is not the one shown there. + Refused, + /// This panel's, and handled HERE deliberately --- the chrome + /// swallows of Q#BP-R2 and R-c. Not a content gesture. + Consumed, + /// Reached the target as a content gesture. + Accepted, +} +``` -The existing variants stay **byte-frozen** and keep their current -meaning, and the frozen-byte pin moves to what is then the previous -final variant of each enum. +**The latch rule, and it is NOT "arm and consume on `Accepted`".** The +two halves are asymmetric, because a release ends a gesture wherever +the pointer happens to be: -### Bilateral gating +- **ARM** only on `Accepted` **and** `Down(Left)`. A chrome press + begins nothing. +- **CONSUME** on any `Up(Left)` that was **not `Refused`** — so + `Accepted` **or** `Consumed`. A button-up over the mode line still + ends the gesture; declining to consume it would strand the gesture + and leave the child holding the button down, which is the same + failure §5b's round 4 named. A **`Refused`** release is different in + kind: out-of-grid or wrong-buffer means the daemon cannot tell the + event is even about this gesture, and §5b already pinned that it + must not consume. -Both directions gate on the negotiated version, and **neither side may -assume the other's support**: +**That asymmetry is what earns the third state.** Under it, all three +outcomes are behaviourally distinct at the latch, so each is +falsifiable: -- The daemon sends `PresentMapped` **only** to a peer whose negotiated - version reaches the new floor; every older peer keeps receiving - `Present`. -- The frontend sends `PanelPointerMapped` **only** when the session - negotiated it; otherwise it sends `PanelPointer` as today. -- **A daemon that receives the bare `PanelPointer` from a - new-enough peer must not silently upgrade it.** The gesture carries - no generation, so it is handled under the old semantics — the - version gate decides the shape, not a guess about intent. +| # | mutation | must bite | +|---|---|---| +| P1 | chrome press returns `Accepted` | a chrome `Down(Left)` does not arm | +| P2 | `Refused` treated as `Accepted` | §5b's four `g5_substrate_a_refused_*` rows | +| P3 | `Consumed` treated as `Refused` for `Up` | a chrome `Up(Left)` consumes a live gesture | +| P4 | consume gated on `Accepted` alone | the same chrome-release row as P3, from the other side | -### Enforcement +**R-c2 does not discharge P3.** The producer normalizes a release that +lands on chrome back to the last content cell, so a conforming +frontend should not send one — but the daemon's contract cannot rest +on the producer's good behaviour, and a TUI or legacy peer reaches the +same path. Producer-side normalization and daemon-side consumption are +**both** required, and the existing GPU row +(`a_press_on_the_bands_mode_line_neither_arms_nor_reports_content`) +covers only the producer half. -On `PanelPointerMapped`, the daemon compares the echoed generation with -the panel's current one and **drops the gesture before any mutation** -when they differ — the same position in the ladder as the epoch checks, -one level finer. +#### The rows §5b's split table assigned here -**Dropping mid-drag is correct.** If the mapping changed, the cells the -user is dragging across no longer mean what they meant when the drag -began, and continuing would select text they never pointed at. +§5b states its own split rather than leaving it to whoever runs the +tests, and this lane is the other column. Inherited, verbatim in +substance: -### Consequence for the GUI arc +- the **document cancellation effect** — an empty selection cleared + without moving point; +- **real stable-generation drag continuation**, G5a's **effects**, and + the v24/v25 replay-effect controls **G4b, G6c, G7c**; +- the **complete producer reset lifecycle** — geometry, `Absent`, + identity and detach — whose fields this lane introduces (**G5f**); +- **every** common legacy/mapped cancellation transition and the real + gesture/click lifecycle (**G5b–e, G5g, G5i–p**), exhaustion + cancellation (**G11b**), and both two-tick wheel effects (**G12**). -This slice takes the **next** protocol version, so **GUI arc 1e's -`OpenTarget` moves to the one after it**. The arc's serialization rule -is unchanged — protocol-bearing slices run alone — and this is simply a -new one inserted ahead of 1e. +**The IDs stay as §5b wrote them.** §5b deliberately pinned its own +three decisions under `g5_substrate_*` names precisely so these IDs +would be unclaimed here; taking them is the point, not a collision. + +#### The cancellation record has nowhere to wait + +§5b landed cancellation as a **saturating count**, and said why: a +queue drained by nobody grows one entry per cancelled drag. It also +left the record itself reachable — `cancel_accepted_gesture` **returns** +the `AcceptedPanelGesture` it ends. + +**The gap is not the count, it is the caller.** Two of the three +cancellation sites are inside `semantic_render.rs`, reached during +**frame production**, where a release cannot be delivered. So the +record is returned into a context that cannot act on it and is +dropped. + +**RULED: one pending-release SLOT per frontend, not a queue.** The +latch holds at most one gesture per frontend, so at most one release +can be owed at a time, and the slot is bounded by construction rather +than by a cap someone has to choose. **The invariant is load-bearing +and therefore owed a witness**: arming while a release is still +pending must be impossible. If that witness fails, the slot is the +wrong shape and this ruling is wrong — which is the point of asserting +it rather than assuming it. + +| # | mutation | must bite | +|---|---|---| +| Q1 | drop the record instead of parking it | the cancelled-gesture release row | +| Q2 | park it but never drain | the same row, from the delivery side | +| Q3 | allow arming over a pending release | the invariant witness above | + +#### Four transitions strand a live gesture, and they become defects HERE + +§5b recorded this as inert and named the branch that owns it: a +**panel-epoch change**, a **buffer replacement**, a **same-size +geometry change** and a **detach** all leave the latch armed with a +release that can never be accepted. Only `Absent` was wired, because +`publish_absent_panel` already cleared input authority two lines +later and omitting it would have been an inconsistency inside one +function. + +**Inert became a defect the moment this lane attaches effects.** Each +of the four needs its own leg, and they fail differently — R-d already +established that for the producer side, where clearing `pointer_held` +without `last_pointer_cell` silently eats the successor's first +motion. The daemon side has the same shape and needs the same +independently discriminating legs rather than one shared "resets +something" assertion. + +#### The ground truth must be RE-MEASURED, and this is evidenced + +This section's heading says `72da24a`. **Every production anchor in it +has moved**, measured at `b758c2e`: + +| §5a claims | actually | +|---|---| +| `dispatch_semantic_panel_pointer` at `src/editor.rs:2674` | `:2809` | +| `apply_terminal_gesture` at `src/editor.rs:3525` | `:3875` | +| `scroll_window` at `src/editor.rs:3845` | `:4195` | +| `Absent` clears the latch at `pmacs-gpu/src/main.rs:6909` | `:7206` | + +**The clause table itself is NOT assumed to have survived.** §5b +rewrote the dispatcher this lane measured, so each DONE/MISSING verdict +is owed a re-measurement before implementation, not a line-number +patch. A table that is right about *what* is missing and wrong about +*where* is the kind of record that gets trusted and then misleads. + +#### Coherence impact (`COHERENCE.md` §20) + +- **Journey steps touched: 5** — Edit, clause (c) selection, kill and + yank — **and 8** — Open terminal, clause (b) input and output + round-trip. **Unlike §5b, this lane MOVES the grade.** §5b hardened + steps that already worked; §5a's own table lists panel selection, + listview row selection, terminal mouse reporting and the wheel as + **MISSING**, and this lane is what supplies them. +- **Interaction islands: none added.** It **completes** the existing + panel island rather than opening a new one — the same gestures the + band already advertises, finally reaching their target. +- **Config registry: no entry.** Nothing here is tunable. +- **Background work: none started**, and no existing attribution + changes. + +#### What revision 14 does NOT do + +It does not re-open Q#BP-R1 (single click selects only), Q#BP-R2 (a +terminal panel's chrome wheel is consumed, not clamped), or R-a/R-b/ +R-c/R-c2/R-d. Those are approved at revision 12 and unaffected by §5b +— **except** where R-c's chrome swallows now feed Q#BP-R4's outcome, +which changes their return value and not their behaviour. -**The edit belongs to the 1b branch, not to this one.** -`docs/gui-stage1-input-framing.md` says *"1e — `OpenTarget` (v25)"* -(`:1011`) and *"floor: v25, after v24, serialized"* (`:1221`), and that -document is heavily revised on `gui-stage1b-pointer-scroll` -(revisions 13–18). **This paragraph is superseded.** It argued the bump -should be recorded rather than made, to avoid colliding at 1b's -scheduled rebase. §5b **made it**, and merged as #242 — an expected -rebase conflict was not grounds for leaving a canonical document -saying v25 after v25 was taken. Kept, struck, because the reasoning it -records was overruled rather than forgotten. ## 5b. The cell-mapping generation — a protocol slice (Q#BP-R3) **Status: revision 16 — APPROVED 2026-08-15. Nothing implemented.** From cf78385b5e4f0d40cfb6a987eb68a3cd69490e72 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 20 Aug 2026 20:05:51 +0200 Subject: [PATCH 14/33] fix(daemon): bind `mods` in the mapped arm --- and withdraw b758c2e's build claim The merge commit b758c2e DOES NOT COMPILE, and its message says "Workspace compiles clean, all targets, no warnings". That claim is WITHDRAWN. It was true of the tree I checked and false of the tree I committed. What happened: I staged the conflict resolution, ran cargo check, hit `cannot find value mods in this scope` at the mapped arm, edited src/daemon.rs to bind it, re-ran cargo check clean --- and then committed without re-staging that file. The verification and the commit were of different trees. This is the same defect class as gating one step of an edit-then-commit chain and leaving the next ungated: a commit that does not depend on the edit it claims. The fix itself is unchanged from what was verified. SS5b left `mods` in `..` on PanelPointerMapped; the mapped family carries the same modifiers, so leaving it there gives a v25 session the inverted Shift behaviour that parent 48 R-a fixed for v24. Verified at THIS commit, after staging: cargo check --workspace --all-targets, no errors and no warnings. Not amended away. b758c2e stays in history with its false claim standing and this withdrawal attached, because erasing a bad record is worse than carrying a corrected one. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- src/daemon.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/daemon.rs b/src/daemon.rs index 7a2fc89..cb792f0 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -2629,6 +2629,12 @@ fn handle_dispatcher_event( coord, kind, mapping_generation, + // Bound for the same reason as the legacy arm above, + // and NOT optional here: the mapped family carries + // the same modifiers, so leaving them in `..` would + // give a v25 session the inverted Shift behaviour + // that parent 48 R-a fixed for v24. + mods, .. } => { // §5b — the mapped family, in this order: From 2c0d3ff9078cdb219943b1b44051c978998088b9 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 20 Aug 2026 20:06:11 +0200 Subject: [PATCH 15/33] docs(lane): record that b758c2e does not build, and that its claim is withdrawn The ledger listed the merge's duplicate-SS5b defect but not the worse one: the merge commit does not compile and its message asserts that it does. Recorded with the bisect consequence spelled out, so a reader crossing that range knows why the build fails there. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/active-work.md b/docs/active-work.md index e5e9960..11b6ba5 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -295,6 +295,15 @@ from #171 and #215 — the correction the 1b lane missed, honoured here. and 284 GPU tests pass**. Base is no longer `72da24a`; read it with `git merge-base githubsucks/main HEAD`. - **THE MERGE CREATED ONE DEFECT AND SURFACED ONE COLLISION.** + - **Defect, fixed at `cf78385`: `b758c2e` DOES NOT COMPILE**, and its + message claims "Workspace compiles clean". **That claim is + withdrawn.** I staged the resolution, hit `cannot find value mods` + at the mapped arm, fixed it, re-checked clean — and committed + without re-staging, so the verification and the commit were of + different trees. Not amended away: `b758c2e` keeps its false claim + with the withdrawal attached, because erasing a bad record is worse + than carrying a corrected one. **Anything bisecting across + `b758c2e`..`3cd7b8a` will fail to build.** - **Defect, fixed:** the merge kept BOTH copies of §5b — this branch's stale pre-split one and main's authoritative one. I discarded the uncommitted stub edit as obsolete and missed that its From cda078fa9195b9783d189f2d3304ed2430d2af7b Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 20 Aug 2026 20:25:22 +0200 Subject: [PATCH 16/33] docs(framing): SS5a revision 15 --- answers review of 14, mostly by correction Four blocking gaps, all of them cases where revision 14 asserted a guarantee its own mechanism did not provide. 1. Q#BP-R4's release half was bookkeeping in an effect's clothes. It ruled that a chrome Up "consumes" the latch and claimed this stops a child being left button-down. It stops nothing: update_accepted_gesture does `let _ = state.consume_accepted_gesture()` at daemon.rs:1067, and terminal chrome returns at editor.rs:2862-2870, BEFORE apply_terminal_gesture at :2899. P3/P4 asserted only that the latch empties, so they would have passed while the child heard nothing --- the failure looking fixed because the bookkeeping looked right. Now ruled as TERMINATE, with a per-target completion effect (child release, or local terminal-selection completion, or the document completion SS5b already assigned here), a no-duplicate leg, and P3 written explicitly against the latch-only assertion. 2. The pending-release slot had an invariant and no ordering. The seam replays before it arms (daemon.rs:2608-2616), so a check inside arm_accepted_gesture could only fire after the new press had already reached the child. Drain order is now part of the ruling: before any subsequent panel-pointer effect, before detach teardown, before the next frame --- with an old-release-before-new-press row that reads the child's byte order, and the invariant demoted to a backstop. 3. The ground truth was knowingly false, and the workflow requires scouting before framing. RE-MEASURED at 2c0d3ff instead of deferred. Two clauses the old table called MISSING are DONE on this branch --- panel selection and terminal mouse reporting --- the vertical wheel works, and the horizontal wheel turns out to be an UNRULED gap sitting in a catch-all arm with no comment. Revision 15 names it rather than ruling it, because inventing a ruling inside a re-measurement is how an unexamined default becomes a decision. The 72da24a table is kept as history. 4. "This lane MOVES the grade" was wrong against the authoritative scorecard. Step 5/GPU is floored Partial by 5(a) IME, so completing 5(c) cannot lift it, and step 8 is already Works on all three columns. The draft had read this section's MISSING column as if it were the scorecard; COHERENCE.md is the only thing that decides grades. Also drops a false TUI claim --- dispatch_semantic_panel_pointer has exactly two callers, both FrontendEvent arms, so only semantic legacy and mapped peers reach it --- and clears two merged-state contradictions: SS5b no longer says "Nothing implemented", and the lane no longer says the v26 correction is owed by 1b while the block below records SS5b making it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 17 ++- docs/bottom-panel-framing.md | 206 ++++++++++++++++++++++++++++------- 2 files changed, 179 insertions(+), 44 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 11b6ba5..5cc2ffd 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -281,7 +281,8 @@ from #171 and #215 — the correction the 1b lane missed, honoured here. **`githubsucks/panel-pointer-replay` is the authoritative tip** (the ref, not a SHA). Recover with `git fetch githubsucks && git checkout panel-pointer-replay`. -- **No PR yet. Checkpoint: framing revision 14 AWAITING APPROVAL; +- **No PR yet. Checkpoint: framing revision 15 AWAITING APPROVAL + (revision 14 was reviewed and had four blocking gaps, all answered); IMPLEMENTATION STILL PAUSED, now on approval rather than on a blocker.** §5a's replay contract is approved at revision 12; revision 13 ruled Q#BP-R3 and blocked the lane on a protocol-bearing mapping @@ -332,11 +333,15 @@ from #171 and #215 — the correction the 1b lane missed, honoured here. inverse mapping, stable across focus/styling/cursor/selection-only repaints so drags survive — as **appended** wire variants with bilateral gating. -- **Chain: §5b (protocol) → panel replay → GUI arc 1b.** §5b takes the - next protocol version, so **1e's `OpenTarget` moves to the one - after**. **That edit is OWED BY THE 1b BRANCH**, which already - revises `docs/gui-stage1-input-framing.md` (`:1011`, `:1221` still - say v25); making it here would collide at 1b's scheduled rebase. Commit one was the ground-truth +- **Chain: ~~§5b (protocol) →~~ panel replay → GUI arc 1b.** §5b took + **v25**, so 1e's `OpenTarget` is **v26**. **That edit is NOT owed by + the 1b branch** — this bullet said it was, and said making it here + would collide at 1b's rebase. **§5b made it, in + `docs/gui-stage1-input-framing.md`, and merged as #242**: a canonical + document saying v25 is false the moment v25 is taken, and an expected + rebase conflict was not grounds for leaving it wrong. Corrected here + because this bullet and the merged-#242 block below it were saying + opposite things. Commit one was the ground-truth re-measurement; 6 added the four replay edges; **7 answers review of 6; **8 answers review of 7** — R-c is target × gesture-ORIGIN (terminals reject all chrome kinds and raw chrome coords fail the diff --git a/docs/bottom-panel-framing.md b/docs/bottom-panel-framing.md index 44fc20d..8424413 100644 --- a/docs/bottom-panel-framing.md +++ b/docs/bottom-panel-framing.md @@ -1968,30 +1968,84 @@ cannot preserve the old panel-focused attach leak. preserving the Stage 1 unknown-value rollback assertions; the Stage 3 PR then runs the full gate suite. -## 5a. Acceptance 48 — ground truth, MEASURED at `72da24a` (2026-08-14) +## 5a. Acceptance 48 — ground truth, RE-MEASURED at `2c0d3ff` (2026-08-20) -**Status: revision 14 — AWAITING APPROVAL.** Revision 13 ruled Q#BP-R3 +**Status: revision 15 — AWAITING APPROVAL.** Revision 13 ruled Q#BP-R3 and **blocked** this lane on a protocol-bearing mapping generation. That slice was framed as §5b, approved at revision 16, and **merged as #242 (`47b5463`)**, so **revision 13's block is DISCHARGED** and its -ruling stands as history rather than as a gate. Revision 14 answers -what the lane inherits from a substrate that changed underneath it: -the meaning of the dispatcher's answer (**Q#BP-R4**, new), the rows -§5b's split table assigned here, the cancellation record's missing -resting place, four transitions that strand a live gesture, and a -ground truth whose every anchor has moved. **The measurement date in -this heading is now false and revision 14 owes its replacement.** +ruling stands as history rather than as a gate. Revision 14 answered +what the lane inherits from a substrate that changed underneath it. + +**Revision 15 answers review of 14, and four of its five changes are +corrections rather than additions:** + +- **Q#BP-R4's release half was bookkeeping wearing an effect's + clothes.** It said a chrome `Up` should "consume" the latch and + claimed that prevents a child left button-down. It prevents nothing: + the daemon drops the record, and terminal chrome returns before + replay. Now ruled as **TERMINATE**, with the row asserting the + child's release or the local selection's completion — and a + no-duplicate leg. +- **The pending-release slot had an invariant but no ordering.** The + seam replays before it arms, so the invariant could only fire after + the damage. **Drain order is now part of the ruling** — before any + subsequent panel-pointer effect, before detach teardown, before the + next frame — with an old-release-before-new-press row. +- **The ground truth was knowingly false and is now RE-MEASURED**, at + `2c0d3ff`, rather than deferred to implementation. Two clauses the + old table called MISSING are DONE, and a third — the horizontal + wheel — turns out to be an unruled gap. +- **"This lane MOVES the grade" was wrong.** Step 5/GPU is floored + `Partial` by 5(a) IME and step 8 is already `Works`; no journey cell + moves. The draft had read this section's own MISSING column as if it + were the scorecard. +- Q#BP-R4 also **drops a false TUI claim**: only semantic legacy and + mapped peers reach this dispatcher. **Why this section exists.** GUI arc Stage 1b's ground-truth pass found -that `PanelPointer` **replays nothing**: `dispatch_semantic_panel_pointer` -(`src/editor.rs:2674`) validates the coord, resolves the side window, -focuses when the gesture activates, and returns `true`. Its own doc -defers replay to *"parent acceptance 48… Stage 2B-3"*. **A panel wheel -is dead today on both axes**, and so is every other panel gesture past -focus. 1b is blocked on this lane and rebases onto its merge. +that `PanelPointer` replayed nothing past focus. **That is no longer +the state of this branch**, and the paragraph that said so — with the +wheel "dead on both axes" — is superseded below. This lane has since +landed the document and terminal replay, and §5b merged underneath it. +1b still rebases onto this lane's merge. -**Acceptance 48 is not unimplemented — it is HALF implemented**, and -the halves were never separated in writing. Measured clause by clause: +**RE-MEASURED at `2c0d3ff`, the tip this revision was written against.** +The 2026-08-14 measurement was taken at `72da24a`, **before this +branch's own implementation commits and before §5b**, so it described +neither the branch nor main. Every anchor in it had also moved. It is +kept below as history; **this table is the current one**: + +| clause | status at `2c0d3ff` | production anchor | +|---|---|---| +| click-to-focus | **DONE** | `activates` → `focus_window` (`src/editor.rs:2888`) | +| terminal panel: non-`Move` activates, hover does neither | **DONE** | the same `activates`, split by `is_terminal` | +| focused-only auto-scroll clamp; passive preserves `view_top` | **DONE** | `src/editor.rs:2698`, which cites "A2A-3 / parent 48" | +| move/drag tails coalesce; press/release/context/wheel lossless and ordered | **DONE** | `coalesce_kind` (`pmacs-gpu/src/attach.rs:338`) | +| **panel selection** | **DONE — was MISSING** | `replay_panel_document_gesture` (`src/editor.rs:2914`), called at `:2903`: `Down`/`Drag`/`Up(Left)`, shift-extend, double-click word select, right-press | +| **terminal mouse reporting** | **DONE — was MISSING** | `apply_terminal_gesture` called at `src/editor.rs:2899`, viewport `content_rows` | +| **wheel moves the panel's viewport** | **VERTICAL DONE — was MISSING** | `ScrollUp`/`ScrollDown` → `scroll_window` (`src/editor.rs:2925`) | +| **horizontal wheel** | **INERT, AND UNRULED** | `ScrollLeft`/`ScrollRight` fall into the catch-all no-op arm (`src/editor.rs:3004`) **with no comment and no ruling** — the old table's "dead on both axes" became half-true without anyone deciding the other half | +| **listview row selection** | **MISSING** | — no listview handling on the replay path; Q#BP-R1 ruled the semantics (single click selects only), and nothing implements them | +| without disturbing the document mirror | **the constraint on all of the above** | — | + +**Two findings the re-measurement produced**, neither of which a +line-number patch would have surfaced: + +- **The horizontal wheel is an unruled gap, not a deferral.** It sits + in a catch-all arm beside `Move` and the non-left buttons, so it + reads as deliberate and is not. **Revision 14 does not rule it** — + it names it, because inventing a ruling inside a re-measurement is + how an unexamined default becomes a decision. +- **Two of the four "MISSING" effects are DONE on this branch.** The + section had been telling every reader that replay does nothing while + the branch it describes had implemented most of it. + +### Superseded — the 2026-08-14 measurement at `72da24a` + +Kept because it is what the lane was scoped against, and because two of +its verdicts are now wrong rather than merely stale. Measured clause by +clause: | clause | status | production anchor | |---|---|---| @@ -2687,14 +2741,50 @@ the pointer happens to be: - **ARM** only on `Accepted` **and** `Down(Left)`. A chrome press begins nothing. -- **CONSUME** on any `Up(Left)` that was **not `Refused`** — so - `Accepted` **or** `Consumed`. A button-up over the mode line still - ends the gesture; declining to consume it would strand the gesture - and leave the child holding the button down, which is the same - failure §5b's round 4 named. A **`Refused`** release is different in +- **TERMINATE** on any `Up(Left)` that was **not `Refused`** — so + `Accepted` **or** `Consumed`. A **`Refused`** release is different in kind: out-of-grid or wrong-buffer means the daemon cannot tell the - event is even about this gesture, and §5b already pinned that it - must not consume. + event is even about this gesture, and §5b already pinned that it must + not consume. + +**"TERMINATE", not "consume", and the distinction is the whole of +review finding 1.** An earlier draft of this ruling said consume, and +justified it by claiming it prevents a child left button-down. **It +prevents no such thing.** Emptying the latch is bookkeeping; +`update_accepted_gesture` currently does `let _ = +state.consume_accepted_gesture()` (`src/daemon.rs:1067`) — it **takes +the record and drops it**. And on the path that matters, terminal +chrome returns at `src/editor.rs:2862`–`:2870`, **before** +`apply_terminal_gesture` at `:2899`, so the child is never told +anything. A latch that empties while the child stays pressed is +*exactly* the failure the draft claimed to prevent, now invisible +because the bookkeeping looks right. + +**RULED: a non-`Refused` `Up(Left)` must produce the target's +completion effect, and the row asserts the EFFECT, not the latch.** + +| target | required completion effect | +|---|---| +| terminal panel, child reporting on | the **release is reported to the child** — the same report an in-content `Up` produces, at the gesture's last content cell per R-c2 | +| terminal panel, child reporting off | the **local terminal selection completes** — the selection the drag built is finalised, not abandoned | +| document panel | the document gesture completes at the last content cell; an empty selection is cleared **without moving point** (the effect §5b's split table already assigns here) | + +**And exactly once.** A completion delivered here must not be delivered +again by a later cancellation of the same gesture, which is reachable +because the four stranding transitions below can fire afterwards. + +| # | mutation | must bite | +|---|---|---| +| P1 | chrome press returns `Accepted` | a chrome `Down(Left)` does not arm | +| P2 | `Refused` treated as `Accepted` | §5b's four `g5_substrate_a_refused_*` rows | +| P3 | chrome `Up` empties the latch but delivers **no** completion | the child-release / selection-completion row — **the latch-only assertion must NOT satisfy this** | +| P4 | `Refused` release delivers a completion | a refused release leaves both the latch and the child untouched | +| P5 | completion delivered, then a later cancellation delivers another | the no-duplicate-release row | + +**P3 is written against the earlier draft's own weakness.** A row that +asserts only `has_accepted_gesture() == false` passes while the child +receives nothing, so the row must read the child's reported bytes or +the terminal's selection state. **That asymmetry is what earns the third state.** Under it, all three outcomes are behaviourally distinct at the latch, so each is @@ -2710,12 +2800,18 @@ falsifiable: **R-c2 does not discharge P3.** The producer normalizes a release that lands on chrome back to the last content cell, so a conforming frontend should not send one — but the daemon's contract cannot rest -on the producer's good behaviour, and a TUI or legacy peer reaches the -same path. Producer-side normalization and daemon-side consumption are -**both** required, and the existing GPU row +on the producer's good behaviour, and **a legacy `PanelPointer` peer +predates that normalization entirely**. Producer-side normalization and +daemon-side termination are **both** required, and the existing GPU row (`a_press_on_the_bands_mode_line_neither_arms_nor_reports_content`) covers only the producer half. +**No TUI claim is made here.** An earlier draft said a TUI reaches the +same path; it does not. `dispatch_semantic_panel_pointer` has exactly +two callers, both `FrontendEvent` arms in `src/daemon.rs` (`:2609`, +`:2680`), so **only semantic legacy and mapped peers reach it** — the +TUI goes through `dispatch_mouse`. + #### The rows §5b's split table assigned here §5b states its own split rather than leaving it to whoever runs the @@ -2752,17 +2848,38 @@ dropped. **RULED: one pending-release SLOT per frontend, not a queue.** The latch holds at most one gesture per frontend, so at most one release can be owed at a time, and the slot is bounded by construction rather -than by a cap someone has to choose. **The invariant is load-bearing -and therefore owed a witness**: arming while a release is still -pending must be impossible. If that witness fails, the slot is the -wrong shape and this ruling is wrong — which is the point of asserting -it rather than assuming it. +than by a cap someone has to choose. + +**AND THE DRAIN ORDER IS PART OF THE RULING, because an invariant alone +is a detector, not a guard.** An earlier draft asserted only that +arming over a pending release is impossible. That check would sit +inside `arm_accepted_gesture` — and the seam replays **before** it: +`dispatch_semantic_panel_pointer` runs its effects and only then does +`update_accepted_gesture` arm (`src/daemon.rs:2608`–`:2616`). So the +invariant can fail **only after the new press has already reached the +child or moved the selection**. It would report the collision one +effect too late. + +**A pending termination DRAINS FIRST:** + +1. **before any subsequent panel-pointer effect** for that frontend — + the drain happens ahead of `dispatch_semantic_panel_pointer`, not + after it, so the old gesture's release reaches the child before the + new gesture's press does; +2. **before detach teardown**, so a frontend that goes away does not + take an owed release with it — detach is one of the four stranding + transitions below, and it is the one with no later opportunity; +3. **before the frontend's next frame is produced**, so a cancellation + raised during projection cannot be overtaken by the frame that + caused it. | # | mutation | must bite | |---|---|---| | Q1 | drop the record instead of parking it | the cancelled-gesture release row | | Q2 | park it but never drain | the same row, from the delivery side | -| Q3 | allow arming over a pending release | the invariant witness above | +| Q3 | drain **after** `dispatch_semantic_panel_pointer` instead of before | the **old-release-before-new-press ordering row** — assert the child's byte stream carries the release ahead of the press, not merely that both arrive | +| Q4 | skip the drain on detach | the detach row: an owed release is delivered before teardown | +| Q5 | allow arming over a pending release | the invariant witness — kept, now as a **backstop** behind the ordering rows rather than as the guarantee | #### Four transitions strand a live gesture, and they become defects HERE @@ -2804,10 +2921,19 @@ patch. A table that is right about *what* is missing and wrong about - **Journey steps touched: 5** — Edit, clause (c) selection, kill and yank — **and 8** — Open terminal, clause (b) input and output - round-trip. **Unlike §5b, this lane MOVES the grade.** §5b hardened - steps that already worked; §5a's own table lists panel selection, - listview row selection, terminal mouse reporting and the wheel as - **MISSING**, and this lane is what supplies them. + round-trip. **NO JOURNEY-CELL GRADE MOVES**, and an earlier draft + claimed one. Checked against the authoritative scorecard rather than + inferred from this section's own MISSING column: + - **Step 5 / GPU is `Partial`, and stays `Partial`.** It is floored + by **5(a)** — no IME, no `set_ime_allowed`, so composed and CJK + input is impossible (`COHERENCE.md:462`). Completing 5(c) inside a + panel cannot lift a cell held down by a different clause. That is + §2a's aggregation rule working as designed. + - **Step 8 is already `Works` on all three columns.** There is no + grade left to move; 8(b) is hardened, not opened. + - The draft's error was reading §5a's MISSING column as if it were + the scorecard. **A section-local gap list is not a journey grade**, + and the one place that decides grades is `COHERENCE.md`. - **Interaction islands: none added.** It **completes** the existing panel island rather than opening a new one — the same gestures the band already advertises, finally reaching their target. @@ -2825,7 +2951,11 @@ which changes their return value and not their behaviour. ## 5b. The cell-mapping generation — a protocol slice (Q#BP-R3) -**Status: revision 16 — APPROVED 2026-08-15. Nothing implemented.** +**Status: revision 16 — APPROVED 2026-08-15. IMPLEMENTED AND MERGED as +#242 (`47b5463`), 2026-08-20.** The "Nothing implemented" this line +carried until then was left behind by the merge; the slice's own +"What the slice actually landed, row by row" subsection below had been +contradicting it. Revision 16 answers review of 15; three of its items reverse a rule 15 introduced: From 1fc3af63de9f1d19b5c2d89d5d24baebd21c850a Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 20 Aug 2026 20:50:21 +0200 Subject: [PATCH 17/33] docs(framing): revise panel replay contract after review Make the three-state panel-pointer result a pre-effect disposition and replace the competing latch matrices with one complete lifecycle table. Accepted tails now require a live gesture, accepted release replay and record-driven termination are mutually exclusive, and the witnesses cover stale tails plus immediate and later duplicate completion. Place projection-raised release delivery at the enforceable daemon boundary after semantic rendering returns and before its messages are written. Keep the arm invariant as a backstop behind pointer, detach, and frame-order witnesses. Split horizontal-wheel ground truth by terminal reporting/local and document targets. Record the missing document-panel effect explicitly under GUI Stage 1b B1--B3, update that framing's surface scope, and synchronize the active-work checkpoint. --- docs/active-work.md | 80 +++++++------- docs/bottom-panel-framing.md | 178 +++++++++++++++++++------------ docs/gui-stage1-input-framing.md | 27 ++++- 3 files changed, 174 insertions(+), 111 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 5cc2ffd..de0aab8 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -281,13 +281,15 @@ from #171 and #215 — the correction the 1b lane missed, honoured here. **`githubsucks/panel-pointer-replay` is the authoritative tip** (the ref, not a SHA). Recover with `git fetch githubsucks && git checkout panel-pointer-replay`. -- **No PR yet. Checkpoint: framing revision 15 AWAITING APPROVAL - (revision 14 was reviewed and had four blocking gaps, all answered); +- **No PR yet. Checkpoint: §5a framing revision 16 and its GUI Stage + 1b revision-13 ownership amendment AWAITING APPROVAL (revision 15 + was reviewed, corrected and folded into this branch); IMPLEMENTATION STILL PAUSED, now on approval rather than on a - blocker.** §5a's replay contract is approved at revision 12; revision - 13 ruled Q#BP-R3 and blocked the lane on a protocol-bearing mapping - generation; **that block is DISCHARGED** — the slice merged as #242 - (`47b5463`). + blocker.** §5a's **pre-merge** replay contract was approved at + revision 12; revisions 14–16 are the post-merge amendment now under + review. Revision 13 ruled Q#BP-R3 and blocked the lane on a + protocol-bearing mapping generation; **that block is DISCHARGED** — + the slice merged as #242 (`47b5463`). - **MERGED main into this branch** at `b758c2e` rather than rebasing: the lane's 12 commits include 10 framing revisions over the same 800–1000 line doc regions, so a rebase meant twelve rounds of @@ -314,14 +316,22 @@ from #171 and #215 — the correction the 1b lane missed, honoured here. §5b and this lane gave `dispatch_semantic_panel_pointer`'s `bool` different meanings — accepted-as-a-gesture versus consumed-here. A mode-line press therefore **arms the latch** on this branch today. - **Q#BP-R4** rules a three-state `PanelPointerOutcome`, with an - asymmetric latch rule: arm only on `Accepted` + `Down(Left)`, - consume on any `Up(Left)` that was not `Refused`. -- **Revision 14 also carries** the rows §5b's split table assigned - here, a **pending-release slot** for the cancellation record §5b - leaves nowhere to wait, the **four transitions** that strand a live - gesture once effects attach, and a **re-measurement obligation**: - every production anchor in §5a has moved, evidenced in the revision. + **Q#BP-R4** rules a three-state `PanelPointerOutcome`, classified + **before** target effects. Only an `Accepted` `Down(Left)` arms; + left `Drag`/`Up` require a live record; an accepted `Up` performs + ordinary replay once, a consumed/chrome `Up` performs the recorded + completion once, and a refused `Up` preserves the record. +- **Revision 16 carries** the rows §5b's split table assigned here, a + **pending-release slot** for the cancellation record §5b leaves + nowhere to wait, and the **four transitions** that strand a live + gesture once effects attach. Drain order is executable: before the + next panel-pointer effect; before detach teardown; and, for a + projection-raised cancellation, after `render_frame` returns but + before any returned message is written. Ground truth is re-measured + at `2c0d3ff`. Document-panel horizontal scrolling is a named deferral + to GUI Stage 1b B1–B3; a horizontal tick whose terminal precedence + selects child reporting already emits SGR, and the local terminal + branch has no horizontal viewport effect. - **THE BLOCKER, and why the earlier acceptance failed.** A `PanelPointer` names a cell; nothing on the wire says which inverse mapping the frontend saw, so the daemon inverts against whatever is @@ -387,32 +397,26 @@ from #171 and #215 — the correction the 1b lane missed, honoured here. **controller identity** catches the shared-path mutation, since `apply_terminal_gesture` claims at `src/editor.rs:3571` before local handling and activation alone claims nothing. -- **Why this lane exists.** `PanelPointer` **replays nothing**: - `dispatch_semantic_panel_pointer` (`src/editor.rs:2674`) validates, - focuses, returns. A panel wheel is dead on both axes and so is every - gesture past focus. **GUI arc 1b is BLOCKED on this lane and rebases - onto its merge commit.** +- **Why this lane exists, re-measured at `2c0d3ff`.** The branch now + replays document selection, terminal mouse reporting and vertical + wheels; the remaining acceptance-48 effect is **listview row + selection**. Q#BP-R4 and §5b's inherited rows still need + implementation: pre-effect disposition/latch ordering, fixed-domain + gesture tails, exact-once termination, cancellation effects and the + pending-release drains. **GUI arc 1b is BLOCKED on this lane and + rebases onto its merge commit.** - **No new framing document.** Acceptance 48 is already ruled in `docs/bottom-panel-framing.md`; §5a adds ground truth to it. -- **The measurement's headline: AC48 is HALF implemented**, and nothing - had written the halves down separately. - - **DONE:** click-to-focus and the terminal activation rule - (`src/editor.rs:2701` and its `activates`); the focused-only - auto-scroll clamp with passive `view_top` preserved (`:2568`–`:2571`, which - already cites parent 48); the coalescing rules — `Move`/`Drag` - tails coalesce, press/release/context/wheel lossless - (`pmacs-gpu/src/attach.rs:374`). - - **MISSING:** listview row selection, panel selection, terminal - mouse reporting, wheel replay. -- **The replay is mostly WIRING; both mechanisms exist.** - `apply_terminal_gesture` (`src/editor.rs:3525`) is *"the one terminal - pointer path, shared by both frontend kinds"* and already drives - child reporting, selection and scrollback — a panel terminal needs - the same call, with `side_window_for` + `TerminalViewKey` + - `panel_grid_size` (which the dispatcher already fetches). - `scroll_window` (`:3845`) is window-scoped, cursor carry included. - **A wheel-only bridge is the wrong shape** — the shared path takes - every kind at once. +- **Current clause split.** DONE: click/focus and terminal activation; + focused-only auto-scroll with passive `view_top` preserved; lossless + and coalesced event delivery; panel document selection; terminal + child reporting/local selection; vertical document and terminal + wheel effects. MISSING here: listview row selection and the + lifecycle/cancellation effects above. Horizontal wheel is split: + child-reporting terminal ticks already emit codes 66/67; the local + terminal branch is deliberately inert; document-panel `view_left` + is explicitly GUI Stage 1b B1–B3's effect, matching the production + comment in `src/editor.rs:2999`–`:3003`. - **The scoping hazard.** `set_cursor_byte` (`src/editor_core.rs:1216`), `begin_selection` (`:4691`) and `clear_selection` are **active-window scoped**; used naively they would move the diff --git a/docs/bottom-panel-framing.md b/docs/bottom-panel-framing.md index 8424413..641d94c 100644 --- a/docs/bottom-panel-framing.md +++ b/docs/bottom-panel-framing.md @@ -1,5 +1,13 @@ # Bottom panel — framing (window placement + side windows) +**Revision 16 — 2026-08-20, AWAITING APPROVAL.** Revision 16 closes +review of 15: Q#BP-R4 is one pre-effect disposition/gesture-lifecycle +table; projection-raised releases drain after `render_frame` returns +and before its messages are written; horizontal-wheel ground truth is +split by target/domain and the missing document effect is explicitly +owned by GUI Stage 1b; superseded current-state text is removed from +this section and the active-work ledger. + **Revision 4 — pre-implementation, DRAFT after review round 3 plus landed-state audit. Ground truth: canonical `main` @ `ddaa80d` (documentation landing #152; runtime @ `0dd16a5`, GPU initial target / #148 after folding Stage 2 / #149), @@ -7,7 +15,7 @@ protocol v20, 2026-07-24. Amended by the pre-implementation dependency verification in §0.6: the folding dependency is cleared, and one geometry caller-census error is corrected.** -**Revision 13 — 2026-08-14, AWAITING APPROVAL. Q#BP-R3 is OVERRULED: +**Previously, revision 13 — 2026-08-14. Q#BP-R3 was OVERRULED: the lane BLOCKS on a protocol-bearing cell-mapping generation (new §5b).** Revision 12 accepted current-state hit semantics on three bounds, and all three were wrong — a **foreign** edit moves the mapping @@ -1970,13 +1978,22 @@ cannot preserve the old panel-focused attach leak. ## 5a. Acceptance 48 — ground truth, RE-MEASURED at `2c0d3ff` (2026-08-20) -**Status: revision 15 — AWAITING APPROVAL.** Revision 13 ruled Q#BP-R3 +**Status: revision 16 — AWAITING APPROVAL.** Revision 13 ruled Q#BP-R3 and **blocked** this lane on a protocol-bearing mapping generation. That slice was framed as §5b, approved at revision 16, and **merged as #242 (`47b5463`)**, so **revision 13's block is DISCHARGED** and its ruling stands as history rather than as a gate. Revision 14 answered what the lane inherits from a substrate that changed underneath it. +**Revision 16 closes review of 15.** It replaces the two competing +Q#BP-R4 matrices with one pre-effect disposition/lifecycle table; +names the projection drain at the only enforceable seam — after +`render_frame` returns and before its messages are written; splits +horizontal-wheel ground truth by target and gesture domain; records +GUI Stage 1b as the owner of document-panel horizontal scrolling; and +removes revision 14's now-false remeasurement obligation from the live +section and ledger. + **Revision 15 answers review of 14, and four of its five changes are corrections rather than additions:** @@ -1995,7 +2012,8 @@ corrections rather than additions:** - **The ground truth was knowingly false and is now RE-MEASURED**, at `2c0d3ff`, rather than deferred to implementation. Two clauses the old table called MISSING are DONE, and a third — the horizontal - wheel — turns out to be an unruled gap. + wheel — exposed an ownership gap that revision 16 now splits and + assigns explicitly. - **"This lane MOVES the grade" was wrong.** Step 5/GPU is floored `Partial` by 5(a) IME and step 8 is already `Works`; no journey cell moves. The draft had read this section's own MISSING column as if it @@ -2024,19 +2042,24 @@ kept below as history; **this table is the current one**: | move/drag tails coalesce; press/release/context/wheel lossless and ordered | **DONE** | `coalesce_kind` (`pmacs-gpu/src/attach.rs:338`) | | **panel selection** | **DONE — was MISSING** | `replay_panel_document_gesture` (`src/editor.rs:2914`), called at `:2903`: `Down`/`Drag`/`Up(Left)`, shift-extend, double-click word select, right-press | | **terminal mouse reporting** | **DONE — was MISSING** | `apply_terminal_gesture` called at `src/editor.rs:2899`, viewport `content_rows` | -| **wheel moves the panel's viewport** | **VERTICAL DONE — was MISSING** | `ScrollUp`/`ScrollDown` → `scroll_window` (`src/editor.rs:2925`) | -| **horizontal wheel** | **INERT, AND UNRULED** | `ScrollLeft`/`ScrollRight` fall into the catch-all no-op arm (`src/editor.rs:3004`) **with no comment and no ruling** — the old table's "dead on both axes" became half-true without anyone deciding the other half | +| **vertical wheel moves the panel's viewport** | **DONE — was MISSING** | document `ScrollUp`/`ScrollDown` → `scroll_window` (`src/editor.rs:2925`); reporting and local terminal paths share `apply_terminal_gesture` | +| **horizontal wheel, terminal precedence selects child reporting** | **RECEIVER DONE; PRODUCER AWAITS GUI 1b** | `apply_terminal_gesture` reaches `encode_mouse`, whose `ScrollLeft`/`ScrollRight` codes are 66/67 (`src/terminal/input.rs:130`–`:131`) | +| **horizontal wheel, terminal precedence selects local handling** | **RULED INERT** | Shift, reporting-off or a scrolled-back view can select the local branch; terminal scrollback is vertical and there is no local horizontal viewport, so the shared path deliberately has no `ScrollLeft`/`ScrollRight` arm | +| **horizontal wheel, document panel** | **NAMED DEFERRAL: GUI Stage 1b B1–B3** | `ScrollLeft`/`ScrollRight` are claimed and dropped at `src/editor.rs:3004`; the comment at `:2999`–`:3003` already assigns horizontal panel scrolling to 1b, whose framing now explicitly includes the panel-document surface | | **listview row selection** | **MISSING** | — no listview handling on the replay path; Q#BP-R1 ruled the semantics (single click selects only), and nothing implements them | | without disturbing the document mirror | **the constraint on all of the above** | — | **Two findings the re-measurement produced**, neither of which a line-number patch would have surfaced: -- **The horizontal wheel is an unruled gap, not a deferral.** It sits - in a catch-all arm beside `Move` and the non-left buttons, so it - reads as deliberate and is not. **Revision 14 does not rule it** — - it names it, because inventing a ruling inside a re-measurement is - how an unexamined default becomes a decision. +- **"Horizontal wheel" was not one verdict.** A tick for which terminal + precedence selects child reporting already sends the horizontal SGR + code; the local terminal branch has no horizontal viewport to move; + only a document panel lacks an effect. That last leg is a **named + deferral to GUI Stage 1b B1–B3**, + not an unexplained catch-all: 1b owns horizontal tick production, + sign, per-surface residuals and the window-targeted `view_left` + effect. The panel-replay lane remains 1b's prerequisite. - **Two of the four "MISSING" effects are DONE on this branch.** The section had been telling every reader that replay does nothing while the branch it describes had implemented most of it. @@ -2694,7 +2717,7 @@ R-d's orphan is distinct from R-b's: **R-b's arrives from a passive panel, R-d's from a replaced or re-declared one**, and an implementation can fix either alone. -### Revision 14 — what §5b changed underneath this lane +### Revisions 14–16 — what §5b changed underneath this lane #### Q#BP-R4 — what does `dispatch_semantic_panel_pointer`'s answer MEAN? **RULED: a three-state outcome, not a bool** @@ -2722,30 +2745,49 @@ fixed, re-entering by merge rather than by edit. | **a three-state outcome** | **RULED** | ```rust -/// What the dispatcher did with a panel gesture. +/// How an authenticated panel event relates to the authoritative +/// panel surface, decided BEFORE any target effect. pub enum PanelPointerOutcome { - /// Neither this panel's nor well-formed: no grid, out of grid, no - /// side window, or a buffer that is not the one shown there. + /// Not addressable as this panel: no grid, out of grid, no side + /// window, or a buffer that is not the one shown there. Refused, - /// This panel's, and handled HERE deliberately --- the chrome - /// swallows of Q#BP-R2 and R-c. Not a content gesture. + /// This panel owns the cell/event, but it is deliberately not a + /// content gesture --- the chrome claims of Q#BP-R2 and R-c. Consumed, - /// Reached the target as a content gesture. + /// A content gesture for the resolved target. Accepted, } ``` -**The latch rule, and it is NOT "arm and consume on `Accepted`".** The -two halves are asymmetric, because a release ends a gesture wherever -the pointer happens to be: +**The outcome is a DISPOSITION, not proof that an effect already ran.** +It is decided before replay. The current function combines validation, +classification and mutation, but that ordering cannot implement §5b's +stale-tail rule: an `Up` or `Drag` with no accepted `Down` has already +reached the child/selection before the daemon consults the latch. The +implementation may split the function into classify/apply helpers or +use an equivalent single wrapper, but these two facts are fixed: -- **ARM** only on `Accepted` **and** `Down(Left)`. A chrome press - begins nothing. -- **TERMINATE** on any `Up(Left)` that was **not `Refused`** — so - `Accepted` **or** `Consumed`. A **`Refused`** release is different in - kind: out-of-grid or wrong-buffer means the daemon cannot tell the - event is even about this gesture, and §5b already pinned that it must - not consume. +1. the editor remains the **only** authority that derives + `Refused`/`Consumed`/`Accepted`; the daemon never re-derives chrome, + target kind or content bounds; +2. disposition and the live-gesture check both complete **before** a + left `Drag`/`Up` target effect. + +**One transition table is authoritative:** + +| event | `Refused` | `Consumed` | `Accepted` | +|---|---|---|---| +| `Down(Left)` | no effect; do not arm | no target effect; do not arm | apply the content press, then arm **from the effect result** so target/domain/encoding and `reached_child` describe what actually happened | +| `Drag(Left)` | no effect; retain any live record | no target effect; retain any live record | no live record → inert; live record → continue in the **recorded domain**, update its last valid content cell | +| `Up(Left)` | no effect; retain any live record for a later authoritative cancellation | no live record → inert; live record → deliver the **recorded completion** at its last valid content cell, then take it | no live record → inert; live record → apply the ordinary in-content completion **once**, then take it; do **not** also run recorded completion | +| every other kind | no effect; no latch change | no target effect; no latch change | apply its one-shot content effect; never arm or consume the left-gesture latch | + +This is the required asymmetry. A chrome press begins nothing. A +chrome release can end a live gesture, but a refused release cannot be +known to concern it. An accepted release already performs the ordinary +target effect; a consumed release has not, so it terminates from the +record. A stale `Drag`/`Up` with no accepted `Down` is inert rather than +sending a child tail or changing selection. **"TERMINATE", not "consume", and the distinction is the whole of review finding 1.** An earlier draft of this ruling said consume, and @@ -2760,42 +2802,42 @@ anything. A latch that empties while the child stays pressed is *exactly* the failure the draft claimed to prevent, now invisible because the bookkeeping looks right. -**RULED: a non-`Refused` `Up(Left)` must produce the target's -completion effect, and the row asserts the EFFECT, not the latch.** +**RULED: a live gesture plus non-`Refused` `Up(Left)` must produce the +target's completion effect exactly once, and the row asserts the +EFFECT, not the latch.** | target | required completion effect | |---|---| -| terminal panel, child reporting on | the **release is reported to the child** — the same report an in-content `Up` produces, at the gesture's last content cell per R-c2 | +| terminal panel, child reporting on | the **release is reported to the child in the recorded encoding** — an accepted content `Up` uses its accepted cell; a consumed/chrome `Up` uses the gesture's last content cell per R-c2 | | terminal panel, child reporting off | the **local terminal selection completes** — the selection the drag built is finalised, not abandoned | | document panel | the document gesture completes at the last content cell; an empty selection is cleared **without moving point** (the effect §5b's split table already assigns here) | -**And exactly once.** A completion delivered here must not be delivered -again by a later cancellation of the same gesture, which is reachable -because the four stranding transitions below can fire afterwards. +**And exactly once.** An accepted `Up` must not replay normally and +then also run the record-driven completion. Any completion must take +the record, so a later cancellation of the same gesture — reachable +through the four stranding transitions below — cannot repeat it. | # | mutation | must bite | |---|---|---| | P1 | chrome press returns `Accepted` | a chrome `Down(Left)` does not arm | -| P2 | `Refused` treated as `Accepted` | §5b's four `g5_substrate_a_refused_*` rows | +| P2 | `Refused` treated as `Accepted` | §5b's four `g5_substrate_a_refused_*` rows plus their no-target-effect assertions | | P3 | chrome `Up` empties the latch but delivers **no** completion | the child-release / selection-completion row — **the latch-only assertion must NOT satisfy this** | | P4 | `Refused` release delivers a completion | a refused release leaves both the latch and the child untouched | -| P5 | completion delivered, then a later cancellation delivers another | the no-duplicate-release row | +| P5 | an accepted content `Up` replays normally **and** invokes record-driven completion | the exact child stream contains one release, not two | +| P6 | completion delivered, then a later cancellation delivers another | the no-duplicate-release row | +| P7 | an accepted `Up` with no live record reaches the target | the stale-release row: no child bytes and no local/document selection mutation | +| P8 | an accepted `Drag` with no live record reaches the target | the orphan-drag row: no child bytes and no cursor/selection mutation | **P3 is written against the earlier draft's own weakness.** A row that asserts only `has_accepted_gesture() == false` passes while the child receives nothing, so the row must read the child's reported bytes or the terminal's selection state. -**That asymmetry is what earns the third state.** Under it, all three -outcomes are behaviourally distinct at the latch, so each is -falsifiable: - -| # | mutation | must bite | -|---|---|---| -| P1 | chrome press returns `Accepted` | a chrome `Down(Left)` does not arm | -| P2 | `Refused` treated as `Accepted` | §5b's four `g5_substrate_a_refused_*` rows | -| P3 | `Consumed` treated as `Refused` for `Up` | a chrome `Up(Left)` consumes a live gesture | -| P4 | consume gated on `Accepted` alone | the same chrome-release row as P3, from the other side | +**That asymmetry is what earns the third state.** `Refused` preserves a +live gesture, `Consumed` owns the surface but runs only a recorded +completion, and `Accepted` reaches content. Each is behaviourally +distinct at the effect/lifecycle seam, and the single matrix above is +the witness contract. **R-c2 does not discharge P3.** The producer normalizes a release that lands on chrome back to the last content cell, so a conforming @@ -2869,9 +2911,15 @@ effect too late. 2. **before detach teardown**, so a frontend that goes away does not take an owed release with it — detach is one of the four stranding transitions below, and it is the one with no later opportunity; -3. **before the frontend's next frame is produced**, so a cancellation - raised during projection cannot be overtaken by the frame that - caused it. +3. **after semantic projection returns and before any message it + returned is filtered or written.** Mapping cancellation is + discovered *inside* `SemanticRenderState::render_frame`, while the + successor `PresentMapped` is being constructed, so "before that + frame is produced" is impossible. The enforceable daemon seam is + immediately after `sem.render_frame(editor)` returns + (`src/daemon.rs:1428`–`:1430`) and before `for msg in &messages` + (`:1591`). The pending termination drains there, so the successor + frame cannot overtake the release its new mapping required. | # | mutation | must bite | |---|---|---| @@ -2879,7 +2927,8 @@ effect too late. | Q2 | park it but never drain | the same row, from the delivery side | | Q3 | drain **after** `dispatch_semantic_panel_pointer` instead of before | the **old-release-before-new-press ordering row** — assert the child's byte stream carries the release ahead of the press, not merely that both arrive | | Q4 | skip the drain on detach | the detach row: an owed release is delivered before teardown | -| Q5 | allow arming over a pending release | the invariant witness — kept, now as a **backstop** behind the ordering rows rather than as the guarantee | +| Q5 | enter the returned-message write loop before draining a projection-raised release | the daemon-order witness records the termination effect before the first successor-frame write | +| Q6 | allow arming over a pending release | the invariant witness — kept, now as a **backstop** behind the ordering rows rather than as the guarantee | #### Four transitions strand a live gesture, and they become defects HERE @@ -2899,23 +2948,13 @@ motion. The daemon side has the same shape and needs the same independently discriminating legs rather than one shared "resets something" assertion. -#### The ground truth must be RE-MEASURED, and this is evidenced +#### Re-measurement closed in revision 16 -This section's heading says `72da24a`. **Every production anchor in it -has moved**, measured at `b758c2e`: - -| §5a claims | actually | -|---|---| -| `dispatch_semantic_panel_pointer` at `src/editor.rs:2674` | `:2809` | -| `apply_terminal_gesture` at `src/editor.rs:3525` | `:3875` | -| `scroll_window` at `src/editor.rs:3845` | `:4195` | -| `Absent` clears the latch at `pmacs-gpu/src/main.rs:6909` | `:7206` | - -**The clause table itself is NOT assumed to have survived.** §5b -rewrote the dispatcher this lane measured, so each DONE/MISSING verdict -is owed a re-measurement before implementation, not a line-number -patch. A table that is right about *what* is missing and wrong about -*where* is the kind of record that gets trusted and then misleads. +The authoritative clause table is now the `2c0d3ff` table at the start +of §5a. The `72da24a` table is retained only under its explicit +**Superseded** heading. Revision 14's line-movement inventory did its +job by forcing a clause-by-clause scout; it is no longer a live +implementation obligation. #### Coherence impact (`COHERENCE.md` §20) @@ -2926,11 +2965,12 @@ patch. A table that is right about *what* is missing and wrong about inferred from this section's own MISSING column: - **Step 5 / GPU is `Partial`, and stays `Partial`.** It is floored by **5(a)** — no IME, no `set_ime_allowed`, so composed and CJK - input is impossible (`COHERENCE.md:462`). Completing 5(c) inside a + input is impossible (`COHERENCE.md:468`). Completing 5(c) inside a panel cannot lift a cell held down by a different clause. That is §2a's aggregation rule working as designed. - - **Step 8 is already `Works` on all three columns.** There is no - grade left to move; 8(b) is hardened, not opened. + - **Step 8 is already `Works` on all three columns** + (`COHERENCE.md:471`). There is no grade left to move; 8(b) is + hardened, not opened. - The draft's error was reading §5a's MISSING column as if it were the scorecard. **A section-local gap list is not a journey grade**, and the one place that decides grades is `COHERENCE.md`. @@ -2941,7 +2981,7 @@ patch. A table that is right about *what* is missing and wrong about - **Background work: none started**, and no existing attribution changes. -#### What revision 14 does NOT do +#### What revisions 14–16 do NOT reopen It does not re-open Q#BP-R1 (single click selects only), Q#BP-R2 (a terminal panel's chrome wheel is consumed, not clamped), or R-a/R-b/ diff --git a/docs/gui-stage1-input-framing.md b/docs/gui-stage1-input-framing.md index b76f0ff..308bb75 100644 --- a/docs/gui-stage1-input-framing.md +++ b/docs/gui-stage1-input-framing.md @@ -1,6 +1,13 @@ # GUI arc, Stage 1 — input foundation (framing) -**Status: revision 12 — APPROVED.** Revision 12 is §2's ground-truth +**Status: revision 13 — AWAITING APPROVAL with bottom-panel §5a +revision 16.** Revision 13 changes no previously ruled outcome. It +makes B1–B3's “per surface” scope explicit for the panel-document +surface, closing the ownership gap exposed by §5a's remeasurement: +panel replay is the prerequisite, while GUI 1b owns horizontal tick +production, residuals and the document-panel `view_left` effect. + +**Previously, revision 12 — APPROVED.** Revision 12 is §2's ground-truth re-measurement for Stage 1a and changes no ruling; it carries two corrections to claims that were wrong at the original anchor too. @@ -308,9 +315,9 @@ The crate has **exactly one** executable `event_loop.exit()`, in | # | Contract | Witness | Mutation | |---|---|---|---| -| B1 | Residual per **axis and surface** | deltas discarded | share one accumulator → surface-switch jump | -| B2 | Wheel-right raises leftmost column; wheel-down raises top line | `x` discarded | invert a sign → that axis's row | -| B3 | Clamps at content bounds; never a negative origin | no horizontal scroll to clamp | remove clamp → **at-bounds row: origin goes negative and the view blanks** | +| B1 | Residual per **axis and surface** — primary document, panel document and minimap are distinct owners | deltas discarded | share one accumulator → primary↔panel or document↔minimap surface-switch jump | +| B2 | Wheel-right raises the leftmost column of the **document surface under the pointer**; wheel-down raises its top line. Primary-document horizontal state stays frontend-local; a panel-document tick reaches the daemon's window-targeted `view_left` path | `x` discarded; panel replay currently claims and drops document `ScrollLeft`/`ScrollRight` | invert a sign → that axis's row; omit the panel effect → primary moves while the panel remains inert | +| B3 | Each document surface clamps at its own content bounds; never a negative origin, and wrap pins horizontal origin to zero | no horizontal scroll to clamp | remove a clamp → **at-bounds row: that surface's origin goes negative and the view blanks**; reuse primary bounds for a panel → unequal-width surface row fails | | B4 | Middle-click paste uses **PRIMARY on Linux** | no middle-click path | use `CLIPBOARD` → B4 only | | B5 | I-beam over text content only | no I-beam | extend over the gutter → B5 only | | B6 | Wheel over the minimap scrolls the **document viewport** with **its own residual accumulator**; click/drag remains scrub | **a FULL tick already scrolls today** — minimap pixels are `Elsewhere` (`main.rs:2061`) and the wheel falls through to `scroll_by_lines` (`main.rs:3373`). What fails is **fractional accumulation**, and **residual ownership distinct from the document's**: sub-tick minimap deltas are discarded, and a **surface-switch fractional witness** (part-tick over the minimap, then over the document) must not carry residue across | share the document's accumulator → the surface-switch fractional row jumps | @@ -322,6 +329,18 @@ blank**. The bound is *width − viewport*, saturating at zero for buffers narrower than the viewport, and **the right-bound witness asserts the final display column is still visible**. +**Panel-replay consequence, ruled after §5a's 2026-08-20 +re-measurement.** “Horizontal panel wheel” has three receiver outcomes, +not one. When terminal precedence selects child reporting it already +forwards `ScrollLeft` and `ScrollRight` as SGR codes 66/67. The local +terminal branch — selected by Shift, reporting-off or a scrolled-back +view — has no horizontal viewport and remains inert. **B1–B3 own the missing +document-panel leg**: fractional production/residuals in the GPU, the +lossless `PanelPointer` tick, and the daemon's window-targeted +`view_left` effect and clamp. This is a named deferral from panel replay, +whose source comment already assigned it here; it does not make the +prerequisite lane implement half of 1b early. + **Why B6 changed — and revision 5's reason was wrong.** Scrubbing on wheel is not *impossible*: the wheel handler already reads the cached `state.pointer_pos` for surface routing (`main.rs:3337`), so an absolute From 39b6fa7dbad89dafdb60d86a4de39e250c0c0b6d Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 20 Aug 2026 21:16:56 +0200 Subject: [PATCH 18/33] feat(panel): Q#BP-R4 --- the disposition, and the lifecycle table it enables Replaces the panel dispatcher's `bool` with a three-state PanelPointerOutcome decided BEFORE any target effect, and moves the gesture lifecycle into one table in the daemon. The old shape could not express the rule it needed. It validated, classified and mutated in one pass, so an Up or Drag with no accepted Down had already reached the child or the selection by the time the daemon consulted the latch. classify_panel_pointer now returns the disposition and carries the resolution it was decided from; apply_panel_pointer acts on that same resolution, so the editor stays the only authority and the daemon never re-derives chrome, target kind or content bounds. The table: a chrome press begins nothing; a left tail with no live record is inert; an Accepted release performs the ordinary in-content completion and takes the record; a Consumed release did not reach content, so it terminates from the record at the gesture's last valid content cell. Never both --- that is P5. apply_terminal_gesture now returns whether the gesture REACHED THE CHILD, so the latch is armed from the effect result rather than from a prediction about the modes. complete_panel_gesture routes both terminal domains back through that same shared path, which is what keeps "what a release does" from having a second implementation. Four witnesses, each reading a TARGET EFFECT and not the latch, and each biting its own mutation: P1 chrome press (classify chrome as Accepted), P3 chrome release on a terminal (drop the recorded completion), P7 orphan release (remove the Up live-gate), P8 orphan drag (remove the Drag live-gate). Two of those rows were vacuous when first written and are recorded here because the mutations are what caught them. P8 dragged over an EMPTY panel buffer, so panel_cell_byte returned None and point could not move whether the gate was there or not. P3 was written against a document panel --- but R-c lets document chrome Up fall through to content, so it classifies Accepted and never reaches the Consumed path it claimed to test; it now uses a terminal panel, on the legacy arm, because reading the live mapping generation ADVANCES the key and SS5b wired a key advance to cancel the live gesture, so the mapped fixture destroyed the gesture it was trying to complete. Adds view_is_dragging_for_test, the observable that separates a delivered completion from a latch that merely emptied. Also re-homes a doc paragraph that described peer_uses_mapped_panel_family while sitting above update_accepted_gesture; deleting the latter's doc with the function made the misplacement visible. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- src/daemon.rs | 501 +++++++++++++++++++++++++++++++++++------ src/editor.rs | 353 ++++++++++++++++++++++------- src/semantic_render.rs | 19 ++ src/terminal/view.rs | 16 ++ 4 files changed, 735 insertions(+), 154 deletions(-) diff --git a/src/daemon.rs b/src/daemon.rs index cb792f0..a0aef23 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1019,6 +1019,112 @@ fn panel_event_epochs_are_current( .is_some_and(|geometry| geometry.geometry_epoch == geometry_epoch) } +/// Parent 48 Q#BP-R4 — the authoritative lifecycle table. +/// +/// The disposition is decided BEFORE any target effect, and the live +/// record is consulted before a left tail reaches a child or a +/// selection. That ordering is the point: the old shape validated, +/// classified and mutated in one pass, so an `Up` or `Drag` with no +/// accepted `Down` had already landed by the time the latch was read. +/// +/// Exactly one completion per gesture. An `Accepted` release performs +/// the ordinary in-content completion and takes the record; a +/// `Consumed` release did not reach content, so it delivers the +/// RECORDED completion and takes the record. Running both is P5. +fn replay_panel_pointer( + editor: &mut EditorState, + semantic_states: &mut HashMap, + source: FrontendId, + buffer_id: crate::buffer::BufferId, + coord: pmacs_protocol::CellCoord, + kind: pmacs_protocol::MouseKind, + mods: pmacs_protocol::Modifiers, +) { + use crate::editor::PanelPointerOutcome as Outcome; + use pmacs_protocol::{MouseButton, MouseKind}; + + let disposition = editor.classify_panel_pointer(source, buffer_id, coord, kind); + let outcome = disposition.outcome(); + if outcome == Outcome::Refused { + // No effect, and the latch is left exactly as it was: a refused + // event cannot be known to concern the live gesture at all. + return; + } + let live = semantic_states + .get(&source) + .is_some_and(crate::semantic_render::SemanticRenderState::has_accepted_gesture); + + match kind { + MouseKind::Down(MouseButton::Left) => { + if outcome != Outcome::Accepted { + // A chrome press begins nothing. + return; + } + let reached_child = editor.apply_panel_pointer(source, &disposition, coord, kind, mods); + if let Some(state) = semantic_states.get_mut(&source) { + // Armed FROM THE EFFECT RESULT: `reached_child` is + // measured where the report branch is taken, not + // predicted from the modes beforehand. + state.arm_accepted_gesture(crate::semantic_render::AcceptedPanelGesture { + button: MouseButton::Left, + coord, + buffer_id, + reached_child, + }); + } + } + MouseKind::Drag(MouseButton::Left) => { + if outcome != Outcome::Accepted || !live { + // Stale tail, or a drag over chrome: inert. Any live + // record is retained rather than advanced, because the + // pointer is not over content. + return; + } + let _ = editor.apply_panel_pointer(source, &disposition, coord, kind, mods); + if let Some(state) = semantic_states.get_mut(&source) { + state.note_gesture_content_cell(coord); + } + } + MouseKind::Up(MouseButton::Left) => { + if !live { + // A release with no accepted press is inert. Letting it + // through would send a child tail or mutate a selection + // for a gesture that never began. + return; + } + match outcome { + Outcome::Accepted => { + let _ = editor.apply_panel_pointer(source, &disposition, coord, kind, mods); + if let Some(state) = semantic_states.get_mut(&source) { + // Taken WITHOUT counting a cancellation, and + // without also running the recorded completion. + let _ = state.consume_accepted_gesture(); + } + } + Outcome::Consumed => { + // The release landed on chrome, so the content path + // never ran. Terminate from the record, at its last + // valid content cell. + let record = semantic_states.get_mut(&source).and_then( + crate::semantic_render::SemanticRenderState::consume_accepted_gesture, + ); + if let Some(record) = record { + editor.complete_panel_gesture(source, &record, mods); + } + } + Outcome::Refused => unreachable!("refused returned above"), + } + } + _ => { + // Every other kind: a one-shot content effect, and it never + // touches the left-gesture latch. + if outcome == Outcome::Accepted { + let _ = editor.apply_panel_pointer(source, &disposition, coord, kind, mods); + } + } + } +} + /// §5b — which panel-pointer family this session speaks. /// /// **Read from the AUTHENTICATED source, never from the payload's @@ -1029,50 +1135,11 @@ fn panel_event_epochs_are_current( /// Consulted BEFORE the payload is trusted, before any generation is /// validated and before any mutation: the family decides which variant /// is even admissible, so it cannot depend on the variant's contents. -/// §5b G5c/G5d/G5g — update the accepted-gesture latch for one -/// ACCEPTED panel gesture. /// -/// Called only after every gate has passed, so "accepted" means exactly -/// that. Three rules, and each closes its own hole: -/// -/// * only a left `Down` ARMS — a right press opens a menu and ends -/// there, and `Move`, wheel and the other buttons begin nothing, so -/// arming on them would manufacture a delayed release at the next -/// authority loss (G5g); -/// * a left `Up` CONSUMES, or a later invalidation finds a gesture it -/// believes live and duplicates its release (G5c); -/// * an `Up` with no armed gesture is INERT — it terminates nothing, -/// because nothing began (G5d). -fn update_accepted_gesture( - semantic_states: &mut HashMap, - source: FrontendId, - kind: pmacs_protocol::MouseKind, - coord: pmacs_protocol::CellCoord, - buffer_id: crate::buffer::BufferId, - reached_child: bool, -) { - use pmacs_protocol::{MouseButton, MouseKind}; - let Some(state) = semantic_states.get_mut(&source) else { - return; - }; - match kind { - MouseKind::Down(MouseButton::Left) => { - state.arm_accepted_gesture(crate::semantic_render::AcceptedPanelGesture { - button: MouseButton::Left, - coord, - buffer_id, - reached_child, - }); - } - MouseKind::Up(MouseButton::Left) => { - // Inert when nothing was armed: `take` on `None` is the - // whole of G5d. - let _ = state.consume_accepted_gesture(); - } - _ => {} - } -} - +/// **Re-homed here.** This paragraph documented THIS function but sat +/// above `update_accepted_gesture`, whose own doc followed it in the +/// same block. Deleting that function's doc with it made the +/// misplacement visible. fn peer_uses_mapped_panel_family(session_registry: &SessionRegistry, source: FrontendId) -> bool { session_registry .session_state(source) @@ -2605,21 +2672,15 @@ fn handle_dispatcher_event( // armed gesture, so the authority loss that // should have ended it finds nothing, and the // child holds the button down forever. - if editor - .dispatch_semantic_panel_pointer(source, buffer_id, coord, kind, mods) - { - update_accepted_gesture( - semantic_states, - source, - kind, - coord, - buffer_id, - // Whether the press reached a child is - // replay's to know; until replay exists - // no press does. - false, - ); - } + replay_panel_pointer( + editor, + semantic_states, + source, + buffer_id, + coord, + kind, + mods, + ); } } FrontendEvent::PanelPointerMapped { @@ -2676,18 +2737,15 @@ fn handle_dispatcher_event( // survive the ladder; it does not make a // surviving one land, so this arm needs the same // gate. - if editor - .dispatch_semantic_panel_pointer(source, buffer_id, coord, kind, mods) - { - update_accepted_gesture( - semantic_states, - source, - kind, - coord, - buffer_id, - false, - ); - } + replay_panel_pointer( + editor, + semantic_states, + source, + buffer_id, + coord, + kind, + mods, + ); } } FrontendEvent::Pointer { @@ -7452,6 +7510,309 @@ mod tests { } } + // ----------------------------------------------------------------- + // Parent 48 Q#BP-R4 — the lifecycle table's witnesses. + // + // Every row here reads a TARGET EFFECT, never the latch alone. The + // framing is explicit that a latch-only assertion must not satisfy + // these: emptying the latch is bookkeeping, and the defect being + // fenced is precisely a gesture whose bookkeeping looks right while + // the target heard nothing. + // ----------------------------------------------------------------- + + /// The panel's buffer, an in-content cell, and a cell on its CHROME + /// (the mode line, which is the grid's last row per R-c). + fn panel_buffer_and_chrome_coord( + editor: &crate::editor::EditorState, + fid: FrontendId, + panel: crate::window::WindowId, + ) -> (crate::buffer::BufferId, pmacs_protocol::CellCoord) { + let core = editor.core.borrow(); + let grid = core.panel_grid_size(fid).expect("a live panel grid"); + let content_rows = grid.rows.saturating_sub(1); + assert!( + content_rows > 0, + "fixture: the panel must have content rows" + ); + ( + core.windows[&panel].buffer_id, + pmacs_protocol::CellCoord::new(content_rows, 0), + ) + } + + /// The side window's cursor, for reading a replayed effect. + fn panel_cursor(editor: &crate::editor::EditorState, panel: crate::window::WindowId) -> u64 { + editor.core.borrow().windows[&panel].cursor + } + + /// P1 — a press on the band's MODE LINE begins nothing. + /// + /// The merge made this arm the latch, because `Consumed` and + /// `Accepted` were the same `true`. The row reads the cursor as well + /// as the latch: a chrome press must not move point either. + #[test] + fn r4_p1_a_chrome_press_neither_arms_nor_moves_point() { + let fid = FrontendId(790); + let (mut editor, mut states, mut render, _document, panel, epochs) = + panel_session_at(PROTOCOL_VERSION, fid); + let (buffer_id, chrome) = panel_buffer_and_chrome_coord(&editor, fid, panel); + let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); + let before = panel_cursor(&editor, panel); + + let generation = live_generation(PanelArm::Mapped, &editor, &mut states, fid); + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut states, + &mut render, + arm_pointer( + PanelArm::Mapped, + fid, + epochs, + buffer_id, + generation, + chrome, + press, + ), + ); + + assert!( + !states[&fid].has_accepted_gesture(), + "a chrome press must not arm: the mode line is not content, \ + and a gesture armed there would be cancelled or released \ + for a press the target never saw" + ); + assert_eq!( + panel_cursor(&editor, panel), + before, + "and it must not move point --- `Consumed` means the panel \ + claimed the cell, not that it replayed anything" + ); + } + + /// P7 — a release with no accepted press reaches nothing. + /// + /// This is the stale-tail case the pre-effect disposition exists + /// for. The document `Up` arm clears an ACTIVE BUT EMPTY selection, + /// so the fixture arms one and asserts it SURVIVES: an inert release + /// must not run that clear. + #[test] + fn r4_p7_a_release_with_no_accepted_press_is_inert() { + let fid = FrontendId(791); + let (mut editor, mut states, mut render, _document, panel, epochs) = + panel_session_at(PROTOCOL_VERSION, fid); + let buffer_id = editor.core.borrow().windows[&panel].buffer_id; + let release = pmacs_protocol::MouseKind::Up(pmacs_protocol::MouseButton::Left); + let inside = pmacs_protocol::CellCoord::new(0, 0); + + // An empty selection is exactly what an accepted release would + // clear, so it is the discriminator for "did the effect run". + { + let mut core = editor.core.borrow_mut(); + let cursor = core.windows[&panel].cursor; + core.windows.get_mut(&panel).expect("panel").selection = + Some(crate::window::Selection { anchor: cursor }); + } + assert!( + !states[&fid].has_accepted_gesture(), + "fixture: no gesture is live" + ); + + let generation = live_generation(PanelArm::Mapped, &editor, &mut states, fid); + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut states, + &mut render, + arm_pointer( + PanelArm::Mapped, + fid, + epochs, + buffer_id, + generation, + inside, + release, + ), + ); + + assert!( + editor.core.borrow().windows[&panel].selection.is_some(), + "a release with no accepted press must reach NOTHING --- it \ + cleared a selection it never began, which on a terminal is \ + a child tail for a press that never happened" + ); + } + + /// P8 — a drag with no accepted press reaches nothing. + /// + /// The document `Drag` arm moves point unconditionally, so an + /// orphan drag used to drive the cursor from a gesture that never + /// began. + #[test] + fn r4_p8_an_orphan_drag_does_not_move_point() { + let fid = FrontendId(792); + let (mut editor, mut states, mut render, _document, panel, epochs) = + panel_session_at(PROTOCOL_VERSION, fid); + let buffer_id = editor.core.borrow().windows[&panel].buffer_id; + let drag = pmacs_protocol::MouseKind::Drag(pmacs_protocol::MouseButton::Left); + // THE PANEL BUFFER MUST HAVE CONTENT. `*panel*` is created empty, + // so `panel_cell_byte` returns `None` for every interesting cell + // and the drag cannot move point whether it is gated or not — + // the row would pass vacuously. Caught by the gating mutation + // failing to bite. + foreign_edit(&editor, buffer_id, b"alpha beta gamma\ndelta\n"); + // A cell the cursor is NOT already on, or the row cannot fail. + let elsewhere = pmacs_protocol::CellCoord::new(0, 6); + let before = panel_cursor(&editor, panel); + assert_eq!(before, 0, "fixture: point starts at the buffer head"); + + let generation = live_generation(PanelArm::Mapped, &editor, &mut states, fid); + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut states, + &mut render, + arm_pointer( + PanelArm::Mapped, + fid, + epochs, + buffer_id, + generation, + elsewhere, + drag, + ), + ); + + assert_eq!( + panel_cursor(&editor, panel), + before, + "an orphan drag must not move point" + ); + assert!( + !states[&fid].has_accepted_gesture(), + "and it must not manufacture a record by writing to one" + ); + } + + /// P3 — a chrome release on a TERMINAL panel completes the gesture + /// from the record. + /// + /// **The document leg cannot test this, and an earlier version of + /// this row tried.** R-c lets document chrome `Up` fall THROUGH to + /// content, so it classifies `Accepted` and takes the ordinary path; + /// the row passed with the recorded completion deleted. Only a + /// terminal panel returns `Consumed` for a chrome release — which is + /// exactly the path the framing named, where the dispatcher returns + /// before `apply_terminal_gesture`. + /// + /// The observable is the terminal view's DRAG STATE, not the latch: + /// `finish_selection` is what takes it, so a gesture that ended + /// without a delivered completion stays mid-drag forever. + #[test] + fn r4_p3_a_chrome_release_completes_a_terminal_gesture_from_the_record() { + use crate::terminal::{TerminalSpec, view::TerminalViewKey}; + + // LEGACY, deliberately. This row is about the Consumed/terminal + // completion, which is family-independent — and the mapped arm + // cannot express it, because reading the live mapping generation + // ADVANCES the key, and §5b wired a key advance to cancel the + // live gesture (G5a). The fixture would destroy the gesture it + // is trying to complete, which is how an earlier version of this + // row failed while the implementation was correct. + let fid = FrontendId(793); + let (mut editor, mut states, mut render, _document, panel, _epochs) = + panel_session_at(LEGACY_PANEL_VERSION, fid); + + let mut spec = TerminalSpec::new("/bin/sh"); + spec.args = vec!["-c".into(), "sleep 30".into()]; + spec.rows = 4; + spec.cols = 20; + let terminal_buffer = editor + .terminal_manager + .borrow_mut() + .open( + spec, + &mut editor.core.borrow_mut(), + &mut editor.process_supervisor.borrow_mut(), + ) + .expect("open panel terminal"); + { + let mut core = editor.core.borrow_mut(); + let view = { + let registry = core.registry.clone(); + let registry = registry.borrow(); + crate::text_view::TextView::new( + registry.get(terminal_buffer).expect("terminal buffer"), + ) + }; + let window = core.windows.get_mut(&panel).expect("panel window"); + window.buffer_id = terminal_buffer; + window.text_view = view; + } + // Re-ship the declaration so the epochs match the terminal panel. + let epochs = shipped_declaration(&editor, fid, &mut states); + let (buffer_id, chrome) = panel_buffer_and_chrome_coord(&editor, fid, panel); + assert_eq!( + buffer_id, terminal_buffer, + "fixture: the panel is the terminal" + ); + let inside = pmacs_protocol::CellCoord::new(0, 0); + let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); + let release = pmacs_protocol::MouseKind::Up(pmacs_protocol::MouseButton::Left); + let key = TerminalViewKey::new(fid, panel, terminal_buffer); + + dispatch_panel_event( + &mut editor, + fid, + LEGACY_PANEL_VERSION, + &mut states, + &mut render, + arm_pointer(PanelArm::Legacy, fid, epochs, buffer_id, 0, inside, press), + ); + assert!( + states[&fid].has_accepted_gesture(), + "fixture: the content press armed" + ); + assert!( + editor + .terminal_manager + .borrow() + .view_is_dragging_for_test(key), + "fixture: the press began a local terminal drag" + ); + + dispatch_panel_event( + &mut editor, + fid, + LEGACY_PANEL_VERSION, + &mut states, + &mut render, + arm_pointer(PanelArm::Legacy, fid, epochs, buffer_id, 0, chrome, release), + ); + + assert!( + !editor + .terminal_manager + .borrow() + .view_is_dragging_for_test(key), + "the RECORDED completion must run: a terminal chrome release \ + returns before the replay path, so without it the drag never \ + finishes while the latch looks correctly empty" + ); + assert!( + !states[&fid].has_accepted_gesture(), + "and the record is taken" + ); + assert_eq!( + states[&fid].panel_gesture_cancellations(), + 0, + "a completion is not a cancellation" + ); + } + /// The panel's buffer, and a coordinate one row past its grid. fn panel_buffer_and_outside_coord( editor: &crate::editor::EditorState, diff --git a/src/editor.rs b/src/editor.rs index adc925c..deed2f8 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -408,6 +408,62 @@ impl PanelMappingSnapshot { } } +/// How an authenticated panel event relates to the authoritative panel +/// surface, decided BEFORE any target effect (parent 48 Q#BP-R4). +/// +/// Two branches once agreed on `bool` while disagreeing on its meaning +/// — §5b read it as "the gesture was accepted" and drove the +/// accepted-gesture latch off it, while panel replay read it as "the +/// event was consumed here", chrome swallows included. Merged, a press +/// on the band's mode line armed a gesture that never began in content. +/// **This type exists so that collision cannot be re-created silently:** +/// a two-state answer with the corrected meaning would be right today +/// and would let the next author restore the bug without touching a +/// test. +#[derive(Copy, Clone, Eq, PartialEq, Debug)] +pub enum PanelPointerOutcome { + /// Not addressable as this panel: no grid, out of grid, no side + /// window, or a buffer that is not the one shown there. + Refused, + /// This panel owns the cell/event, but it is deliberately not a + /// content gesture — the chrome claims of Q#BP-R2 and R-c. + Consumed, + /// A content gesture for the resolved target. + Accepted, +} + +/// A classified panel gesture: the outcome, and the resolution it was +/// decided from. +/// +/// The resolution travels with the outcome so that +/// `EditorState::apply_panel_pointer` acts on the SAME derivation the +/// disposition was decided by. Q#BP-R4 fixes the editor as the only +/// authority for that derivation; handing the daemon a bare outcome +/// and letting it re-derive chrome or target kind would be the hole +/// §5b closed, reopened beside the dispatcher. +pub struct PanelPointerDisposition { + outcome: PanelPointerOutcome, + resolved: Option, +} + +impl PanelPointerDisposition { + /// The disposition, for the daemon's lifecycle table. + #[must_use] + pub fn outcome(&self) -> PanelPointerOutcome { + self.outcome + } +} + +/// The panel target a disposition resolved to, derived once. +struct ResolvedPanelTarget { + side: WindowId, + buffer_id: crate::buffer::BufferId, + is_terminal: bool, + /// The grid's rows MINUS the mode line — never the frame (R-c). + content_rows: u32, + cols: u32, +} + /// What decides the mapping BELOW the geometry, which differs by /// target kind. #[derive(Clone, PartialEq, Eq, Debug)] @@ -2769,7 +2825,8 @@ impl EditorState { .is_some_and(|now| now != current) } - /// Apply an accepted `FrontendEvent::PanelPointer` gesture (Q#BP16). + /// Classify an authenticated panel gesture, WITHOUT applying it + /// (Q#BP-R4). /// /// Steps 2, 5, and 6 of Q#BP16's ladder are re-derived here from the /// daemon's own state — a live, non-hidden side window whose current @@ -2778,6 +2835,104 @@ impl EditorState { /// epochs) belong to the caller, because only the session holds the /// declaration the frontend was actually looking at. /// + /// **The answer is a DISPOSITION, decided before any target + /// effect.** Q#BP-R4 fixes two facts and this split exists to hold + /// them: the editor is the **only** authority that derives + /// `Refused`/`Consumed`/`Accepted`, and the disposition completes + /// before a left `Drag`/`Up` reaches a child or a selection. The old + /// single function could not honour the second — it validated, + /// classified and mutated in one pass, so a tail with no accepted + /// press had already landed by the time the daemon consulted the + /// latch. + /// + /// The returned value carries the resolution it was decided from, so + /// [`Self::apply_panel_pointer`] acts on the same derivation rather + /// than repeating it. **A second derivation is the hole §5b closed**; + /// reopening it beside the dispatcher would be the same defect in a + /// new place. + #[must_use] + pub fn classify_panel_pointer( + &self, + frontend_id: FrontendId, + buffer_id: crate::buffer::BufferId, + coord: CellCoord, + kind: pmacs_protocol::MouseKind, + ) -> PanelPointerDisposition { + use pmacs_protocol::MouseKind as PKind; + + let refused = PanelPointerDisposition { + outcome: PanelPointerOutcome::Refused, + resolved: None, + }; + + let Some(size) = self.core.borrow().panel_grid_size(frontend_id) else { + return refused; + }; + if coord.row >= size.rows || coord.col >= size.cols { + return refused; + } + // Parent 48 R-c: the panel's LAST ROW IS ITS MODE LINE. Projection + // derives content as `rows - 1` while the frontend hit-tests the + // whole frame, so a `PanelPointer` can legitimately name chrome — + // and chrome is never a content cell for either target. + let content_rows = size.rows.saturating_sub(1); + if content_rows == 0 { + return refused; + } + let is_terminal = self.terminal_manager.borrow().is_terminal(buffer_id); + let side = { + let core = self.core.borrow(); + let Some(side) = core.side_window_for(frontend_id) else { + return refused; + }; + if core.windows.get(&side).map(|window| window.buffer_id) != Some(buffer_id) { + return refused; + } + side + }; + + let resolved = Some(ResolvedPanelTarget { + side, + buffer_id, + is_terminal, + content_rows, + cols: size.cols, + }); + let on_chrome = coord.row >= content_rows; + let is_wheel = matches!( + kind, + PKind::ScrollUp | PKind::ScrollDown | PKind::ScrollLeft | PKind::ScrollRight + ); + + // Everything below is CLAIMED by this panel — the cell is ours — + // and the only question left is whether it is a content gesture. + let outcome = if on_chrome { + // Q#BP-R2, step 3 of the ordering: a terminal panel's chrome + // wheel is consumed BEFORE focus, before `active_frontend`, + // and before any controller claim. Placing it after + // activation would leave the wheel changing focus while + // scrolling nothing. + // + // TUI parity for the rest: a terminal never sees a chrome + // coordinate (`dispatch_mouse` rejects every kind above + // `inner_rows`). Document chrome mirrors the TUI's PER-KIND + // rule — presses and motion are reserved, while `Up` and the + // wheel fall through, because an `Up` must still terminate a + // gesture begun in content and a chrome wheel still scrolls. + if is_terminal || matches!(kind, PKind::Down(_) | PKind::Drag(_) | PKind::Move) { + let _ = is_wheel; + PanelPointerOutcome::Consumed + } else { + PanelPointerOutcome::Accepted + } + } else { + PanelPointerOutcome::Accepted + }; + PanelPointerDisposition { outcome, resolved } + } + + /// Apply a classified panel gesture to its target (Q#BP-R4). + /// /// **Activation is not uniform, and Q#BP16 says so explicitly.** A /// **press** focuses any panel — that is click-to-focus, and /// `Down(Right)` is the context-menu gesture, so both buttons count. @@ -2796,112 +2951,132 @@ impl EditorState { /// Review round 1 (R2-5) found the terminal clause applied to both. /// Bare hover neither focuses nor claims, on either kind. /// - /// **Replay is out of scope in Stage 2B-2.** Driving selection, - /// listview rows, or child SGR reporting is parent acceptance 48, - /// which needs the GPU band and lands in Stage 2B-3. - /// - /// Returns whether the gesture was accepted. - /// - /// `#[must_use]` because the accepted-gesture latch is driven off - /// this answer, and discarding it silently arms on rejected presses - /// and consumes on rejected releases. - #[must_use] - pub fn dispatch_semantic_panel_pointer( + /// **Only `Accepted` reaches a target.** A `Consumed` disposition + /// returns without effect: the claim was the effect. Returns whether + /// the gesture reached a CHILD, which is what the accepted-gesture + /// latch records — the framing requires arming "from the effect + /// result", so this is measured rather than assumed. + pub fn apply_panel_pointer( &mut self, frontend_id: FrontendId, - buffer_id: crate::buffer::BufferId, + disposition: &PanelPointerDisposition, coord: CellCoord, kind: pmacs_protocol::MouseKind, mods: pmacs_protocol::Modifiers, ) -> bool { use pmacs_protocol::MouseKind as PKind; - let Some(size) = self.core.borrow().panel_grid_size(frontend_id) else { + if disposition.outcome != PanelPointerOutcome::Accepted { + return false; + } + let Some(target) = disposition.resolved.as_ref() else { return false; }; - if coord.row >= size.rows || coord.col >= size.cols { - return false; - } - // Parent 48 R-c: the panel's LAST ROW IS ITS MODE LINE. Projection - // derives content as `rows - 1` while the frontend hit-tests the - // whole frame, so a `PanelPointer` can legitimately name chrome — - // and chrome is never a content cell for either target. - let content_rows = size.rows.saturating_sub(1); - if content_rows == 0 { - return false; - } - let on_chrome = coord.row >= content_rows; - let is_wheel = matches!( - kind, - PKind::ScrollUp | PKind::ScrollDown | PKind::ScrollLeft | PKind::ScrollRight - ); - let is_terminal = self.terminal_manager.borrow().is_terminal(buffer_id); - let side = { - let core = self.core.borrow(); - let Some(side) = core.side_window_for(frontend_id) else { - return false; - }; - if core.windows.get(&side).map(|window| window.buffer_id) != Some(buffer_id) { - return false; - } - side - }; - - // Q#BP-R2, step 3 of the ordering: a terminal panel's CHROME WHEEL - // is not a terminal gesture at all, so it is consumed HERE — - // before `focus_window`, before `active_frontend`, before any - // controller claim, and before the shared terminal path. - // - // Placing this below the activation block would leave the wheel - // CHANGING FOCUS while scrolling nothing and claiming no - // controller: `activates` is `!Move` for a terminal, so the wheel - // already activates. That half-state is what the - // activate-then-claim rule exists to prevent. - if is_terminal && on_chrome && is_wheel { - return true; - } - if on_chrome { - if is_terminal { - // TUI parity: a terminal never sees a chrome coordinate - // (`dispatch_mouse` rejects every kind above `inner_rows` - // for a terminal window). The wheel left above; the rest - // stops here. - return true; - } - // Document chrome mirrors the TUI's PER-KIND rule: presses and - // motion are reserved, while `Up` and the wheel fall through — - // an `Up` must still terminate a gesture begun in content, and - // a chrome wheel still scrolls. - if matches!(kind, PKind::Down(_) | PKind::Drag(_) | PKind::Move) { - return true; - } - } - - let activates = if is_terminal { + let activates = if target.is_terminal { !matches!(kind, PKind::Move) } else { matches!(kind, PKind::Down(_)) }; if activates { let mut core = self.core.borrow_mut(); - core.focus_window(frontend_id, side); + core.focus_window(frontend_id, target.side); core.active_frontend = frontend_id; } - if is_terminal { + if target.is_terminal { // The ONE terminal pointer path, shared with the TUI and the // document terminal. The viewport is `content_rows`, never the // full grid: passing the frame would make the mode line a child // cell and put every clamp one row out. - let viewport = CellSize::new(content_rows, size.cols); - let key = TerminalViewKey::new(frontend_id, side, buffer_id); - self.apply_terminal_gesture(key, viewport, coord, kind, mods, (coord.row, coord.col)); - return true; + let viewport = CellSize::new(target.content_rows, target.cols); + let key = TerminalViewKey::new(frontend_id, target.side, target.buffer_id); + return self.apply_terminal_gesture( + key, + viewport, + coord, + kind, + mods, + (coord.row, coord.col), + ); } - self.replay_panel_document_gesture(frontend_id, side, coord, kind, mods); - true + self.replay_panel_document_gesture(frontend_id, target.side, coord, kind, mods); + false + } + + /// Deliver the RECORDED completion for a gesture the ordinary path + /// did not complete (parent 48 Q#BP-R4). + /// + /// Used when a release is `Consumed` — it landed on chrome, so the + /// content path never ran — or when an authority loss cancels a + /// live gesture. An `Accepted` release must NOT come here: it + /// already performed the ordinary in-content completion, and doing + /// both is the duplicate P5 forbids. + /// + /// **The domain is read from the record, not from where the pointer + /// is now.** `buffer_id` says which panel the gesture belongs to and + /// `reached_child` says whether a press was ever delivered, so the + /// three completions separate without a fourth field: + /// + /// * reporting terminal → the child gets its release; + /// * local terminal → the drag's selection is finalised; + /// * document → the gesture completes and an empty selection is + /// cleared without moving point. + /// + /// Delivery goes through the SAME paths an in-content release uses, + /// at the record's last valid content cell (R-c2). A second + /// implementation of "what a release does" would drift from the + /// first, which is the whole reason the shared terminal path exists. + pub fn complete_panel_gesture( + &mut self, + frontend_id: FrontendId, + record: &crate::semantic_render::AcceptedPanelGesture, + mods: pmacs_protocol::Modifiers, + ) { + use pmacs_protocol::{MouseButton as PButton, MouseKind as PKind}; + + let release = PKind::Up(PButton::Left); + let Some(size) = self.core.borrow().panel_grid_size(frontend_id) else { + return; + }; + let content_rows = size.rows.saturating_sub(1); + if content_rows == 0 { + return; + } + let side = { + let core = self.core.borrow(); + let Some(side) = core.side_window_for(frontend_id) else { + return; + }; + // The panel must still be showing the buffer the gesture + // belongs to. If it is not, the four stranding transitions + // own this record, not an ordinary completion. + if core.windows.get(&side).map(|window| window.buffer_id) != Some(record.buffer_id) { + return; + } + side + }; + + if self.terminal_manager.borrow().is_terminal(record.buffer_id) { + // Both terminal domains route here: `apply_terminal_gesture` + // reports to the child when the modes allow and finalises the + // local selection otherwise, which is exactly the split this + // completion needs. + let viewport = CellSize::new(content_rows, size.cols); + let key = TerminalViewKey::new(frontend_id, side, record.buffer_id); + let _ = self.apply_terminal_gesture( + key, + viewport, + record.coord, + release, + mods, + (record.coord.row, record.coord.col), + ); + return; + } + + self.replay_panel_document_gesture(frontend_id, side, record.coord, release, mods); } /// Replay one accepted gesture into a DOCUMENT panel (parent 48). @@ -3872,6 +4047,13 @@ impl EditorState { /// A second copy of this precedence in the GPU lane is exactly how /// Shift-drag or scrolled-back selection would silently diverge /// between frontends. + /// Returns whether the gesture REACHED THE CHILD as a mouse report. + /// + /// Parent 48 Q#BP-R4 arms the accepted-gesture latch "from the + /// effect result", so `reached_child` has to be measured where the + /// branch is taken rather than predicted from the modes beforehand: + /// the report is gated on five conditions and `encode_mouse` can + /// still decline. fn apply_terminal_gesture( &mut self, key: TerminalViewKey, @@ -3880,12 +4062,12 @@ impl EditorState { kind: TerminalMouseKind, modifiers: TerminalModifiers, global: (u32, u32), - ) { + ) -> bool { let shift = modifiers.contains(TerminalModifiers::SHIFT); let (at_bottom, modes, screen_size) = { let mut manager = self.terminal_manager.borrow_mut(); let Some(status) = manager.view_status_for_size(key, viewport_size) else { - return; + return false; }; let modes = manager.modes_for_view(key).unwrap_or_default(); let screen_size = manager.screen_size_for_view(key).unwrap_or(viewport_size); @@ -3915,7 +4097,7 @@ impl EditorState { self.claim_terminal_controller(key); } self.send_terminal_bytes(key.buffer_id, &bytes); - return; + return true; } if claims_control { @@ -3946,6 +4128,9 @@ impl EditorState { } _ => {} } + // The local branch: scrollback, local selection or the menu. The + // child heard nothing. + false } fn is_double_click( diff --git a/src/semantic_render.rs b/src/semantic_render.rs index 2f429f4..408940e 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -769,6 +769,25 @@ impl SemanticRenderState { cancelled } + /// The live gesture record, if any — parent 48 Q#BP-R4's + /// "live record" test, and the source of the recorded completion. + #[must_use] + pub fn accepted_gesture(&self) -> Option<&AcceptedPanelGesture> { + self.accepted_gesture.as_ref() + } + + /// Q#BP-R4: an accepted `Drag` continues the gesture and moves its + /// LAST VALID CONTENT CELL, which is where a release that later + /// lands on chrome gets delivered (R-c2). + /// + /// Inert with nothing armed. A drag with no accepted press is a + /// stale tail and must not create a record by writing to one. + pub fn note_gesture_content_cell(&mut self, coord: pmacs_protocol::CellCoord) { + if let Some(gesture) = self.accepted_gesture.as_mut() { + gesture.coord = coord; + } + } + /// Whether a gesture is currently accepted, for assertions. #[must_use] pub fn has_accepted_gesture(&self) -> bool { diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 414d607..44aac29 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -501,6 +501,22 @@ impl TerminalManager { moved || was_dragging } + /// Whether a view is mid-drag, for parent 48 Q#BP-R4's completion + /// witnesses. + /// + /// A local terminal gesture is "finished" exactly when + /// `finish_selection` takes `drag`, so this is the observable that + /// separates a delivered completion from a latch that merely + /// emptied — which is the distinction the framing requires those + /// rows to assert. + #[doc(hidden)] + #[must_use] + pub fn view_is_dragging_for_test(&self, key: TerminalViewKey) -> bool { + self.views + .get(&key) + .is_some_and(|state| state.drag.is_some()) + } + /// Clear one view's terminal selection without changing its scroll anchor. pub fn clear_selection(&mut self, key: TerminalViewKey) -> bool { let Some(state) = self.views.get_mut(&key) else { From 48057d7667967be50dd80e36ebb8aa583bba5fd0 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 20 Aug 2026 21:40:02 +0200 Subject: [PATCH 19/33] fix(panel): G5k --- the gesture records its domain, and every tail obeys it Answers review of 39b6fa7. The three-state disposition was sound; the record and effect half downstream of it were not. apply_panel_pointer returned a bare bool, so the record carried neither the resolved target nor the reporting contract, and the daemon drove accepted Drag/Up back through the mode-sensitive adapter. That adapter re-reads Shift, the scrollback position and the child's mouse modes on every event --- which is G5k's named mutation verbatim. A press reported to the child followed by a release re-evaluated after the child turned reporting off leaves that child holding a button down; the reverse transition sends a child an Up for a Down it never saw. The recorded completion had the same defect and additionally re-derived the current side window, returning when it had changed --- precisely the transitions task 19 must terminate, so the completion they need was the one thing that refused to run. The press now resolves a PanelGestureDomain --- Document{window}, TerminalChild{window, buffer, modes} or TerminalLocal{window, buffer} --- and the record carries it. Tails and completions route through replay_panel_gesture_in_domain, which gates on nothing: not Shift, not the scroll position, not the child's current modes, not the panel's current identity. apply_terminal_gesture reports which way it routed so the domain is measured where the branch is taken. Arming now requires an effect: a press the target refused records nothing. G5k(a)-(d) plus P3's reporting leg, P4 and P5. Every row reads a TARGET EFFECT --- the child's byte stream in order, the terminal drag state, or the document selection --- never the latch. Each bites its own mutation, and G5k's four legs all fail under the framing's own named mutation applied verbatim. Two seams exist because nothing else exposes what the child received: an opt-in child-input tap, off by default, and a drag-state read. Also corrects the recovery ledger, which still said implementation was paused and the bool collision unfixed. Records for the ci-red registry rather than hiding it: during this work composition_overhead_under_ten_percent and pty_mode_child_sees_a_tty redded together in one --lib run at load 21 and each passed in isolation immediately after --- U9's signature, and neither path is touched here. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 33 ++- src/daemon.rs | 576 +++++++++++++++++++++++++++++++++++++++- src/editor.rs | 263 ++++++++++++------ src/semantic_render.rs | 23 +- src/terminal/screen.rs | 16 ++ src/terminal/session.rs | 35 +++ src/terminal/view.rs | 8 + 7 files changed, 853 insertions(+), 101 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index de0aab8..4935211 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -281,11 +281,31 @@ from #171 and #215 — the correction the 1b lane missed, honoured here. **`githubsucks/panel-pointer-replay` is the authoritative tip** (the ref, not a SHA). Recover with `git fetch githubsucks && git checkout panel-pointer-replay`. -- **No PR yet. Checkpoint: §5a framing revision 16 and its GUI Stage - 1b revision-13 ownership amendment AWAITING APPROVAL (revision 15 - was reviewed, corrected and folded into this branch); - IMPLEMENTATION STILL PAUSED, now on approval rather than on a - blocker.** §5a's **pre-merge** replay contract was approved at +- **No PR yet. Checkpoint: framing revision 16 APPROVED; + IMPLEMENTATION UNDER WAY.** Read the tip with + `git log --oneline githubsucks/main..HEAD`; no count or SHA is + recorded here, for the reason the §5b lane learned twice. + - **LANDED: Q#BP-R4's pre-effect disposition and the lifecycle + table** — `PanelPointerOutcome` (`Refused`/`Consumed`/`Accepted`) + decided before any target effect, with the resolution carried so + the daemon never re-derives. + - **LANDED: G5k's recorded gesture domain.** The first version was + reviewed and rejected: it drove tails through the mode-sensitive + adapter, which re-reads Shift, scroll position and the child's + modes per event — G5k's named mutation. The press now records + `PanelGestureDomain` (document / terminal-child-with-encoding / + terminal-local) and every tail and completion follows it. + - **Witnesses: G5k(a)–(d), P1, P3 both legs, P4, P5, P7, P8.** Each + reads a TARGET EFFECT — the child's byte stream, the terminal drag + state, or the document selection — never the latch alone. Each + bites its own mutation, including G5k's verbatim. + - **REMAINING, in order:** task 18's pending-release slot and drains, + task 19's four stranding transitions, then the full head-exact gate + and the PR. + - **Two test seams added for this:** an opt-in child-input tap + (`start_send_tap_for_test`) and a drag-state read + (`view_is_dragging_for_test`). Nothing else exposes what the child + actually received, which is what these rows must assert. §5a's **pre-merge** replay contract was approved at revision 12; revisions 14–16 are the post-merge amendment now under review. Revision 13 ruled Q#BP-R3 and blocked the lane on a protocol-bearing mapping generation; **that block is DISCHARGED** — @@ -312,7 +332,8 @@ from #171 and #215 — the correction the 1b lane missed, honoured here. discarded the uncommitted stub edit as obsolete and missed that its **deletion** half was still owed. Removed; exactly one §5a and one §5b remain. - - **Collision, ruled by revision 14 and NOT yet fixed in code:** + - **Collision, ruled by revision 14 and NOW FIXED IN CODE** (see the + checkpoint above; this bullet records what it was): §5b and this lane gave `dispatch_semantic_panel_pointer`'s `bool` different meanings — accepted-as-a-gesture versus consumed-here. A mode-line press therefore **arms the latch** on this branch today. diff --git a/src/daemon.rs b/src/daemon.rs index a0aef23..425c0d7 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1060,16 +1060,20 @@ fn replay_panel_pointer( // A chrome press begins nothing. return; } - let reached_child = editor.apply_panel_pointer(source, &disposition, coord, kind, mods); + // ARMED FROM THE EFFECT RESULT, and only if there was one. + // `None` means the target refused the press --- a terminal + // view that is gone, for instance --- and arming over that + // would record a gesture no tail can deliver. + let Some(domain) = editor.apply_panel_pointer(source, &disposition, coord, kind, mods) + else { + return; + }; if let Some(state) = semantic_states.get_mut(&source) { - // Armed FROM THE EFFECT RESULT: `reached_child` is - // measured where the report branch is taken, not - // predicted from the modes beforehand. state.arm_accepted_gesture(crate::semantic_render::AcceptedPanelGesture { button: MouseButton::Left, coord, buffer_id, - reached_child, + domain, }); } } @@ -1080,7 +1084,18 @@ fn replay_panel_pointer( // pointer is not over content. return; } - let _ = editor.apply_panel_pointer(source, &disposition, coord, kind, mods); + // THE TAIL FOLLOWS THE RECORD, not a fresh classification. + // The disposition above decided only whether this cell is + // ours and in content; WHERE the drag goes is the domain the + // press resolved (G5k). + let Some(domain) = semantic_states + .get(&source) + .and_then(crate::semantic_render::SemanticRenderState::accepted_gesture) + .map(|record| record.domain) + else { + return; + }; + editor.replay_panel_gesture_in_domain(source, domain, coord, kind, mods); if let Some(state) = semantic_states.get_mut(&source) { state.note_gesture_content_cell(coord); } @@ -1094,11 +1109,25 @@ fn replay_panel_pointer( } match outcome { Outcome::Accepted => { - let _ = editor.apply_panel_pointer(source, &disposition, coord, kind, mods); - if let Some(state) = semantic_states.get_mut(&source) { + // In content, so the ordinary completion runs --- but + // still in the RECORDED domain, or a mid-gesture mode + // flip would route this release away from the target + // that received the press (G5k). + let record = semantic_states.get_mut(&source).and_then( + crate::semantic_render::SemanticRenderState::consume_accepted_gesture, + ); + if let Some(record) = record { // Taken WITHOUT counting a cancellation, and - // without also running the recorded completion. - let _ = state.consume_accepted_gesture(); + // exactly one completion: this is the ordinary + // one, so `complete_panel_gesture` must not also + // run (P5). + editor.replay_panel_gesture_in_domain( + source, + record.domain, + coord, + kind, + mods, + ); } } Outcome::Consumed => { @@ -7376,7 +7405,7 @@ mod tests { button: pmacs_protocol::MouseButton::Left, coord: pmacs_protocol::CellCoord::new(0, 0), buffer_id: buffer_a, - reached_child: false, + domain: crate::editor::PanelGestureDomain::Document { window: panel_a }, }, ); @@ -7813,6 +7842,531 @@ mod tests { ); } + /// A panel session whose side window holds a live TERMINAL, with + /// the send tap armed. + /// + /// Legacy, deliberately: reading the live mapping generation + /// ADVANCES the key, and §5b wired a key advance to cancel the live + /// gesture, so a mapped fixture destroys the gesture these rows are + /// about. + type TerminalPanelFixture = ( + crate::editor::EditorState, + HashMap, + HashMap, + crate::window::WindowId, + crate::buffer::BufferId, + (u64, u64), + ); + + fn terminal_panel_session(fid: FrontendId, reporting: bool) -> TerminalPanelFixture { + use crate::terminal::TerminalSpec; + + let (editor, mut states, render, _document, panel, _epochs) = + panel_session_at(LEGACY_PANEL_VERSION, fid); + let mut spec = TerminalSpec::new("/bin/sh"); + spec.args = vec!["-c".into(), "sleep 30".into()]; + spec.rows = 4; + spec.cols = 20; + let terminal_buffer = editor + .terminal_manager + .borrow_mut() + .open( + spec, + &mut editor.core.borrow_mut(), + &mut editor.process_supervisor.borrow_mut(), + ) + .expect("open panel terminal"); + { + let mut core = editor.core.borrow_mut(); + let view = { + let registry = core.registry.clone(); + let registry = registry.borrow(); + crate::text_view::TextView::new( + registry.get(terminal_buffer).expect("terminal buffer"), + ) + }; + let window = core.windows.get_mut(&panel).expect("panel window"); + window.buffer_id = terminal_buffer; + window.text_view = view; + } + editor + .terminal_manager + .borrow_mut() + .set_mouse_reporting_for_test(terminal_buffer, reporting); + editor.terminal_manager.borrow().start_send_tap_for_test(); + let epochs = shipped_declaration(&editor, fid, &mut states); + (editor, states, render, panel, terminal_buffer, epochs) + } + + /// Everything the child received, in order. + fn child_stream(editor: &crate::editor::EditorState) -> Vec> { + editor + .terminal_manager + .borrow() + .take_send_tap_for_test() + .into_iter() + .map(|(_, bytes)| bytes) + .collect() + } + + /// Send one legacy panel gesture. + #[expect( + clippy::too_many_arguments, + reason = "one call shape for every G5k leg" + )] + fn send_panel( + editor: &mut crate::editor::EditorState, + states: &mut HashMap, + render: &mut HashMap, + fid: FrontendId, + epochs: (u64, u64), + buffer_id: crate::buffer::BufferId, + coord: pmacs_protocol::CellCoord, + kind: pmacs_protocol::MouseKind, + mods: pmacs_protocol::Modifiers, + ) { + let mut event = arm_pointer(PanelArm::Legacy, fid, epochs, buffer_id, 0, coord, kind); + if let FrontendEvent::PanelPointer { mods: slot, .. } = &mut event { + *slot = mods; + } + dispatch_panel_event(editor, fid, LEGACY_PANEL_VERSION, states, render, event); + } + + // ----------------------------------------------------------------- + // G5k — the terminal gesture-domain matrix. + // + // Four legs. In each, the press resolves a domain and then the + // condition that chose it REVERSES before the tail. The gesture must + // finish in the domain it began in; re-reading Shift, the scroll + // position or the child's modes per event is the framing's named + // mutation, and it either strands a child press or sends the child + // an `Up` for a `Down` it never saw. + // ----------------------------------------------------------------- + + /// G5k(a) — child press, then the child turns REPORTING OFF: the + /// release still reaches the child, and no local selection forms. + #[test] + fn g5k_a_reporting_off_mid_gesture_still_releases_to_the_child() { + let fid = FrontendId(794); + let (mut editor, mut states, mut render, panel, buffer_id, epochs) = + terminal_panel_session(fid, true); + let cell = pmacs_protocol::CellCoord::new(1, 2); + let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); + let release = pmacs_protocol::MouseKind::Up(pmacs_protocol::MouseButton::Left); + let none = pmacs_protocol::Modifiers::default(); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + press, + none, + ); + let after_press = child_stream(&editor); + assert_eq!(after_press.len(), 1, "fixture: the press reached the child"); + assert!( + states[&fid] + .accepted_gesture() + .is_some_and(crate::semantic_render::AcceptedPanelGesture::reached_child), + "fixture: the record says the child owns this gesture" + ); + + // The child stops reporting MID-GESTURE. + editor + .terminal_manager + .borrow_mut() + .set_mouse_reporting_for_test(buffer_id, false); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + release, + none, + ); + + assert_eq!( + child_stream(&editor).len(), + 1, + "the release must still reach the child: it holds a button \ + down that only this release can lift, and re-reading the \ + modes here is what strands it" + ); + let key = crate::terminal::view::TerminalViewKey::new(fid, panel, buffer_id); + assert!( + !editor + .terminal_manager + .borrow() + .view_is_dragging_for_test(key), + "and no local selection was started behind the child's back" + ); + } + + /// G5k(b) — child press, then SHIFT is held before the release: the + /// release still reaches the child. + #[test] + fn g5k_b_shift_before_the_release_still_releases_to_the_child() { + let fid = FrontendId(795); + let (mut editor, mut states, mut render, panel, buffer_id, epochs) = + terminal_panel_session(fid, true); + let cell = pmacs_protocol::CellCoord::new(1, 2); + let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); + let release = pmacs_protocol::MouseKind::Up(pmacs_protocol::MouseButton::Left); + let none = pmacs_protocol::Modifiers::default(); + let shift = pmacs_protocol::Modifiers::SHIFT; + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + press, + none, + ); + assert_eq!( + child_stream(&editor).len(), + 1, + "fixture: press reached the child" + ); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + release, + shift, + ); + + assert_eq!( + child_stream(&editor).len(), + 1, + "Shift is the LOCAL-HANDLING override for a NEW gesture, not \ + a way to abandon one already delivered to the child" + ); + let key = crate::terminal::view::TerminalViewKey::new(fid, panel, buffer_id); + assert!( + !editor + .terminal_manager + .borrow() + .view_is_dragging_for_test(key), + "and no local selection was started" + ); + } + + /// G5k(c) — SHIFT press starts a LOCAL gesture; releasing without + /// Shift finishes locally and sends the child nothing. + #[test] + fn g5k_c_a_shift_started_gesture_finishes_locally() { + let fid = FrontendId(796); + let (mut editor, mut states, mut render, panel, buffer_id, epochs) = + terminal_panel_session(fid, true); + let cell = pmacs_protocol::CellCoord::new(1, 2); + let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); + let release = pmacs_protocol::MouseKind::Up(pmacs_protocol::MouseButton::Left); + let none = pmacs_protocol::Modifiers::default(); + let shift = pmacs_protocol::Modifiers::SHIFT; + let key = crate::terminal::view::TerminalViewKey::new(fid, panel, buffer_id); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + press, + shift, + ); + assert!( + child_stream(&editor).is_empty(), + "fixture: a Shift press is handled locally" + ); + assert!( + editor + .terminal_manager + .borrow() + .view_is_dragging_for_test(key), + "fixture: it began a local drag" + ); + + // Shift released before the button. + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + release, + none, + ); + + assert!( + child_stream(&editor).is_empty(), + "the child must receive NOTHING: it never saw the press, so \ + an Up here is a release for a Down that never happened" + ); + assert!( + !editor + .terminal_manager + .borrow() + .view_is_dragging_for_test(key), + "and the local drag finishes" + ); + } + + /// G5k(d) — a press taken locally because reporting was OFF stays + /// local when the child turns reporting ON mid-gesture. + #[test] + fn g5k_d_reporting_on_mid_gesture_does_not_capture_a_local_gesture() { + let fid = FrontendId(797); + let (mut editor, mut states, mut render, panel, buffer_id, epochs) = + terminal_panel_session(fid, false); + let cell = pmacs_protocol::CellCoord::new(1, 2); + let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); + let release = pmacs_protocol::MouseKind::Up(pmacs_protocol::MouseButton::Left); + let none = pmacs_protocol::Modifiers::default(); + let key = crate::terminal::view::TerminalViewKey::new(fid, panel, buffer_id); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + press, + none, + ); + assert!( + child_stream(&editor).is_empty(), + "fixture: reporting is off, so the press is local" + ); + assert!( + editor + .terminal_manager + .borrow() + .view_is_dragging_for_test(key), + "fixture: it began a local drag" + ); + + // The child turns reporting ON mid-gesture. + editor + .terminal_manager + .borrow_mut() + .set_mouse_reporting_for_test(buffer_id, true); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + release, + none, + ); + + assert!( + child_stream(&editor).is_empty(), + "the child must receive NOTHING --- it never saw this press" + ); + assert!( + !editor + .terminal_manager + .borrow() + .view_is_dragging_for_test(key), + "and the local gesture still finishes locally" + ); + } + + /// P3, reporting leg — a chrome release still delivers the child's + /// release, in the RECORDED encoding. + #[test] + fn r4_p3_child_a_chrome_release_still_reaches_the_reporting_child() { + let fid = FrontendId(798); + let (mut editor, mut states, mut render, _panel, buffer_id, epochs) = + terminal_panel_session(fid, true); + let cell = pmacs_protocol::CellCoord::new(1, 2); + let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); + let release = pmacs_protocol::MouseKind::Up(pmacs_protocol::MouseButton::Left); + let none = pmacs_protocol::Modifiers::default(); + let chrome = { + let core = editor.core.borrow(); + let grid = core.panel_grid_size(fid).expect("grid"); + pmacs_protocol::CellCoord::new(grid.rows.saturating_sub(1), 0) + }; + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + press, + none, + ); + assert_eq!( + child_stream(&editor).len(), + 1, + "fixture: press reached the child" + ); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + chrome, + release, + none, + ); + + let sent = child_stream(&editor); + assert_eq!( + sent.len(), + 1, + "the chrome release must still terminate the child's gesture \ + --- the terminal path returns before replay, so without the \ + recorded completion the child holds the button forever" + ); + assert!( + !states[&fid].has_accepted_gesture(), + "and the record is taken" + ); + } + + /// P5 — an accepted in-content release delivers EXACTLY ONE child + /// release, never the ordinary one plus a record-driven one. + #[test] + fn r4_p5_an_accepted_release_reaches_the_child_exactly_once() { + let fid = FrontendId(799); + let (mut editor, mut states, mut render, _panel, buffer_id, epochs) = + terminal_panel_session(fid, true); + let cell = pmacs_protocol::CellCoord::new(1, 2); + let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); + let release = pmacs_protocol::MouseKind::Up(pmacs_protocol::MouseButton::Left); + let none = pmacs_protocol::Modifiers::default(); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + press, + none, + ); + assert_eq!( + child_stream(&editor).len(), + 1, + "fixture: press reached the child" + ); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + release, + none, + ); + + assert_eq!( + child_stream(&editor).len(), + 1, + "EXACTLY ONE release: the in-content path already completed \ + the gesture, so running the recorded completion as well \ + sends the child two Ups for one Down" + ); + } + + /// P4 — a REFUSED release performs no completion and retains the + /// record, so a later authoritative cancellation can still end it. + #[test] + fn r4_p4_a_refused_release_neither_completes_nor_takes_the_record() { + let fid = FrontendId(800); + let (mut editor, mut states, mut render, _panel, buffer_id, epochs) = + terminal_panel_session(fid, true); + let cell = pmacs_protocol::CellCoord::new(1, 2); + let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); + let release = pmacs_protocol::MouseKind::Up(pmacs_protocol::MouseButton::Left); + let none = pmacs_protocol::Modifiers::default(); + let outside = { + let core = editor.core.borrow(); + let grid = core.panel_grid_size(fid).expect("grid"); + pmacs_protocol::CellCoord::new(grid.rows, 0) + }; + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + press, + none, + ); + assert_eq!( + child_stream(&editor).len(), + 1, + "fixture: press reached the child" + ); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + outside, + release, + none, + ); + + assert!( + child_stream(&editor).is_empty(), + "a refused release must deliver NO completion --- the daemon \ + cannot tell it concerns this gesture at all" + ); + assert!( + states[&fid].has_accepted_gesture(), + "and it must RETAIN the record, so an authoritative \ + cancellation can still end the gesture properly" + ); + } + /// The panel's buffer, and a coordinate one row past its grid. fn panel_buffer_and_outside_coord( editor: &crate::editor::EditorState, diff --git a/src/editor.rs b/src/editor.rs index deed2f8..b9e46e6 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -432,6 +432,82 @@ pub enum PanelPointerOutcome { Accepted, } +/// The domain a live panel gesture was RESOLVED INTO at its accepted +/// press, and which every tail and its completion must follow +/// (parent 48 G5k). +/// +/// **Re-deciding this per event is G5k's forbidden mutation.** The +/// terminal adapter picks between reporting to the child and handling +/// locally by reading Shift, the scrollback position and the child's +/// current mouse modes — all three of which can change mid-gesture. +/// A press reported to the child followed by a release re-evaluated +/// after the child turned reporting off leaves that child holding a +/// button down forever; the reverse transition sends a child an `Up` +/// for a `Down` it never saw. +/// +/// So the press records the contract and the tails obey it. The +/// encoding travels with it for the same reason: the report must be +/// framed the way the press was framed, not the way the child would +/// ask for it now. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PanelGestureDomain { + /// A document panel; every write is addressed to `window`. + Document { + /// The side window the gesture belongs to. + window: WindowId, + }, + /// A terminal panel whose press REACHED THE CHILD. + TerminalChild { + /// The side window the gesture belongs to. + window: WindowId, + /// The terminal buffer the child is attached to. + buffer_id: crate::buffer::BufferId, + /// The modes the press was encoded under, replayed verbatim. + modes: crate::terminal::screen::TerminalModes, + }, + /// A terminal panel handled LOCALLY — Shift, reporting off, or a + /// scrolled-back view at press time. + TerminalLocal { + /// The side window the gesture belongs to. + window: WindowId, + /// The terminal buffer whose local selection is being built. + buffer_id: crate::buffer::BufferId, + }, +} + +impl PanelGestureDomain { + /// The side window this gesture is addressed to. + #[must_use] + pub fn window(&self) -> WindowId { + match self { + Self::Document { window } + | Self::TerminalChild { window, .. } + | Self::TerminalLocal { window, .. } => *window, + } + } + + /// Whether the press reached the child, so a release is OWED to it. + #[must_use] + pub fn reached_child(&self) -> bool { + matches!(self, Self::TerminalChild { .. }) + } +} + +/// Which way the shared terminal adapter routed one gesture. +/// +/// Returned rather than recomputed so a panel press can RECORD the +/// route it actually took (G5k). Other callers of the adapter ignore +/// it — they have no gesture latch to bind. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TerminalGestureRoute { + /// Nothing ran: the view is gone or has no status at that size. + None, + /// Reported to the child, under these modes. + Child(crate::terminal::screen::TerminalModes), + /// Handled locally — scrollback, local selection, or the menu. + Local, +} + /// A classified panel gesture: the outcome, and the resolution it was /// decided from. /// @@ -2952,10 +3028,11 @@ impl EditorState { /// Bare hover neither focuses nor claims, on either kind. /// /// **Only `Accepted` reaches a target.** A `Consumed` disposition - /// returns without effect: the claim was the effect. Returns whether - /// the gesture reached a CHILD, which is what the accepted-gesture - /// latch records — the framing requires arming "from the effect - /// result", so this is measured rather than assumed. + /// returns without effect: the claim was the effect. + /// + /// Returns the [`PanelGestureDomain`] the effect RESOLVED INTO, so + /// an accepted press can record the contract its tails must follow + /// (G5k). `None` means nothing ran and nothing may be recorded. pub fn apply_panel_pointer( &mut self, frontend_id: FrontendId, @@ -2963,15 +3040,13 @@ impl EditorState { coord: CellCoord, kind: pmacs_protocol::MouseKind, mods: pmacs_protocol::Modifiers, - ) -> bool { + ) -> Option { use pmacs_protocol::MouseKind as PKind; if disposition.outcome != PanelPointerOutcome::Accepted { - return false; + return None; } - let Some(target) = disposition.resolved.as_ref() else { - return false; - }; + let target = disposition.resolved.as_ref()?; let activates = if target.is_terminal { !matches!(kind, PKind::Move) @@ -2991,18 +3066,95 @@ impl EditorState { // cell and put every clamp one row out. let viewport = CellSize::new(target.content_rows, target.cols); let key = TerminalViewKey::new(frontend_id, target.side, target.buffer_id); - return self.apply_terminal_gesture( + return match self.apply_terminal_gesture( key, viewport, coord, kind, mods, (coord.row, coord.col), - ); + ) { + TerminalGestureRoute::None => None, + TerminalGestureRoute::Child(modes) => Some(PanelGestureDomain::TerminalChild { + window: target.side, + buffer_id: target.buffer_id, + modes, + }), + TerminalGestureRoute::Local => Some(PanelGestureDomain::TerminalLocal { + window: target.side, + buffer_id: target.buffer_id, + }), + }; } self.replay_panel_document_gesture(frontend_id, target.side, coord, kind, mods); - false + Some(PanelGestureDomain::Document { + window: target.side, + }) + } + + /// Replay a tail or a completion in the gesture's RECORDED domain + /// (parent 48 G5k). + /// + /// **Nothing here re-decides the domain.** The press resolved it and + /// the record carries it, so Shift going down mid-drag, the view + /// scrolling back, or the child flipping its mouse modes cannot move + /// a live gesture from one target to another. Re-evaluating those + /// per event is G5k's forbidden mutation, and it strands a child + /// press in one direction and fabricates an unmatched child release + /// in the other. + /// + /// **The window is read from the record too.** An earlier version + /// re-derived the current side window and returned when it had + /// changed — which is exactly the set of transitions the stranding + /// rows must terminate, so the completion they need would have been + /// the one thing that refused to run. + pub fn replay_panel_gesture_in_domain( + &mut self, + frontend_id: FrontendId, + domain: PanelGestureDomain, + coord: CellCoord, + kind: pmacs_protocol::MouseKind, + mods: pmacs_protocol::Modifiers, + ) { + match domain { + PanelGestureDomain::Document { window } => { + self.replay_panel_document_gesture(frontend_id, window, coord, kind, mods); + } + PanelGestureDomain::TerminalChild { + window, + buffer_id, + modes, + } => { + // Encoded under the RECORDED modes, and gated on + // nothing: not Shift, not the scroll position, not the + // child's current modes. Those are precisely the three + // inputs G5k forbids re-reading. + let key = TerminalViewKey::new(frontend_id, window, buffer_id); + let _ = key; + if let Some(bytes) = crate::terminal::input::encode_mouse(kind, coord, mods, modes) + { + self.send_terminal_bytes(buffer_id, &bytes); + } + } + PanelGestureDomain::TerminalLocal { window, buffer_id } => { + let Some(size) = self.core.borrow().panel_grid_size(frontend_id) else { + return; + }; + let viewport = CellSize::new(size.rows.saturating_sub(1), size.cols); + let key = TerminalViewKey::new(frontend_id, window, buffer_id); + let mut manager = self.terminal_manager.borrow_mut(); + match kind { + pmacs_protocol::MouseKind::Drag(pmacs_protocol::MouseButton::Left) => { + let _ = manager.update_selection(key, viewport, coord); + } + pmacs_protocol::MouseKind::Up(pmacs_protocol::MouseButton::Left) => { + let _ = manager.finish_selection(key, viewport, coord); + } + _ => {} + } + } + } } /// Deliver the RECORDED completion for a gesture the ordinary path @@ -3014,69 +3166,23 @@ impl EditorState { /// already performed the ordinary in-content completion, and doing /// both is the duplicate P5 forbids. /// - /// **The domain is read from the record, not from where the pointer - /// is now.** `buffer_id` says which panel the gesture belongs to and - /// `reached_child` says whether a press was ever delivered, so the - /// three completions separate without a fourth field: - /// - /// * reporting terminal → the child gets its release; - /// * local terminal → the drag's selection is finalised; - /// * document → the gesture completes and an empty selection is - /// cleared without moving point. - /// - /// Delivery goes through the SAME paths an in-content release uses, - /// at the record's last valid content cell (R-c2). A second - /// implementation of "what a release does" would drift from the - /// first, which is the whole reason the shared terminal path exists. + /// Delivery goes through the gesture's recorded domain at its last + /// valid content cell (R-c2), so the release is framed the way its + /// press was. pub fn complete_panel_gesture( &mut self, frontend_id: FrontendId, record: &crate::semantic_render::AcceptedPanelGesture, mods: pmacs_protocol::Modifiers, ) { - use pmacs_protocol::{MouseButton as PButton, MouseKind as PKind}; - - let release = PKind::Up(PButton::Left); - let Some(size) = self.core.borrow().panel_grid_size(frontend_id) else { - return; - }; - let content_rows = size.rows.saturating_sub(1); - if content_rows == 0 { - return; - } - let side = { - let core = self.core.borrow(); - let Some(side) = core.side_window_for(frontend_id) else { - return; - }; - // The panel must still be showing the buffer the gesture - // belongs to. If it is not, the four stranding transitions - // own this record, not an ordinary completion. - if core.windows.get(&side).map(|window| window.buffer_id) != Some(record.buffer_id) { - return; - } - side - }; - - if self.terminal_manager.borrow().is_terminal(record.buffer_id) { - // Both terminal domains route here: `apply_terminal_gesture` - // reports to the child when the modes allow and finalises the - // local selection otherwise, which is exactly the split this - // completion needs. - let viewport = CellSize::new(content_rows, size.cols); - let key = TerminalViewKey::new(frontend_id, side, record.buffer_id); - let _ = self.apply_terminal_gesture( - key, - viewport, - record.coord, - release, - mods, - (record.coord.row, record.coord.col), - ); - return; - } - - self.replay_panel_document_gesture(frontend_id, side, record.coord, release, mods); + let release = pmacs_protocol::MouseKind::Up(pmacs_protocol::MouseButton::Left); + self.replay_panel_gesture_in_domain( + frontend_id, + record.domain, + record.coord, + release, + mods, + ); } /// Replay one accepted gesture into a DOCUMENT panel (parent 48). @@ -4047,13 +4153,12 @@ impl EditorState { /// A second copy of this precedence in the GPU lane is exactly how /// Shift-drag or scrolled-back selection would silently diverge /// between frontends. - /// Returns whether the gesture REACHED THE CHILD as a mouse report. + /// Returns WHICH WAY it routed the gesture (G5k). /// - /// Parent 48 Q#BP-R4 arms the accepted-gesture latch "from the - /// effect result", so `reached_child` has to be measured where the - /// branch is taken rather than predicted from the modes beforehand: - /// the report is gated on five conditions and `encode_mouse` can - /// still decline. + /// The route has to be measured where the branch is taken, not + /// predicted from the modes beforehand: the report is gated on five + /// conditions and `encode_mouse` can still decline. A panel press + /// records the answer and binds its whole gesture to it. fn apply_terminal_gesture( &mut self, key: TerminalViewKey, @@ -4062,12 +4167,12 @@ impl EditorState { kind: TerminalMouseKind, modifiers: TerminalModifiers, global: (u32, u32), - ) -> bool { + ) -> TerminalGestureRoute { let shift = modifiers.contains(TerminalModifiers::SHIFT); let (at_bottom, modes, screen_size) = { let mut manager = self.terminal_manager.borrow_mut(); let Some(status) = manager.view_status_for_size(key, viewport_size) else { - return false; + return TerminalGestureRoute::None; }; let modes = manager.modes_for_view(key).unwrap_or_default(); let screen_size = manager.screen_size_for_view(key).unwrap_or(viewport_size); @@ -4097,7 +4202,7 @@ impl EditorState { self.claim_terminal_controller(key); } self.send_terminal_bytes(key.buffer_id, &bytes); - return true; + return TerminalGestureRoute::Child(modes); } if claims_control { @@ -4130,7 +4235,7 @@ impl EditorState { } // The local branch: scrollback, local selection or the menu. The // child heard nothing. - false + TerminalGestureRoute::Local } fn is_double_click( diff --git a/src/semantic_render.rs b/src/semantic_render.rs index 408940e..4758b9c 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -174,11 +174,24 @@ pub struct AcceptedPanelGesture { pub coord: pmacs_protocol::CellCoord, /// The panel buffer the gesture belongs to. pub buffer_id: BufferId, - /// Whether the press actually REACHED the child, for a reporting - /// terminal. A release is owed only if a press was delivered; - /// synthesising one for a press the child never saw is the same - /// defect in the other direction. - pub reached_child: bool, + /// The domain the accepted press RESOLVED INTO, and which every + /// tail and the completion must follow (G5k). + /// + /// This replaces a bare `reached_child` flag. The flag said whether + /// a release was owed to a child but not how to frame it, nor which + /// window to address, so a tail had to re-derive both from state + /// that moves mid-gesture — Shift, the scroll position, the child's + /// modes, and the panel's own identity. Recording the resolution + /// is what makes the tail independent of all four. + pub domain: crate::editor::PanelGestureDomain, +} + +impl AcceptedPanelGesture { + /// Whether the press reached the child, so a release is OWED to it. + #[must_use] + pub fn reached_child(&self) -> bool { + self.domain.reached_child() + } } /// Owns one `semantic_render` session's projection state: the last diff --git a/src/terminal/screen.rs b/src/terminal/screen.rs index 516c5d7..abcc11c 100644 --- a/src/terminal/screen.rs +++ b/src/terminal/screen.rs @@ -619,6 +619,22 @@ impl TerminalScreen { pub fn modes(&self) -> TerminalModes { self.modes } + + /// Turn SGR mouse reporting on or off, for parent 48 G5k. + /// + /// The domain matrix needs a child that reports and then STOPS + /// reporting mid-gesture. Driving that through the ANSI parser + /// would make the row depend on escape-sequence handling it is not + /// testing; this sets the two modes the adapter actually reads. + #[doc(hidden)] + pub fn set_mouse_reporting_for_test(&mut self, enabled: bool) { + self.modes.mouse_sgr = enabled; + self.modes.mouse_tracking = if enabled { + MouseTrackingMode::Button + } else { + MouseTrackingMode::Off + }; + } /// Return whether the published active screen is alternate. #[must_use] pub fn alternate_active(&self) -> bool { diff --git a/src/terminal/session.rs b/src/terminal/session.rs index 47fae56..2eddab0 100644 --- a/src/terminal/session.rs +++ b/src/terminal/session.rs @@ -247,10 +247,21 @@ pub(super) struct EscapeCache { pub(super) reported_invalid: Option, } +/// What each child was sent, in order, while the G5k tap is armed. +type ChildSendLog = Vec<(BufferId, Vec)>; + /// Owns the one-buffer/one-process/one-screen terminal registry. #[derive(Default)] pub struct TerminalManager { pub(super) sessions: HashMap, + /// An OPT-IN tap on child input, for parent 48 G5k's witnesses. + /// + /// The gesture-domain rows have to read what the child actually + /// received --- a release delivered in the recorded encoding, and + /// exactly one of it --- and no other seam exposes that. Off by + /// default, so production pays one `is_some` check per send and + /// never accumulates. + send_tap: RefCell>, /// Total escape-key parses performed (Q#TC4c observability). escape_parses: u64, process_to_buffer: HashMap, @@ -564,11 +575,35 @@ impl TerminalManager { .sessions .get(&buffer_id) .ok_or(TerminalError::NotTerminal(buffer_id))?; + if let Some(tap) = self.send_tap.borrow_mut().as_mut() { + tap.push((buffer_id, bytes.to_vec())); + } supervisor .write_stdin(session.process_id, bytes) .map_err(TerminalError::Process) } + /// Begin recording child input for G5k's witnesses. + #[doc(hidden)] + pub fn start_send_tap_for_test(&self) { + *self.send_tap.borrow_mut() = Some(Vec::new()); + } + + /// Take everything sent to children since the tap was started. + /// + /// Returns the sends in ORDER, because "one release, not two" and + /// "the old gesture's release before the new gesture's press" are + /// both ordering claims that a set cannot express. + #[doc(hidden)] + #[must_use] + pub fn take_send_tap_for_test(&self) -> ChildSendLog { + self.send_tap + .borrow_mut() + .as_mut() + .map(std::mem::take) + .unwrap_or_default() + } + /// Resolve this terminal's effective escape chord, parsing at most /// once per `(terminal, config epoch)` (Q#TC4c). /// diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 44aac29..8e47474 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -525,6 +525,14 @@ impl TerminalManager { state.selection.take().is_some() || state.drag.take().is_some() } + /// Turn SGR mouse reporting on or off for one session (G5k). + #[doc(hidden)] + pub fn set_mouse_reporting_for_test(&mut self, buffer_id: BufferId, enabled: bool) { + if let Some(session) = self.sessions.get_mut(&buffer_id) { + session.screen.set_mouse_reporting_for_test(enabled); + } + } + /// Current child input modes for one session. #[must_use] pub fn modes_for_view(&self, key: TerminalViewKey) -> Option { From 2ea39aaa572fff2fd9592b9550c1b5807956b23b Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 20 Aug 2026 22:22:09 +0200 Subject: [PATCH 20/33] fix(panel): the record is self-contained --- viewport, anchoring, exact bytes Answers review of 48057d7. The G5k routing fix held; the record still leaned on ambient state in three places. TerminalLocal now records the accepted content VIEWPORT. Replay fetched the current panel_grid_size and returned when it was None --- which is exactly what a hidden or absent panel produces, so a cancellation could not finish the drag it was cancelling, and a size-changing cancellation would have finished against the successor's geometry. A press that anchors NOTHING no longer arms. The document path returned Some(Document) unconditionally even when panel_cell_byte found no byte, and the terminal path returned Local while discarding begin_selection's answer. Both now report what actually began. The child rows assert EXACT BYTES rather than a count: a wrong event or encoding passed the old length checks. The literals are written out rather than built with the encoder's own formula, which would only assert that the encoder agrees with itself. G5k(b) pins the ruling that the SGR framing comes from the record while the modifier bits still report live state, so its release carries code 4 rather than 0. New rows: P2's effect half (a refused press reaches no target), P9 (a document press that anchors nothing does not arm), P11 (a recorded local completion still runs with the panel HIDDEN, which is what the recorded viewport is for). THREE ROWS IN THIS ROUND WERE VACUOUS BEFORE THE MUTATIONS CAUGHT THEM, and the fixtures now assert their way past each cause. The panel grid in this fixture is FOUR rows, so content is rows 0..=2: my first P9 and P10 cells were out of grid and refused before reaching the path they claimed to test, and P9's earlier cell clamped to byte 0 instead of failing to anchor. Both rows now assert the disposition is Accepted before asserting anything about the effect. P10 IS DELIBERATELY ABSENT AND RECORDED AS UNWITNESSED. The begin_selection gate has no reachable false branch through the daemon: classify has already established the buffer is the side window's live terminal, and anchor_at resolves every in-grid cell of a live view --- measured on the fixture, not assumed. The gate is kept as insurance and the gap is written where the row would have been, rather than covered by something that would pass whether or not the gate existed. P2's effect half is likewise not falsifiable by any mutation I could construct, because a Refused disposition carries no resolution, so no path can apply it. That is a stronger guarantee than a test, and it is stated rather than dressed up as coverage. Also corrects the last false ledger tense. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 3 +- src/daemon.rs | 265 ++++++++++++++++++++++++++++++++++++++------ src/editor.rs | 88 ++++++++++++--- 3 files changed, 310 insertions(+), 46 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 4935211..3cc2b2a 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -336,7 +336,8 @@ from #171 and #215 — the correction the 1b lane missed, honoured here. checkpoint above; this bullet records what it was): §5b and this lane gave `dispatch_semantic_panel_pointer`'s `bool` different meanings — accepted-as-a-gesture versus consumed-here. A - mode-line press therefore **arms the latch** on this branch today. + mode-line press therefore **armed the latch** --- past tense: the + fix landed with Q#BP-R4, and P1 pins it. **Q#BP-R4** rules a three-state `PanelPointerOutcome`, classified **before** target effects. Only an `Accepted` `Down(Left)` arms; left `Drag`/`Up` require a live record; an accepted `Up` performs diff --git a/src/daemon.rs b/src/daemon.rs index 425c0d7..18e7994 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -7842,6 +7842,16 @@ mod tests { ); } + /// The exact SGR bytes a left press at cell (1, 2) produces: + /// `ESC [ < 0 ; col+1 ; row+1 M`. Written as a LITERAL rather than + /// built with the encoder's own formula, which would assert only + /// that the encoder agrees with itself. + const SGR_PRESS_1_2: &[u8] = b"\x1b[<0;3;2M"; + /// The matching release, `m` rather than `M`. + const SGR_RELEASE_1_2: &[u8] = b"\x1b[<0;3;2m"; + /// The same release with SHIFT held: the button code gains 4. + const SGR_RELEASE_1_2_SHIFT: &[u8] = b"\x1b[<4;3;2m"; + /// A panel session whose side window holds a live TERMINAL, with /// the send tap armed. /// @@ -7966,8 +7976,11 @@ mod tests { press, none, ); - let after_press = child_stream(&editor); - assert_eq!(after_press.len(), 1, "fixture: the press reached the child"); + assert_eq!( + child_stream(&editor), + vec![SGR_PRESS_1_2.to_vec()], + "fixture: the press reached the child, in SGR" + ); assert!( states[&fid] .accepted_gesture() @@ -7994,11 +8007,11 @@ mod tests { ); assert_eq!( - child_stream(&editor).len(), - 1, - "the release must still reach the child: it holds a button \ - down that only this release can lift, and re-reading the \ - modes here is what strands it" + child_stream(&editor), + vec![SGR_RELEASE_1_2.to_vec()], + "the release must still reach the child AS A RELEASE at the \ + gesture's cell: it holds a button down that only this can \ + lift, and re-reading the modes here is what strands it" ); let key = crate::terminal::view::TerminalViewKey::new(fid, panel, buffer_id); assert!( @@ -8035,9 +8048,9 @@ mod tests { none, ); assert_eq!( - child_stream(&editor).len(), - 1, - "fixture: press reached the child" + child_stream(&editor), + vec![SGR_PRESS_1_2.to_vec()], + "fixture: the press reached the child, in SGR" ); send_panel( @@ -8053,10 +8066,12 @@ mod tests { ); assert_eq!( - child_stream(&editor).len(), - 1, + child_stream(&editor), + vec![SGR_RELEASE_1_2_SHIFT.to_vec()], "Shift is the LOCAL-HANDLING override for a NEW gesture, not \ - a way to abandon one already delivered to the child" + a way to abandon one already delivered to the child. The SGR \ + framing comes from the record; the modifier bits still report \ + live state, so the code is 4 rather than 0" ); let key = crate::terminal::view::TerminalViewKey::new(fid, panel, buffer_id); assert!( @@ -8228,9 +8243,9 @@ mod tests { none, ); assert_eq!( - child_stream(&editor).len(), - 1, - "fixture: press reached the child" + child_stream(&editor), + vec![SGR_PRESS_1_2.to_vec()], + "fixture: the press reached the child, in SGR" ); send_panel( @@ -8245,12 +8260,12 @@ mod tests { none, ); - let sent = child_stream(&editor); assert_eq!( - sent.len(), - 1, - "the chrome release must still terminate the child's gesture \ - --- the terminal path returns before replay, so without the \ + child_stream(&editor), + vec![SGR_RELEASE_1_2.to_vec()], + "the chrome release must terminate the child's gesture AT THE \ + GESTURE'S OWN CELL, not the chrome cell it landed on (R-c2). \ + The terminal path returns before replay, so without the \ recorded completion the child holds the button forever" ); assert!( @@ -8259,6 +8274,192 @@ mod tests { ); } + /// P9 — a document press that ANCHORS NOTHING does not arm. + /// + /// `panel_cell_byte` is `None` past the end of a short line and on + /// an empty panel, and the press then places no cursor and opens no + /// selection. Arming over it records a gesture whose completion has + /// nothing to complete — and, once cancellations deliver effects, + /// one that fires a release for a press that did nothing. + #[test] + fn r4_p9_a_document_press_that_anchors_nothing_does_not_arm() { + let fid = FrontendId(802); + let (mut editor, mut states, mut render, _document, panel, epochs) = + panel_session_at(LEGACY_PANEL_VERSION, fid); + let buffer_id = editor.core.borrow().windows[&panel].buffer_id; + let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); + let none = pmacs_protocol::Modifiers::default(); + // The panel grid is 4 rows, so content is rows 0..=2 and row 3 + // is the mode line. The `*panel*` buffer is created EMPTY --- one + // zero-length line --- so row 1 is IN CONTENT and still past the + // buffer's only line. + // + // Two cells that do NOT work, and the row asserts its way past + // both: a column past the end of row 0 clamps to the line end + // and yields byte 0, and any row >= 4 is out of grid, so the + // press is refused before the document path is reached at all. + let unanchorable = pmacs_protocol::CellCoord::new(1, 0); + assert!( + editor + .classify_panel_pointer(fid, buffer_id, unanchorable, press) + .outcome() + == crate::editor::PanelPointerOutcome::Accepted, + "fixture: the cell must be ACCEPTED content, or this row \ + passes because the press was refused for an unrelated reason" + ); + assert!( + editor + .panel_cell_byte_for_test(panel, unanchorable) + .is_none(), + "fixture: this cell must genuinely have no byte behind it" + ); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + unanchorable, + press, + none, + ); + + assert!( + !states[&fid].has_accepted_gesture(), + "a press that anchored nothing must not arm" + ); + } + + // P10 IS DELIBERATELY ABSENT, and this note is why. + // + // The press path also gates arming on `begin_selection` actually + // starting a drag, so a local terminal press that begins nothing + // records nothing. That gate has NO WITNESS because it has no + // reachable false branch through the daemon: `begin_selection` + // returns `false` only when the session is missing or the cell maps + // to no retained row, and by the time the daemon reaches the press + // path `classify_panel_pointer` has already established that the + // buffer IS the side window's live terminal, while `anchor_at` + // resolves every in-grid cell of a live view — measured, not + // assumed: a press at every content row of the 4-row fixture + // anchors. + // + // The gate is kept as insurance against a future view that can + // refuse, and it is recorded as UNWITNESSED rather than covered by + // a row that would pass whether or not the gate existed. A row at + // an out-of-grid cell looks like it tests this and does not: such a + // press is `Refused` long before the local path. + + /// P11 — a recorded LOCAL completion still runs with the panel + /// HIDDEN. + /// + /// `panel_grid_size` is `None` for a hidden panel, so a completion + /// that fetched the current geometry could not finish a drag during + /// exactly the cancellations that need finishing. The viewport is + /// recorded at the press for this reason, and the row hides the + /// panel between the press and the completion to prove it. + #[test] + fn r4_p11_a_recorded_local_completion_survives_a_hidden_panel() { + let fid = FrontendId(804); + let (mut editor, mut states, mut render, panel, buffer_id, epochs) = + terminal_panel_session(fid, false); + let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); + let none = pmacs_protocol::Modifiers::default(); + let cell = pmacs_protocol::CellCoord::new(1, 2); + let key = crate::terminal::view::TerminalViewKey::new(fid, panel, buffer_id); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + press, + none, + ); + assert!( + editor + .terminal_manager + .borrow() + .view_is_dragging_for_test(key), + "fixture: a local drag is live" + ); + let record = states[&fid] + .accepted_gesture() + .copied() + .expect("fixture: the gesture is armed"); + + // The panel goes away. `panel_grid_size` is now `None`. + editor.hide_panel_for_test(fid); + assert!( + editor.core.borrow().panel_grid_size(fid).is_none(), + "fixture: the panel is hidden, so ambient geometry is gone" + ); + + editor.complete_panel_gesture(fid, &record, none); + + assert!( + !editor + .terminal_manager + .borrow() + .view_is_dragging_for_test(key), + "the completion must still finish the drag --- it replays \ + against the viewport RECORDED at the press, because the \ + ambient one is exactly what a cancellation removes" + ); + } + + /// P2, effect half — a REFUSED press reaches no target at all. + /// + /// §5b's four `g5_substrate_a_refused_*` rows read the latch and the + /// cancellation count; none of them reads the target. A refusal that + /// armed nothing while still sending the child a press, or starting + /// a local drag, would pass every one of them. + #[test] + fn r4_p2_a_refused_press_reaches_no_target() { + let fid = FrontendId(801); + let (mut editor, mut states, mut render, panel, buffer_id, epochs) = + terminal_panel_session(fid, true); + let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); + let none = pmacs_protocol::Modifiers::default(); + let outside = { + let core = editor.core.borrow(); + let grid = core.panel_grid_size(fid).expect("grid"); + pmacs_protocol::CellCoord::new(grid.rows, 0) + }; + let key = crate::terminal::view::TerminalViewKey::new(fid, panel, buffer_id); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + outside, + press, + none, + ); + + assert!( + child_stream(&editor).is_empty(), + "a refused press must send the child NOTHING --- a press it \ + receives is one it will expect a release for" + ); + assert!( + !editor + .terminal_manager + .borrow() + .view_is_dragging_for_test(key), + "and it must not begin a local drag either" + ); + assert!(!states[&fid].has_accepted_gesture(), "and it must not arm"); + } + /// P5 — an accepted in-content release delivers EXACTLY ONE child /// release, never the ordinary one plus a record-driven one. #[test] @@ -8283,9 +8484,9 @@ mod tests { none, ); assert_eq!( - child_stream(&editor).len(), - 1, - "fixture: press reached the child" + child_stream(&editor), + vec![SGR_PRESS_1_2.to_vec()], + "fixture: the press reached the child, in SGR" ); send_panel( @@ -8301,11 +8502,11 @@ mod tests { ); assert_eq!( - child_stream(&editor).len(), - 1, - "EXACTLY ONE release: the in-content path already completed \ - the gesture, so running the recorded completion as well \ - sends the child two Ups for one Down" + child_stream(&editor), + vec![SGR_RELEASE_1_2.to_vec()], + "EXACTLY ONE release, and its exact bytes: the in-content path \ + already completed the gesture, so running the recorded \ + completion as well sends the child two Ups for one Down" ); } @@ -8338,9 +8539,9 @@ mod tests { none, ); assert_eq!( - child_stream(&editor).len(), - 1, - "fixture: press reached the child" + child_stream(&editor), + vec![SGR_PRESS_1_2.to_vec()], + "fixture: the press reached the child, in SGR" ); send_panel( diff --git a/src/editor.rs b/src/editor.rs index b9e46e6..d6cbed1 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -472,6 +472,15 @@ pub enum PanelGestureDomain { window: WindowId, /// The terminal buffer whose local selection is being built. buffer_id: crate::buffer::BufferId, + /// The CONTENT viewport the press was accepted against. + /// + /// Recorded, not re-fetched. `panel_grid_size` is `None` while + /// the panel is hidden or absent, so a completion that asked + /// for it could not finish a drag during exactly the + /// cancellations that need finishing — and a size-changing + /// cancellation would finish against the SUCCESSOR's geometry, + /// putting the selection somewhere the user never dragged. + viewport: CellSize, }, } @@ -2901,6 +2910,24 @@ impl EditorState { .is_some_and(|now| now != current) } + /// The byte behind a panel cell, for P9. + #[doc(hidden)] + #[must_use] + pub fn panel_cell_byte_for_test(&self, win_id: WindowId, coord: CellCoord) -> Option { + self.panel_cell_byte(win_id, coord) + } + + /// Hide this frontend's panel, for P11. + /// + /// `panel_grid_size` returns `None` once hidden, which is the state + /// a recorded completion has to survive. + #[doc(hidden)] + pub fn hide_panel_for_test(&self, frontend_id: FrontendId) { + if let Some(view) = self.core.borrow_mut().views.get_mut(&frontend_id) { + view.panel_hidden = true; + } + } + /// Classify an authenticated panel gesture, WITHOUT applying it /// (Q#BP-R4). /// @@ -3083,14 +3110,19 @@ impl EditorState { TerminalGestureRoute::Local => Some(PanelGestureDomain::TerminalLocal { window: target.side, buffer_id: target.buffer_id, + viewport, }), }; } - self.replay_panel_document_gesture(frontend_id, target.side, coord, kind, mods); - Some(PanelGestureDomain::Document { - window: target.side, - }) + // A press that placed no anchor began NOTHING, and arming over + // it records a gesture whose completion has nothing to + // complete. `panel_cell_byte` is `None` past the end of a short + // line and on an empty panel, which is not a rare shape. + self.replay_panel_document_gesture(frontend_id, target.side, coord, kind, mods) + .then_some(PanelGestureDomain::Document { + window: target.side, + }) } /// Replay a tail or a completion in the gesture's RECORDED domain @@ -3137,11 +3169,11 @@ impl EditorState { self.send_terminal_bytes(buffer_id, &bytes); } } - PanelGestureDomain::TerminalLocal { window, buffer_id } => { - let Some(size) = self.core.borrow().panel_grid_size(frontend_id) else { - return; - }; - let viewport = CellSize::new(size.rows.saturating_sub(1), size.cols); + PanelGestureDomain::TerminalLocal { + window, + buffer_id, + viewport, + } => { let key = TerminalViewKey::new(frontend_id, window, buffer_id); let mut manager = self.terminal_manager.borrow_mut(); match kind { @@ -3192,6 +3224,9 @@ impl EditorState { /// frontend's input can interleave between a `Down` and its tail, so /// a replay reading `active_window_mut()` would act on whatever /// happened to be active at that moment (R-b). + /// Returns whether a left press ANCHORED a gesture. Every other + /// kind returns `false`: only a press can begin one, so only a + /// press has an answer to give. fn replay_panel_document_gesture( &mut self, frontend_id: FrontendId, @@ -3199,7 +3234,7 @@ impl EditorState { coord: CellCoord, kind: pmacs_protocol::MouseKind, mods: pmacs_protocol::Modifiers, - ) { + ) -> bool { use pmacs_protocol::{MouseButton as PButton, MouseKind as PKind}; match kind { @@ -3210,7 +3245,8 @@ impl EditorState { self.core.borrow_mut().break_command_chain(frontend_id); let is_double = self.is_double_click(frontend_id, side, coord); let Some(byte) = self.panel_cell_byte(side, coord) else { - return; + // No anchor: nothing began, so nothing may arm. + return false; }; let extending = mods.contains(pmacs_protocol::Modifiers::SHIFT); let prev = self.core.borrow().windows[&side].cursor; @@ -3233,7 +3269,9 @@ impl EditorState { // window-targeted writers instead. if self.core.borrow_mut().select_word_at_cursor() { self.mouse_click = None; - return; + // A double-click word selection IS an anchored + // gesture: its release still has to complete. + return true; } } if extending { @@ -3249,6 +3287,11 @@ impl EditorState { cell: coord, at: Instant::now(), }); + // Anchored. Stated here rather than inferred from + // selection state afterwards, which a later change + // could stop setting without anyone noticing arming had + // gone quiet. + return true; } PKind::Drag(PButton::Left) => { self.mouse_click = None; @@ -3289,6 +3332,9 @@ impl EditorState { | PKind::Up(_) | PKind::Drag(_) => {} } + // Only a left press can anchor, and it returns `true` above. + // Every other kind reaching here handled something already live. + false } /// Byte under a panel cell, resolved against the SIDE window's own @@ -4208,6 +4254,7 @@ impl EditorState { if claims_control { self.claim_terminal_controller(key); } + let mut began_local_gesture = false; let mut manager = self.terminal_manager.borrow_mut(); match kind { TerminalMouseKind::ScrollUp => { @@ -4217,7 +4264,12 @@ impl EditorState { let _ = manager.scroll_view(key, viewport_size, -SCROLL_LINES); } TerminalMouseKind::Down(TerminalMouseButton::Left) => { - let _ = manager.begin_selection(key, viewport_size, coord); + // The ONE local kind that begins a gesture, so its + // answer decides whether there is anything to arm. A + // press against a view that cannot start a drag began + // nothing, and arming over it records a gesture whose + // completion has nothing to finish. + began_local_gesture = manager.begin_selection(key, viewport_size, coord); } TerminalMouseKind::Drag(TerminalMouseButton::Left) => { let _ = manager.update_selection(key, viewport_size, coord); @@ -4235,6 +4287,16 @@ impl EditorState { } // The local branch: scrollback, local selection or the menu. The // child heard nothing. + // + // A left press reports `Local` only when it actually began a + // drag; otherwise it began nothing and the caller must not arm. + // Every other kind is not an arming event, so `Local` is the + // honest answer for it either way. + if matches!(kind, TerminalMouseKind::Down(TerminalMouseButton::Left)) + && !began_local_gesture + { + return TerminalGestureRoute::None; + } TerminalGestureRoute::Local } From 9028e1b1727a203b758e89c88a15dcb3cd994d23 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 20 Aug 2026 22:54:57 +0200 Subject: [PATCH 21/33] fix(panel): the projection clamp, a reachable P10, and a disposition that cannot lie Answers review of 2ea39aa. Two of my claims were wrong and the code disproved them. WIDE PANELS HAD DEAD POINTER INPUT. A panel deliberately does not inherit the terminal's per-axis PTY caps (Bet B5'), and the renderer clamps through terminal_projection_size so a band wider than MAX_TERMINAL_COLS paints correctly. Pointer routing passed the RAW panel width, and view_status_for_size refuses anything over the cap --- so on exactly those panels every click inside the visible terminal resolved to None while the band looked perfectly normal. Routing and the recorded viewport now go through the same clamp, with P12 as a POSITIVE control at MAX_TERMINAL_COLS + 128. P10 IS REACHABLE, and my note saying otherwise was wrong. I claimed anchor_at resolves every in-grid cell of a live view, "measured, not assumed". I had measured ROWS and generalised to cells. anchor_at refuses coord.col >= row.cells.len(), and the fixture's band is 80 columns over a 20-column child, so columns 20..79 are painted padding inside accepted content. The row exists now and the begin_selection gate is witnessed rather than excused. THE DISPOSITION IS AN ENUM. As {outcome, Option} the invalid pair --- refused, yet carrying a target --- stayed representable inside editor.rs, so my "the type makes it impossible" was also wrong. Refused now holds no target at all. ResolvedPanelTarget is public as a type and opaque as a value: every field stays private, so the daemon hands the disposition back rather than reading a derivation out of it. P2 also now asserts what it observes: the classification is Refused, and focus and terminal-controller ownership are preserved. A misclassified press focuses the panel and claims the controller BEFORE its out-of-range anchor fails, so byte and latch assertions alone stayed green through exactly that bug. MY MUTATION HARNESS WAS READING SUCCESS FROM SILENCE. `cargo test --lib "r4_p\|g5k_"` is a literal substring filter, not a regex: it matched nothing and ran ZERO tests, and I read the absence of failures as "the mutation did not bite". One whole round proved nothing. The harness now prints the run count and says so loudly when it is zero. Re-run properly, all three fixes bite their named rows. Also updates the ledger's witness list, which omitted P2, P9, P11, the recorded viewport and the exact-byte strengthening. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 24 +++++-- src/daemon.rs | 171 +++++++++++++++++++++++++++++++++++++++----- src/editor.rs | 76 +++++++++++++------- 3 files changed, 224 insertions(+), 47 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 3cc2b2a..f50e8b0 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -295,10 +295,26 @@ from #171 and #215 — the correction the 1b lane missed, honoured here. modes per event — G5k's named mutation. The press now records `PanelGestureDomain` (document / terminal-child-with-encoding / terminal-local) and every tail and completion follows it. - - **Witnesses: G5k(a)–(d), P1, P3 both legs, P4, P5, P7, P8.** Each - reads a TARGET EFFECT — the child's byte stream, the terminal drag - state, or the document selection — never the latch alone. Each - bites its own mutation, including G5k's verbatim. + - **LANDED: the record is SELF-CONTAINED.** `TerminalLocal` carries + the accepted content **viewport** (ambient geometry is `None` + exactly when a hidden panel needs completing); a press that anchors + nothing no longer arms on either target; and pointer routing goes + through the renderer's own `terminal_projection_size` clamp — a + band wider than `MAX_TERMINAL_COLS` painted fine while every click + inside it resolved to nothing. + - **LANDED: `PanelPointerDisposition` is an ENUM.** As + `{outcome, Option}` the invalid pair — refused, yet + carrying a target — stayed representable inside `editor.rs`. Now + `Refused` holds no target at all. + - **Witnesses: G5k(a)–(d), P1, P2, P3 both legs, P4, P5, P7, P8, P9, + P10, P11, P12.** Each reads a TARGET EFFECT — the child's byte + stream (**exact bytes**, not a count), the terminal drag state, the + document selection, or focus and controller ownership — never the + latch alone. Each bites its own mutation, including G5k's verbatim. + - **Every fixture asserts its own precondition** (the disposition is + `Accepted`, or is `Refused`) because four rows in these rounds + passed vacuously: cells that were out of grid, or that clamped to + byte 0, exercised a refusal instead of the path they named. - **REMAINING, in order:** task 18's pending-release slot and drains, task 19's four stranding transitions, then the full head-exact gate and the PR. diff --git a/src/daemon.rs b/src/daemon.rs index 18e7994..60bc11e 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -8332,25 +8332,130 @@ mod tests { ); } - // P10 IS DELIBERATELY ABSENT, and this note is why. - // - // The press path also gates arming on `begin_selection` actually - // starting a drag, so a local terminal press that begins nothing - // records nothing. That gate has NO WITNESS because it has no - // reachable false branch through the daemon: `begin_selection` - // returns `false` only when the session is missing or the cell maps - // to no retained row, and by the time the daemon reaches the press - // path `classify_panel_pointer` has already established that the - // buffer IS the side window's live terminal, while `anchor_at` - // resolves every in-grid cell of a live view — measured, not - // assumed: a press at every content row of the 4-row fixture - // anchors. - // - // The gate is kept as insurance against a future view that can - // refuse, and it is recorded as UNWITNESSED rather than covered by - // a row that would pass whether or not the gate existed. A row at - // an out-of-grid cell looks like it tests this and does not: such a - // press is `Refused` long before the local path. + /// P10 — a LOCAL terminal press that begins no drag does not arm. + /// + /// **The panel is wider than its terminal.** The fixture's band is + /// 80 columns while the child's screen is 20, so columns 20..79 are + /// painted padding: inside accepted panel content, and behind no + /// terminal cell. `anchor_at` refuses them + /// (`coord.col >= row.cells.len()`), `begin_selection` returns + /// `false`, and nothing begins. + /// + /// An earlier round recorded this gate as UNWITNESSED, claiming + /// `anchor_at` resolves every in-grid cell of a live view. That was + /// measured on ROWS and generalised to cells, which is a different + /// statement and a false one. + #[test] + fn r4_p10_a_local_press_that_begins_no_drag_does_not_arm() { + let fid = FrontendId(803); + let (mut editor, mut states, mut render, panel, buffer_id, epochs) = + terminal_panel_session(fid, false); + let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); + let none = pmacs_protocol::Modifiers::default(); + let key = crate::terminal::view::TerminalViewKey::new(fid, panel, buffer_id); + // Column 20 of an 80-column band over a 20-column child. + let padding = pmacs_protocol::CellCoord::new(1, 20); + assert_eq!( + editor + .classify_panel_pointer(fid, buffer_id, padding, press) + .outcome(), + crate::editor::PanelPointerOutcome::Accepted, + "fixture: the cell is ACCEPTED content, so a refusal cannot \ + be what this row observes" + ); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + padding, + press, + none, + ); + + assert!( + !editor + .terminal_manager + .borrow() + .view_is_dragging_for_test(key), + "fixture: no local drag began --- the cell is painted padding" + ); + assert!( + !states[&fid].has_accepted_gesture(), + "so nothing may be armed: the record would name a drag that \ + does not exist, and its completion would have nothing to \ + finish" + ); + } + + /// P12 — a panel WIDER THAN 512 COLUMNS still routes pointer input. + /// + /// A panel deliberately does not inherit the terminal's per-axis PTY + /// caps (Bet B5'): a 4K surface at a small font is legitimately + /// wider than `MAX_TERMINAL_COLS`, and the renderer clamps through + /// `terminal_projection_size` so the band paints. Pointer routing + /// passed the RAW panel width, and `view_status_for_size` refuses + /// anything over the cap — so on exactly those panels a click + /// squarely inside the visible terminal resolved to nothing and the + /// whole band was dead to the mouse while looking perfectly normal. + /// + /// A POSITIVE control: the gesture must land, not merely fail + /// safely. + #[test] + fn r4_p12_a_panel_wider_than_the_terminal_cap_still_routes_pointer_input() { + let fid = FrontendId(805); + let (mut editor, mut states, mut render, panel, buffer_id, _epochs) = + terminal_panel_session(fid, true); + + // Re-declare the surface far wider than MAX_TERMINAL_COLS. + let wide = u32::from(crate::terminal::MAX_TERMINAL_COLS) + 128; + editor.accept_semantic_frame_geometry(fid, 2, CellSize::new(24, wide)); + let epochs = shipped_declaration(&editor, fid, &mut states); + { + let core = editor.core.borrow(); + let grid = core.panel_grid_size(fid).expect("a live panel grid"); + assert!( + grid.cols > u32::from(crate::terminal::MAX_TERMINAL_COLS), + "fixture: the band must actually exceed the terminal cap, \ + got {} columns", + grid.cols + ); + } + + let cell = pmacs_protocol::CellCoord::new(1, 2); + let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); + let none = pmacs_protocol::Modifiers::default(); + let _ = child_stream(&editor); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + press, + none, + ); + + assert_eq!( + child_stream(&editor), + vec![SGR_PRESS_1_2.to_vec()], + "the press must reach the child on a wide band: routing has \ + to use the SAME projection clamp the renderer uses, or the \ + two disagree and the panel paints while ignoring the mouse" + ); + assert!( + states[&fid].has_accepted_gesture(), + "and it must arm --- this is a positive control, so failing \ + safely is still failing" + ); + let _ = panel; + } /// P11 — a recorded LOCAL completion still runs with the panel /// HIDDEN. @@ -8432,6 +8537,19 @@ mod tests { pmacs_protocol::CellCoord::new(grid.rows, 0) }; let key = crate::terminal::view::TerminalViewKey::new(fid, panel, buffer_id); + let active_before = editor.core.borrow().active_frontend; + let controller_before = editor + .terminal_manager + .borrow() + .controller_view_for_frontend(fid); + assert_eq!( + editor + .classify_panel_pointer(fid, buffer_id, outside, press) + .outcome(), + crate::editor::PanelPointerOutcome::Refused, + "fixture: this press really is refused --- asserted, because \ + every effect assertion below is vacuous if it is not" + ); send_panel( &mut editor, @@ -8450,6 +8568,21 @@ mod tests { "a refused press must send the child NOTHING --- a press it \ receives is one it will expect a release for" ); + assert_eq!( + editor.core.borrow().active_frontend, + active_before, + "and it must not ACTIVATE the panel: a misclassified press \ + focuses before its out-of-range anchor fails, so byte and \ + latch assertions alone stay green while focus has moved" + ); + assert_eq!( + editor + .terminal_manager + .borrow() + .controller_view_for_frontend(fid), + controller_before, + "and it must not claim the terminal CONTROLLER" + ); assert!( !editor .terminal_manager diff --git a/src/editor.rs b/src/editor.rs index d6cbed1..40e0ec7 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -526,21 +526,41 @@ pub enum TerminalGestureRoute { /// authority for that derivation; handing the daemon a bare outcome /// and letting it re-derive chrome or target kind would be the hole /// §5b closed, reopened beside the dispatcher. -pub struct PanelPointerDisposition { - outcome: PanelPointerOutcome, - resolved: Option, +pub enum PanelPointerDisposition { + /// Not addressable as this panel. **Carries no target**, which is + /// the point: as two independent fields, `Refused` plus a resolved + /// target stayed representable inside this module, and the only + /// thing preventing a refused gesture from reaching a target was + /// that no code currently built that pair. + Refused, + /// Claimed by this panel, but deliberately not a content gesture. + Consumed(ResolvedPanelTarget), + /// A content gesture for the resolved target. + Accepted(ResolvedPanelTarget), } impl PanelPointerDisposition { /// The disposition, for the daemon's lifecycle table. #[must_use] pub fn outcome(&self) -> PanelPointerOutcome { - self.outcome + match self { + Self::Refused => PanelPointerOutcome::Refused, + Self::Consumed(_) => PanelPointerOutcome::Consumed, + Self::Accepted(_) => PanelPointerOutcome::Accepted, + } } } /// The panel target a disposition resolved to, derived once. -struct ResolvedPanelTarget { +/// +/// **Public as a type, opaque as a value.** It has to be nameable +/// because `PanelPointerDisposition`'s variants carry it, but every +/// field stays private: the daemon receives a disposition and hands it +/// back, and cannot read the side window, the target kind or the +/// content bounds out of it. That is Q#BP-R4's first fixed fact — the +/// editor is the only authority for the derivation — enforced by +/// visibility rather than by convention. +pub struct ResolvedPanelTarget { side: WindowId, buffer_id: crate::buffer::BufferId, is_terminal: bool, @@ -2963,10 +2983,7 @@ impl EditorState { ) -> PanelPointerDisposition { use pmacs_protocol::MouseKind as PKind; - let refused = PanelPointerDisposition { - outcome: PanelPointerOutcome::Refused, - resolved: None, - }; + let refused = PanelPointerDisposition::Refused; let Some(size) = self.core.borrow().panel_grid_size(frontend_id) else { return refused; @@ -2994,13 +3011,13 @@ impl EditorState { side }; - let resolved = Some(ResolvedPanelTarget { + let resolved = ResolvedPanelTarget { side, buffer_id, is_terminal, content_rows, cols: size.cols, - }); + }; let on_chrome = coord.row >= content_rows; let is_wheel = matches!( kind, @@ -3009,7 +3026,7 @@ impl EditorState { // Everything below is CLAIMED by this panel — the cell is ours — // and the only question left is whether it is a content gesture. - let outcome = if on_chrome { + let consumed = if on_chrome { // Q#BP-R2, step 3 of the ordering: a terminal panel's chrome // wheel is consumed BEFORE focus, before `active_frontend`, // and before any controller claim. Placing it after @@ -3022,16 +3039,16 @@ impl EditorState { // rule — presses and motion are reserved, while `Up` and the // wheel fall through, because an `Up` must still terminate a // gesture begun in content and a chrome wheel still scrolls. - if is_terminal || matches!(kind, PKind::Down(_) | PKind::Drag(_) | PKind::Move) { - let _ = is_wheel; - PanelPointerOutcome::Consumed - } else { - PanelPointerOutcome::Accepted - } + let _ = is_wheel; + is_terminal || matches!(kind, PKind::Down(_) | PKind::Drag(_) | PKind::Move) } else { - PanelPointerOutcome::Accepted + false }; - PanelPointerDisposition { outcome, resolved } + if consumed { + PanelPointerDisposition::Consumed(resolved) + } else { + PanelPointerDisposition::Accepted(resolved) + } } /// Apply a classified panel gesture to its target (Q#BP-R4). @@ -3070,10 +3087,11 @@ impl EditorState { ) -> Option { use pmacs_protocol::MouseKind as PKind; - if disposition.outcome != PanelPointerOutcome::Accepted { + // Only `Accepted` carries a target into an effect, and the type + // is what enforces it: there is no `Refused` value holding one. + let PanelPointerDisposition::Accepted(target) = disposition else { return None; - } - let target = disposition.resolved.as_ref()?; + }; let activates = if target.is_terminal { !matches!(kind, PKind::Move) @@ -3091,7 +3109,17 @@ impl EditorState { // document terminal. The viewport is `content_rows`, never the // full grid: passing the frame would make the mode line a child // cell and put every clamp one row out. - let viewport = CellSize::new(target.content_rows, target.cols); + // THROUGH THE RENDERER'S OWN CLAMP. A panel deliberately does + // not inherit the terminal's per-axis PTY caps (Bet B5'), so + // a 4K surface at a small font is legitimately wider than + // `MAX_TERMINAL_COLS` — and `view_status_for_size` refuses + // anything over that cap. Passing the raw panel width made + // every gesture on such a panel resolve to `None`: a click + // squarely inside the visible terminal did nothing at all, + // while the same panel rendered fine because projection + // clamps and pointer routing did not agree. + let viewport = + terminal_projection_size(CellSize::new(target.content_rows, target.cols)); let key = TerminalViewKey::new(frontend_id, target.side, target.buffer_id); return match self.apply_terminal_gesture( key, From 557ea6dfacda48537efd59030650742edbe39673 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 20 Aug 2026 23:06:30 +0200 Subject: [PATCH 22/33] test(panel): P2 asserts real focus, and the ledger stops overstating Answers review of 9028e1b. P2 recorded `active_frontend`, which is which FRONTEND is current, not which window has focus. Focus is `views[&fid].active` and `focus_window` moves it, so the row would have watched a panel steal focus without noticing. It now records the focused window, and asserts up front that the panel does not already hold it --- otherwise "focus did not move to the panel" asserts nothing. P2's refusal lever also changed, because the old one could not exercise what the row claims. An out-of-grid cell with the row bound removed becomes `on_chrome`, so the press classifies Consumed and still reaches no target: the row bit on its own precondition while focus was never touched. A foreign buffer at an in-content cell is the refusal whose mis-gating actually yields Accepted. And the row now says what is falsifiable about it. Removing the buffer check makes the press Accepted and P2 fails --- but on the precondition, which fires first, so the focus, controller and byte assertions cannot fail under that mutation and no other mutation reaches them: the daemon applies only on Accepted, and the disposition enum gives Refused no target to apply. They are defence in depth against a future refactor, labelled as such rather than presented as coverage. The ledger claimed every row reads a target effect and never the latch alone. That was false. P9 and P10 read the LATCH, and correctly so --- the defect they fence is a record existing for a gesture that never began, so the record is the artifact, and an effect assertion would not distinguish their mutations. The line now separates effect rows from arming-gate rows and names P2's third case. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 19 ++++++++++++---- src/daemon.rs | 55 +++++++++++++++++++++++++++++++++++---------- 2 files changed, 58 insertions(+), 16 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index f50e8b0..f8cfc9d 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -307,10 +307,21 @@ from #171 and #215 — the correction the 1b lane missed, honoured here. carrying a target — stayed representable inside `editor.rs`. Now `Refused` holds no target at all. - **Witnesses: G5k(a)–(d), P1, P2, P3 both legs, P4, P5, P7, P8, P9, - P10, P11, P12.** Each reads a TARGET EFFECT — the child's byte - stream (**exact bytes**, not a count), the terminal drag state, the - document selection, or focus and controller ownership — never the - latch alone. Each bites its own mutation, including G5k's verbatim. + P10, P11, P12**, each biting its own mutation, including G5k's + verbatim. **They do NOT all read a target effect, and an earlier + version of this line said they did.** Two kinds: + - **Effect rows** — G5k(a)–(d), P1, P3 both legs, P4, P5, P7, P8, + P11, P12 — read the child's byte stream (**exact bytes**, not a + count), the terminal drag state, or the document selection. + - **Arming-gate rows** — **P9 and P10** — read the LATCH, and that + is correct for them: the defect they fence is a record existing + for a gesture that never began, so the record IS the artifact. + Manufacturing an effect assertion for them would not distinguish + their mutations. + **P2 is a third case**: its falsifiable claim is the + classification; its focus, controller and byte assertions are + defence in depth, because the precondition asserting `Refused` + fires first under the only mutation that would reach them. - **Every fixture asserts its own precondition** (the disposition is `Accepted`, or is `Refused`) because four rows in these rounds passed vacuously: cells that were out of grid, or that clamped to diff --git a/src/daemon.rs b/src/daemon.rs index 60bc11e..20a843a 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -8520,6 +8520,16 @@ mod tests { /// P2, effect half — a REFUSED press reaches no target at all. /// + /// **What is falsifiable here is the CLASSIFICATION.** Removing the + /// buffer check makes this press `Accepted`, and the row fails. The + /// focus, controller and byte assertions cannot fail under that + /// same mutation, because the precondition asserting `Refused` + /// fires first — and no other mutation reaches them, since the + /// daemon calls `apply_panel_pointer` only on `Accepted` and the + /// disposition enum gives `Refused` no target to apply. They are + /// **defence in depth against a future refactor**, kept and + /// labelled rather than presented as witnessed coverage. + /// /// §5b's four `g5_substrate_a_refused_*` rows read the latch and the /// cancellation count; none of them reads the target. A refusal that /// armed nothing while still sending the child a press, or starting @@ -8531,20 +8541,41 @@ mod tests { terminal_panel_session(fid, true); let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); let none = pmacs_protocol::Modifiers::default(); - let outside = { - let core = editor.core.borrow(); - let grid = core.panel_grid_size(fid).expect("grid"); - pmacs_protocol::CellCoord::new(grid.rows, 0) - }; + // THE REFUSAL LEVER IS A WRONG BUFFER, at an IN-CONTENT cell. + // + // An out-of-grid cell cannot serve here: with the row bound + // removed it becomes `on_chrome`, so the press classifies + // `Consumed` and still reaches no target. The row would "bite" + // on its own precondition while never exercising focus at all. + // A foreign buffer at a content cell is the refusal whose + // mis-gating yields `Accepted`, which is the misclassification + // these assertions are written against. + let foreign_buffer = editor + .core + .borrow() + .registry + .borrow_mut() + .create("*not-the-panel*"); + let in_content = pmacs_protocol::CellCoord::new(1, 2); let key = crate::terminal::view::TerminalViewKey::new(fid, panel, buffer_id); - let active_before = editor.core.borrow().active_frontend; + // FOCUS is `views[&fid].active`, not `active_frontend`: the + // latter says which frontend is current, and `focus_window` + // moves the former. An earlier version of this row recorded the + // wrong one and would have watched a panel steal focus without + // noticing. + let focused_before = editor.core.borrow().views[&fid].active; + assert_ne!( + focused_before, panel, + "fixture: the panel must start PASSIVE, or 'focus did not \ + move to the panel' asserts nothing" + ); let controller_before = editor .terminal_manager .borrow() .controller_view_for_frontend(fid); assert_eq!( editor - .classify_panel_pointer(fid, buffer_id, outside, press) + .classify_panel_pointer(fid, foreign_buffer, in_content, press) .outcome(), crate::editor::PanelPointerOutcome::Refused, "fixture: this press really is refused --- asserted, because \ @@ -8557,8 +8588,8 @@ mod tests { &mut render, fid, epochs, - buffer_id, - outside, + foreign_buffer, + in_content, press, none, ); @@ -8569,9 +8600,9 @@ mod tests { receives is one it will expect a release for" ); assert_eq!( - editor.core.borrow().active_frontend, - active_before, - "and it must not ACTIVATE the panel: a misclassified press \ + editor.core.borrow().views[&fid].active, + focused_before, + "and it must not FOCUS the panel: a misclassified press focuses before its out-of-range anchor fails, so byte and \ latch assertions alone stay green while focus has moved" ); From ba48ae6153c0f90adf240f1f607624e691817dec Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 20 Aug 2026 23:12:06 +0200 Subject: [PATCH 23/33] test(panel): P2's focus assertion was blocked by assertion order, not by the type Answers review of 557ea6d. Capturing the outcome and asserting it LAST makes the focus assertion reachable: removing the buffer check accepts the press, an accepted press activates the panel before it replays, and the row now fails on focus --- WindowId(3) against WindowId(2). I had recorded this as a limit of the type boundary, claiming no mutation could reach the effect assertions because the daemon applies only on Accepted and the disposition gives Refused no target. That was wrong. The obstacle was that the row asserted the refusal BEFORE dispatch and aborted there. Ordering, not architecture. The classification is still checked, at the end, so the row cannot go vacuous if it ever stops testing a refusal. Controller and byte assertions stay documented as defence in depth, and now for an accurate reason: the mutation that reaches them routes through a document buffer, which touches neither. Also replaces failure text that still described an out-of-range anchor, which this fixture stopped using when its refusal lever became a foreign buffer. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 14 ++++++++---- src/daemon.rs | 52 ++++++++++++++++++++++++++++----------------- 2 files changed, 42 insertions(+), 24 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index f8cfc9d..2a87a12 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -318,10 +318,16 @@ from #171 and #215 — the correction the 1b lane missed, honoured here. for a gesture that never began, so the record IS the artifact. Manufacturing an effect assertion for them would not distinguish their mutations. - **P2 is a third case**: its falsifiable claim is the - classification; its focus, controller and byte assertions are - defence in depth, because the precondition asserting `Refused` - fires first under the only mutation that would reach them. + **P2 is a third case**: its FOCUS assertion is witnessed — + removing the buffer check accepts the press, which activates the + panel before replaying — and its classification is checked LAST so + the row still fails if it stops testing a refusal. Only its + controller and byte assertions are defence in depth, because that + mutation routes through a document buffer and touches neither. An + earlier version asserted the refusal first, which aborted the row + before dispatch and made every effect assertion unreachable; I + recorded that ordering limit as a limit of the type boundary, and + it was not one. - **Every fixture asserts its own precondition** (the disposition is `Accepted`, or is `Refused`) because four rows in these rounds passed vacuously: cells that were out of grid, or that clamped to diff --git a/src/daemon.rs b/src/daemon.rs index 20a843a..776c032 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -8520,15 +8520,18 @@ mod tests { /// P2, effect half — a REFUSED press reaches no target at all. /// - /// **What is falsifiable here is the CLASSIFICATION.** Removing the - /// buffer check makes this press `Accepted`, and the row fails. The - /// focus, controller and byte assertions cannot fail under that - /// same mutation, because the precondition asserting `Refused` - /// fires first — and no other mutation reaches them, since the - /// daemon calls `apply_panel_pointer` only on `Accepted` and the - /// disposition enum gives `Refused` no target to apply. They are - /// **defence in depth against a future refactor**, kept and - /// labelled rather than presented as witnessed coverage. + /// **The FOCUS assertion is witnessed.** Removing the buffer check + /// makes this press `Accepted`; it then activates the panel before + /// replaying, and the focus assertion fails. An earlier version + /// asserted the refusal BEFORE dispatch, which aborted the row + /// first and made every effect assertion unreachable — a limit of + /// ordering that I mistook for a limit of the type boundary. The + /// classification is now checked LAST, so it still catches a row + /// that has stopped testing a refusal. + /// + /// The controller and byte assertions remain **defence in depth**: + /// the mutation that reaches them routes through a document buffer, + /// which touches neither. /// /// §5b's four `g5_substrate_a_refused_*` rows read the latch and the /// cancellation count; none of them reads the target. A refusal that @@ -8573,14 +8576,14 @@ mod tests { .terminal_manager .borrow() .controller_view_for_frontend(fid); - assert_eq!( - editor - .classify_panel_pointer(fid, foreign_buffer, in_content, press) - .outcome(), - crate::editor::PanelPointerOutcome::Refused, - "fixture: this press really is refused --- asserted, because \ - every effect assertion below is vacuous if it is not" - ); + // CAPTURED, not asserted yet. Asserting the refusal here aborted + // the row before dispatch, so the effect assertions below could + // never fail under the one mutation that reaches them. The + // precondition still runs --- at the END --- so the row cannot go + // vacuous either. + let observed_outcome = editor + .classify_panel_pointer(fid, foreign_buffer, in_content, press) + .outcome(); send_panel( &mut editor, @@ -8602,9 +8605,10 @@ mod tests { assert_eq!( editor.core.borrow().views[&fid].active, focused_before, - "and it must not FOCUS the panel: a misclassified press - focuses before its out-of-range anchor fails, so byte and \ - latch assertions alone stay green while focus has moved" + "and it must not FOCUS the panel: an accepted press \ + activates BEFORE it replays, so a misclassified one moves \ + focus to the panel whatever its replay then does with a \ + buffer that is not the one on screen" ); assert_eq!( editor @@ -8614,6 +8618,14 @@ mod tests { controller_before, "and it must not claim the terminal CONTROLLER" ); + assert_eq!( + observed_outcome, + crate::editor::PanelPointerOutcome::Refused, + "and the press really was refused --- checked LAST so that a \ + mutation which accepts it is caught by the effects above \ + rather than aborting the row here, while still failing if \ + this row ever stops testing a refusal at all" + ); assert!( !editor .terminal_manager From ab8ddaecec55697ca40a67e997def7f3a46fb957 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 20 Aug 2026 23:28:15 +0200 Subject: [PATCH 24/33] feat(panel): the pending-release slot, and the order it drains in Task 18. Cancellation now PARKS the record instead of returning it into a context that drops it: two of the three cancellation sites --- the mapping-generation advance and publish_absent_panel --- run inside frame production, where no target effect can happen, so the gesture ended with the child still holding its button. A SLOT, not a queue. The latch holds at most one gesture per frontend, so at most one release can be owed, and the bound is structural rather than a cap someone chose. Overwriting a still-parked release is a debug_assert rather than a silent loss --- a backstop, since the ordering is what actually prevents it. Three drains, each against a specific way the release would otherwise arrive too late or not at all: before any subsequent panel-pointer effect, before detach teardown (the next statement in that arm drops the state holding the record), and at the projection seam between render_frame returning and its messages being written. Q1, Q2, Q3, Q4 and Q6, each biting its named mutation with the run counts checked. The mutations discriminate: dropping the park fails all three delivery rows; removing the effect-drain spares Q4, which has its own; removing the detach drain hits Q4 alone. Q3 asserts ORDER rather than arrival, and the mutation that keeps the drain but moves it after the press effect fails exactly that assertion, with the child's stream coming back press-then-release. Q5 IS OWED, AND RECORDED AS SUCH. The projection-seam drain needs a row that drives the real per-frontend frame loop; these rows call render_frame directly and never enter it. The seam still matters --- a cancellation with no following panel event and no detach would let the successor frame overtake the release its own new mapping required --- so it is written down as unwitnessed rather than treated as covered by its neighbours. Two fixture facts worth keeping: the Q rows cancel through Absence rather than a mapping advance, because a terminal panel's key tracks its screen and anchor, not its buffer, so a foreign edit does not move it; and the helper re-shows AND re-declares the panel, because a panel left Absent fails the inbound ladder and the row would then be observing the ladder rather than the slot. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 19 ++- src/daemon.rs | 351 +++++++++++++++++++++++++++++++++++++++++ src/editor.rs | 8 + src/semantic_render.rs | 48 +++++- 4 files changed, 422 insertions(+), 4 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 2a87a12..bba7579 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -332,9 +332,22 @@ from #171 and #215 — the correction the 1b lane missed, honoured here. `Accepted`, or is `Refused`) because four rows in these rounds passed vacuously: cells that were out of grid, or that clamped to byte 0, exercised a refusal instead of the path they named. - - **REMAINING, in order:** task 18's pending-release slot and drains, - task 19's four stranding transitions, then the full head-exact gate - and the PR. + - **LANDED: the pending-release SLOT and its drain order (task 18).** + Cancellation parks the record instead of returning it into a + context that drops it — two of the three cancellation sites are + inside frame production, where no target effect can run. The drain + pays it **before any subsequent panel-pointer effect**, **before + detach teardown**, and **at the projection seam** between + `render_frame` returning and its messages being written. + - **Witnessed: Q1, Q2, Q3, Q4, Q6.** Q3 asserts ORDER, not arrival, + and the mutation that keeps the drain but moves it after the + press effect fails exactly that assertion. + - **Q5 is OWED.** The projection-seam drain needs a row that drives + the real per-frontend frame loop; the unit rows call + `render_frame` directly and never enter it. Recorded rather than + treated as covered by its neighbours. + - **REMAINING, in order:** task 19's four stranding transitions, Q5's + acceptance-shaped row, then the full head-exact gate and the PR. - **Two test seams added for this:** an opt-in child-input tap (`start_send_tap_for_test`) and a drag-state read (`view_is_dragging_for_test`). Nothing else exposes what the child diff --git a/src/daemon.rs b/src/daemon.rs index 776c032..0cdcae1 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1019,6 +1019,40 @@ fn panel_event_epochs_are_current( .is_some_and(|geometry| geometry.geometry_epoch == geometry_epoch) } +/// Deliver the release this frontend is owed, if any (parent 48). +/// +/// **Order is the ruling, not just the existence of a slot.** A +/// cancellation raised inside frame production cannot deliver its own +/// release, so the record is parked; this is where it is paid. It runs +/// at three points, and each is chosen against a specific way the +/// release would otherwise arrive too late or not at all: +/// +/// * **before any subsequent panel-pointer effect**, so the old +/// gesture's release reaches the child ahead of the new gesture's +/// press rather than after it; +/// * **before detach teardown**, because detach removes the state that +/// holds the record and there is no later opportunity; +/// * **after semantic projection returns and before its messages are +/// written**, so the successor frame cannot overtake the release its +/// own new mapping required. +/// +/// The synthetic release carries NO modifiers: nothing is physically +/// held, and inventing a modifier state would report a chord the user +/// never made. +fn drain_pending_release( + editor: &mut EditorState, + semantic_states: &mut HashMap, + source: FrontendId, +) { + let Some(record) = semantic_states + .get_mut(&source) + .and_then(crate::semantic_render::SemanticRenderState::take_pending_release) + else { + return; + }; + editor.complete_panel_gesture(source, &record, pmacs_protocol::Modifiers::default()); +} + /// Parent 48 Q#BP-R4 — the authoritative lifecycle table. /// /// The disposition is decided BEFORE any target effect, and the live @@ -1043,6 +1077,12 @@ fn replay_panel_pointer( use crate::editor::PanelPointerOutcome as Outcome; use pmacs_protocol::{MouseButton, MouseKind}; + // DRAIN FIRST. An owed release has to reach the child before this + // gesture's press does; draining afterwards would put them on the + // wire in the wrong order, which reads to the child as a press + // followed by a release of the gesture BEFORE it. + drain_pending_release(editor, semantic_states, source); + let disposition = editor.classify_panel_pointer(source, buffer_id, coord, kind); let outcome = disposition.outcome(); if outcome == Outcome::Refused { @@ -1540,6 +1580,16 @@ fn dispatcher_loop( render_state.render_frame(editor, *fid, &terminal_snapshots, &other_presences) }; + // Parent 48 — THE PROJECTION SEAM. A mapping-generation + // advance cancels the live gesture INSIDE `render_frame`, + // while the successor `PresentMapped` is still being built, + // so "before that frame is produced" is not a place that + // exists. This is the first point that is: projection has + // returned and none of what it returned has been written. + // Draining here keeps the release ahead of the frame whose + // own new mapping is what required it. + drain_pending_release(editor, &mut semantic_states, *fid); + // Vterm Stage 3 — a semantic frontend showing a terminal has // no document cursor: the identity buffer is empty, so a // `CursorByte` would describe byte 0 of a buffer with no @@ -2928,6 +2978,9 @@ fn handle_dispatcher_event( } } DispatcherEvent::SessionDetached { frontend_id } => { + // Before ANY teardown: the next line drops the state that + // holds an owed release, and detach has no later chance. + drain_pending_release(editor, semantic_states, frontend_id); render_states.remove(&frontend_id); semantic_states.remove(&frontend_id); streams.remove(&frontend_id); @@ -8391,6 +8444,304 @@ mod tests { ); } + // ----------------------------------------------------------------- + // Q1-Q6 — the pending-release slot, and the ORDER it drains in. + // + // A cancellation raised inside frame production cannot deliver its + // own release. The slot is where the record waits; these rows are + // about it being paid, and paid at the right moment. + // ----------------------------------------------------------------- + + /// Drive a real `SessionDetached` through the dispatcher. + fn detach_session( + editor: &mut crate::editor::EditorState, + semantic_states: &mut HashMap, + render_states: &mut HashMap, + fid: FrontendId, + ) { + let mut streams = HashMap::new(); + let mut term_sizes = HashMap::new(); + let mut last_idle = HashMap::new(); + let mut last_active = HashMap::new(); + let mut bells = HashMap::new(); + let mut registry = SessionRegistry::new(); + registry.register_session(fid, session(LEGACY_PANEL_VERSION, true)); + handle_dispatcher_event( + DispatcherEvent::SessionDetached { frontend_id: fid }, + editor, + render_states, + semantic_states, + &mut streams, + &mut term_sizes, + &mut last_idle, + &mut last_active, + &mut bells, + &mut registry, + ); + } + + /// Cancel the live gesture from INSIDE PROJECTION, by taking the + /// panel away. + /// + /// `publish_absent_panel` cancels while `render_frame` is building + /// the frame, which is the shape these rows are about: a + /// cancellation with nowhere to deliver its release. + /// + /// A mapping-generation advance is the other such trigger and would + /// read more naturally, but it cannot be driven on a TERMINAL + /// panel: that key tracks the screen and anchor, not the buffer, so + /// a foreign edit does not move it. `Absent` is family-independent + /// and reaches the same parking path. + /// Returns the epochs a later gesture must echo: re-showing the + /// panel ships a NEW declaration, and the old epochs are stale. + fn cancel_by_panel_absence( + editor: &crate::editor::EditorState, + states: &mut HashMap, + fid: FrontendId, + ) -> (u64, u64) { + editor.hide_panel_for_test(fid); + { + let sem = states.get_mut(&fid).expect("projection"); + let _ = sem.render_frame(editor); + } + // Re-shown and re-declared, because these rows are about what + // happens to the OWED RELEASE afterwards. A panel left `Absent` + // fails the inbound ladder, so no later gesture would reach the + // drain at all and the row would be observing the ladder rather + // than the slot. + editor.show_panel_for_test(fid); + shipped_declaration(editor, fid, states) + } + + /// Q1/Q2 — a cancelled gesture's release is PARKED and then PAID. + /// + /// The cancellation happens inside projection, where no target + /// effect can run; dropping the record there is how the child ended + /// up holding a button with nothing left to lift it. + #[test] + fn q1_q2_a_cancelled_gesture_release_is_parked_then_delivered() { + let fid = FrontendId(810); + let (mut editor, mut states, mut render, _panel, buffer_id, epochs) = + terminal_panel_session(fid, true); + let cell = pmacs_protocol::CellCoord::new(1, 2); + let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); + let none = pmacs_protocol::Modifiers::default(); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + press, + none, + ); + assert_eq!( + child_stream(&editor), + vec![SGR_PRESS_1_2.to_vec()], + "fixture: the press reached the child" + ); + + let epochs = cancel_by_panel_absence(&editor, &mut states, fid); + assert!( + !states[&fid].has_accepted_gesture(), + "fixture: the advance cancelled the gesture" + ); + assert!( + states[&fid].has_pending_release(), + "Q1: the record must be PARKED, not returned into a context \ + that drops it" + ); + assert!( + child_stream(&editor).is_empty(), + "and not delivered from inside projection, which cannot run \ + a target effect" + ); + + // A later panel event: the drain runs ahead of it. + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + press, + none, + ); + + let sent = child_stream(&editor); + assert!( + sent.contains(&SGR_RELEASE_1_2.to_vec()), + "Q2: the parked release must be PAID --- parking it and never \ + draining leaves the child exactly as stranded, got {sent:?}" + ); + } + + /// Q3 — the owed release reaches the child BEFORE the next press. + /// + /// Draining after the dispatch instead of before puts them on the + /// wire reversed, which the child reads as a press followed by the + /// release of the gesture before it. + #[test] + fn q3_the_owed_release_precedes_the_next_press() { + let fid = FrontendId(811); + let (mut editor, mut states, mut render, _panel, buffer_id, epochs) = + terminal_panel_session(fid, true); + let cell = pmacs_protocol::CellCoord::new(1, 2); + let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); + let none = pmacs_protocol::Modifiers::default(); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + press, + none, + ); + let epochs = cancel_by_panel_absence(&editor, &mut states, fid); + let _ = child_stream(&editor); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + press, + none, + ); + + let sent = child_stream(&editor); + let release_at = sent.iter().position(|b| b == SGR_RELEASE_1_2); + let press_at = sent.iter().position(|b| b == SGR_PRESS_1_2); + assert!( + release_at.is_some() && press_at.is_some(), + "fixture: both the owed release and the new press must be on \ + the wire, got {sent:?}" + ); + assert!( + release_at < press_at, + "Q3: ORDER, not merely arrival --- the old gesture's release \ + must precede the new gesture's press, got {sent:?}" + ); + } + + /// Q4 — detach pays what it owes before tearing the state down. + /// + /// `SessionDetached` drops `semantic_states` for the frontend, and + /// there is no later opportunity: a release not delivered here is + /// never delivered. + #[test] + fn q4_detach_delivers_an_owed_release_before_teardown() { + let fid = FrontendId(812); + let (mut editor, mut states, mut render, _panel, buffer_id, epochs) = + terminal_panel_session(fid, true); + let cell = pmacs_protocol::CellCoord::new(1, 2); + let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); + let none = pmacs_protocol::Modifiers::default(); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + press, + none, + ); + // Detach never sends another gesture, so the fresh epochs are + // not needed here --- only the parked release is. + let _ = cancel_by_panel_absence(&editor, &mut states, fid); + assert!( + states[&fid].has_pending_release(), + "fixture: a release is owed" + ); + let _ = child_stream(&editor); + + detach_session(&mut editor, &mut states, &mut render, fid); + + assert_eq!( + child_stream(&editor), + vec![SGR_RELEASE_1_2.to_vec()], + "Q4: the owed release must be paid before teardown --- the \ + next statement in that arm drops the state holding it" + ); + assert!( + !states.contains_key(&fid), + "fixture: and the teardown really did run" + ); + } + + // Q5 IS NOT WITNESSED HERE, and this is why. + // + // The third drain sits in the daemon's per-frontend frame loop, + // between `sem.render_frame(editor)` returning and the loop that + // writes what it returned. These rows drive `render_frame` + // directly, so they never enter that loop and cannot observe the + // seam; the two drains they DO reach — before a panel-pointer + // effect, and before detach teardown — are witnessed by Q1-Q4. + // + // The seam is still load-bearing: a cancellation raised inside + // projection with no following panel event and no detach would + // otherwise let the successor frame reach the frontend ahead of the + // release its own new mapping required. Witnessing it needs a row + // that drives the real frame loop, which is acceptance-suite shaped + // rather than unit shaped. Recorded as OWED rather than assumed + // covered by its neighbours. + + /// Q6 — the arm-over-pending invariant, as a BACKSTOP. + /// + /// The ordering is what prevents this; the assertion exists so that + /// a future path which cancels twice without draining is loud + /// rather than silently losing one release. + #[test] + fn q6_arming_never_happens_over_a_pending_release() { + let fid = FrontendId(813); + let (mut editor, mut states, mut render, _panel, buffer_id, epochs) = + terminal_panel_session(fid, true); + let cell = pmacs_protocol::CellCoord::new(1, 2); + let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); + let none = pmacs_protocol::Modifiers::default(); + + for _ in 0..3 { + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + press, + none, + ); + let _ = cancel_by_panel_absence(&editor, &mut states, fid); + assert!( + states[&fid].has_pending_release(), + "fixture: each round parks a release" + ); + } + + assert!( + !states[&fid].has_pending_release() || states[&fid].accepted_gesture().is_none(), + "Q6: a release may be owed, or a gesture armed, but arming \ + OVER an owed release would lose one --- the drain before \ + each effect is what keeps these from overlapping" + ); + } + /// P12 — a panel WIDER THAN 512 COLUMNS still routes pointer input. /// /// A panel deliberately does not inherit the terminal's per-axis PTY diff --git a/src/editor.rs b/src/editor.rs index 40e0ec7..7be75ce 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -2948,6 +2948,14 @@ impl EditorState { } } + /// Re-show this frontend's panel, for the Q rows. + #[doc(hidden)] + pub fn show_panel_for_test(&self, frontend_id: FrontendId) { + if let Some(view) = self.core.borrow_mut().views.get_mut(&frontend_id) { + view.panel_hidden = false; + } + } + /// Classify an authenticated panel gesture, WITHOUT applying it /// (Q#BP-R4). /// diff --git a/src/semantic_render.rs b/src/semantic_render.rs index 4758b9c..d4c9d1c 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -464,6 +464,21 @@ pub struct SemanticRenderState { /// match the press it ends, and a stale `Up` with no accepted /// `Down` must be inert rather than synthesising one. accepted_gesture: Option, + /// §5b/parent 48 — a release this frontend is OWED, parked until + /// somewhere that can deliver it. + /// + /// Two of the three cancellation sites are inside frame production + /// — the mapping-generation advance and `publish_absent_panel` — + /// where no target effect can run. `cancel_accepted_gesture` + /// returned the record into those contexts and they dropped it, so + /// the gesture ended with the child still holding its button. + /// + /// **A SLOT, not a queue.** The latch holds at most one gesture per + /// frontend, so at most one release can be owed, and the bound is + /// structural rather than a cap someone had to choose. The drain + /// runs ahead of the next panel-pointer effect, so a second + /// cancellation cannot arrive while one is still parked. + pending_release: Option, /// §5b — how many armed gestures an authority loss has ended this /// session. /// @@ -686,6 +701,7 @@ impl SemanticRenderState { panel_mapping: None, panel_mapping_exhausted: false, accepted_gesture: None, + pending_release: None, panel_gesture_cancellations: 0, panel_epoch_used: 0, panel_presentation: None, @@ -776,12 +792,42 @@ impl SemanticRenderState { /// without inventing a second cancellation. pub fn cancel_accepted_gesture(&mut self) -> Option { let cancelled = self.accepted_gesture.take(); - if cancelled.is_some() { + if let Some(record) = cancelled { self.panel_gesture_cancellations = self.panel_gesture_cancellations.saturating_add(1); + // PARKED, not just returned. Returning was the whole bug: + // the callers inside frame production cannot deliver a + // release, so the record went out of scope and the gesture + // ended with nothing terminated. + // + // Overwriting a still-parked release would lose one, so it + // is an invariant violation rather than a silent drop. It is + // a BACKSTOP: the ordering — drain before the next effect — + // is what actually prevents it. + debug_assert!( + self.pending_release.is_none(), + "a release was still owed when another gesture was \ + cancelled; the drain must run before any subsequent \ + panel-pointer effect" + ); + self.pending_release = Some(record); } cancelled } + /// Take the release this frontend is owed, if any. + /// + /// The RETURN of `cancel_accepted_gesture` is for inspection; THIS + /// is the delivery path. + pub fn take_pending_release(&mut self) -> Option { + self.pending_release.take() + } + + /// Whether a release is still owed, for assertions. + #[must_use] + pub fn has_pending_release(&self) -> bool { + self.pending_release.is_some() + } + /// The live gesture record, if any — parent 48 Q#BP-R4's /// "live record" test, and the source of the recorded completion. #[must_use] From 4e9420a69acb6210231394d1ddfc3fb88e8ed12b Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 21 Aug 2026 10:40:54 +0200 Subject: [PATCH 25/33] fix(panel): a live gesture is paid BEFORE its replacement press lands Answers review of ab8ddae. The entry drain could not close this: it looks for an OWED release, and a gesture that is still LIVE owes nothing yet. Arming was what cancelled it, and arming runs after the replacement press has already reached the target --- so a second press with the first never released put `old press, new press, old release` on the wire. Two presses outstanding, then a release arriving for the wrong one. The Down arm now ends the live gesture and drains it before applying the replacement, so the child sees `old press, old release, new press`. The invariant moved to where it is relied on. arm_accepted_gesture now asserts that neither a live gesture nor an owed release remains, at the point of ARMING rather than inside cancellation --- arming is what the ordering protects, and checking during cancellation cannot see the case where nothing has been cancelled yet. The defensive cancel stays for release builds, because parking late is recoverable and overwriting is not. Q6 was rewritten, because the old one never sent a second press while the first was live and so could not observe any of this; its final assertion also ran after a further cancellation. It now expects the exact bytes `release(1,2), press(2,4)` in that order. Both layers are witnessed separately. Reverting the ordering trips the new debug assertion at the point of arming; reverting it AND compiling that assertion out --- which is what a release build does --- fails the byte-order assertion instead, with the child receiving only the new press. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 17 +++++-- src/daemon.rs | 111 ++++++++++++++++++++++++++++++----------- src/semantic_render.rs | 30 ++++++++--- 3 files changed, 119 insertions(+), 39 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index bba7579..d2717aa 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -339,9 +339,20 @@ from #171 and #215 — the correction the 1b lane missed, honoured here. pays it **before any subsequent panel-pointer effect**, **before detach teardown**, and **at the projection seam** between `render_frame` returning and its messages being written. - - **Witnessed: Q1, Q2, Q3, Q4, Q6.** Q3 asserts ORDER, not arrival, - and the mutation that keeps the drain but moves it after the - press effect fails exactly that assertion. + - **A LIVE gesture is ended and PAID before a replacement press + lands.** The entry drain alone was not enough: it looks for an + OWED release, and a live gesture owes nothing yet — arming was + what cancelled it, which happens after the replacement has + already reached the target. The child saw `old press, new press, + old release`. + - **Witnessed: Q1, Q2, Q3, Q4, Q6.** Q3 asserts ORDER, not arrival. + Q6 sends a second press with the first still live and expects + exactly `old release, new press` in the child's stream. Both + layers of Q6 are proven separately: the invariant now asserts at + the point of ARMING (not inside cancellation) and fires in debug, + and with that assert compiled out the byte-order assertion + catches the same defect — which is what a release build relies + on. - **Q5 is OWED.** The projection-seam drain needs a row that drives the real per-frontend frame loop; the unit rows call `render_frame` directly and never enter it. Recorded rather than diff --git a/src/daemon.rs b/src/daemon.rs index 0cdcae1..17d2b97 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1100,6 +1100,23 @@ fn replay_panel_pointer( // A chrome press begins nothing. return; } + // A SECOND PRESS WITH THE FIRST STILL LIVE. The entry drain + // above saw nothing, because the old gesture had not been + // cancelled yet --- it was live, not owed. Arming used to do + // the cancelling, which happens AFTER this press has already + // reached the target, so the child received + // `old press, new press, old release`. + // + // End the old gesture and PAY it here, before the + // replacement lands, so the wire carries + // `old press, old release, new press`. + if live { + if let Some(state) = semantic_states.get_mut(&source) { + let _ = state.cancel_accepted_gesture(); + } + drain_pending_release(editor, semantic_states, source); + } + // ARMED FROM THE EFFECT RESULT, and only if there was one. // `None` means the target refused the press --- a terminal // view that is gone, for instance --- and arming over that @@ -7904,6 +7921,8 @@ mod tests { const SGR_RELEASE_1_2: &[u8] = b"\x1b[<0;3;2m"; /// The same release with SHIFT held: the button code gains 4. const SGR_RELEASE_1_2_SHIFT: &[u8] = b"\x1b[<4;3;2m"; + /// A left press at cell (2, 4), the replacement gesture's cell. + const SGR_PRESS_2_4: &[u8] = b"\x1b[<0;5;3M"; /// A panel session whose side window holds a live TERMINAL, with /// the send tap armed. @@ -8701,48 +8720,80 @@ mod tests { // rather than unit shaped. Recorded as OWED rather than assumed // covered by its neighbours. - /// Q6 — the arm-over-pending invariant, as a BACKSTOP. + /// Q6 — a SECOND PRESS with the first still live: the old release + /// reaches the child before the new press. /// - /// The ordering is what prevents this; the assertion exists so that - /// a future path which cancels twice without draining is loud - /// rather than silently losing one release. + /// This is the ordering the entry drain alone cannot give. That + /// drain looks for an OWED release, and a live gesture owes + /// nothing yet — it is cancelled by arming, which happens after the + /// replacement press has already reached the target. The child then + /// saw `old press, new press, old release`: two presses + /// outstanding, and a release arriving for the wrong one. + /// + /// An earlier version of this row never sent a second press while + /// the first was live, so it could not observe any of that. #[test] - fn q6_arming_never_happens_over_a_pending_release() { + fn q6_a_second_press_pays_the_first_before_it_lands() { let fid = FrontendId(813); let (mut editor, mut states, mut render, _panel, buffer_id, epochs) = terminal_panel_session(fid, true); - let cell = pmacs_protocol::CellCoord::new(1, 2); + let first = pmacs_protocol::CellCoord::new(1, 2); + let second = pmacs_protocol::CellCoord::new(2, 4); let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); let none = pmacs_protocol::Modifiers::default(); - for _ in 0..3 { - send_panel( - &mut editor, - &mut states, - &mut render, - fid, - epochs, - buffer_id, - cell, - press, - none, - ); - let _ = cancel_by_panel_absence(&editor, &mut states, fid); - assert!( - states[&fid].has_pending_release(), - "fixture: each round parks a release" - ); - } - + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + first, + press, + none, + ); + assert_eq!( + child_stream(&editor), + vec![SGR_PRESS_1_2.to_vec()], + "fixture: the first press reached the child" + ); assert!( - !states[&fid].has_pending_release() || states[&fid].accepted_gesture().is_none(), - "Q6: a release may be owed, or a gesture armed, but arming \ - OVER an owed release would lose one --- the drain before \ - each effect is what keeps these from overlapping" + states[&fid].has_accepted_gesture(), + "fixture: and it is still LIVE --- no release was sent" + ); + + // The second press, with the first never released. + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + second, + press, + none, + ); + + assert_eq!( + child_stream(&editor), + vec![SGR_RELEASE_1_2.to_vec(), SGR_PRESS_2_4.to_vec()], + "the first gesture's release must reach the child BEFORE the \ + replacement press --- the release is at the first gesture's \ + own cell, and it comes first" + ); + assert!( + states[&fid].has_accepted_gesture(), + "and the replacement is armed" + ); + assert!( + !states[&fid].has_pending_release(), + "with nothing left owed" ); } - /// P12 — a panel WIDER THAN 512 COLUMNS still routes pointer input. + /// P12 — a panel WIDER THAN 512 COLUMNS still routes pointer input. /// P12 — a panel WIDER THAN 512 COLUMNS still routes pointer input. /// /// A panel deliberately does not inherit the terminal's per-axis PTY /// caps (Bet B5'): a 4K surface at a small font is legitimately diff --git a/src/semantic_render.rs b/src/semantic_render.rs index d4c9d1c..e3cb9cb 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -757,12 +757,30 @@ impl SemanticRenderState { /// per §5b's split table, because the release it denies does not /// exist on this branch. pub fn arm_accepted_gesture(&mut self, gesture: AcceptedPanelGesture) { - // A second accepted press while one is already armed means the - // first gesture's release never arrived — a dropped `Up`, or an - // outbox that closed under a stall. END it rather than - // overwrite it: overwriting discards the record silently, and - // once replay attaches effects to that record the child is left - // holding a button down with nothing left to release it. + // THE INVARIANT IS ASSERTED WHERE IT IS RELIED ON. A caller must + // have ended AND paid any live gesture before arming a + // replacement: a second accepted press while one is armed means + // the first gesture's release never arrived, and its release has + // to reach the target before this press does. Cancelling here + // instead is too late by exactly one effect --- the replacement + // has already landed. + // + // Checked at the point of arming rather than inside + // cancellation, because arming is what the ordering protects. + debug_assert!( + self.accepted_gesture.is_none(), + "arming over a LIVE gesture: the caller must cancel and \ + drain it first, or the replacement press overtakes the old \ + gesture's release" + ); + debug_assert!( + self.pending_release.is_none(), + "arming over an OWED release: the drain must run before the \ + effect that arms" + ); + // Defensive in release builds: ending it parks the record for a + // later drain, which is late but not lost. Overwriting would + // discard it outright and leave the child holding a button. self.cancel_accepted_gesture(); self.accepted_gesture = Some(gesture); } From 70d531cb7d3e1600956c4d5f8ec2bcd7e0e52ab3 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 21 Aug 2026 10:46:43 +0200 Subject: [PATCH 26/33] docs(panel): drop a doubled P12 rustdoc line The Q6 rewrite spliced its replacement text ending with P12's heading while the slice it replaced began with the same line, concatenating both copies onto one line. Neither cargo fmt nor clippy reflows doc comments, so nothing downstream noticed. Swept the rest of the crate for the same shape; the only other hit is a `file:///` URI inside a legitimate doc line. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- src/daemon.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/daemon.rs b/src/daemon.rs index 17d2b97..83dddd0 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -8793,7 +8793,7 @@ mod tests { ); } - /// P12 — a panel WIDER THAN 512 COLUMNS still routes pointer input. /// P12 — a panel WIDER THAN 512 COLUMNS still routes pointer input. + /// P12 — a panel WIDER THAN 512 COLUMNS still routes pointer input. /// /// A panel deliberately does not inherit the terminal's per-axis PTY /// caps (Bet B5'): a 4K surface at a small font is legitimately From e2b080f6aeaa244be4319732ac988cc7aaa353a8 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 21 Aug 2026 11:06:44 +0200 Subject: [PATCH 27/33] feat(panel): the authority-loss matrix --- four transitions gain their effect Task 19. SS5b wired Absent and left the other four transitions armed, inert while nothing consumed the latch and defects the moment cancellation gained an effect. Three are visible in the producer, where the declaration is built: the side WINDOW replaced, its BUFFER replaced, and the GEOMETRY epoch moved. The last needed a retained geometry_epoch on PanelPresentation, because nothing else the producer holds moves with a same-size geometry change --- not the panel epoch, not the identity, and on a legacy peer not a mapping key either --- so the transition was invisible and a live gesture survived a grid it no longer belonged to. DETACH cancels in the dispatcher, before any teardown, because it is the transition with no later opportunity of any kind. The release follows the RECORDED domain, so a buffer replacement pays the child the gesture was pressed on rather than whatever occupies the panel now. G5b(a)-(d), G5m and G5j, each reading the child's byte stream or the document's selection rather than the latch. The mutations discriminate exactly: dropping the window half of the identity check fails only the buffer leg, dropping the buffer half fails only the window leg, dropping the geometry check fails only that leg, and dropping detach's cancel fails only detach. G5m survives all four, and correctly --- two coincident causes take the same latch, so one release, and removing either cause still leaves one. G5j's two legs differ and the row proves it: an empty selection is cleared without moving point, while a real dragged region survives anchor-and-cursor exact. Clearing every selection fails the second leg, which is the mutation that matters --- ending a gesture is not a reason to discard what the user selected. Machine note: a foreign java build was running at 213% CPU during this work, and the wall-clock budget row composition_overhead_under_ten_percent redded twice under it, green in isolation both times. Functional rows are unaffected; the gate should wait for a quiet machine. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 26 ++- src/daemon.rs | 374 ++++++++++++++++++++++++++++++++++++++++- src/semantic_render.rs | 35 ++++ 3 files changed, 431 insertions(+), 4 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index d2717aa..1a50b2f 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -357,8 +357,30 @@ from #171 and #215 — the correction the 1b lane missed, honoured here. the real per-frontend frame loop; the unit rows call `render_frame` directly and never enter it. Recorded rather than treated as covered by its neighbours. - - **REMAINING, in order:** task 19's four stranding transitions, Q5's - acceptance-shaped row, then the full head-exact gate and the PR. + - **LANDED: the authority-loss matrix (task 19).** §5b wired `Absent` + and left the other four transitions armed — inert while nothing + consumed the latch, defects the moment cancellation gained an + effect. Three are visible in the producer where the declaration is + built (**window replacement**, **buffer replacement**, and a + **geometry-epoch change including at an unchanged size**, which + needed a retained `geometry_epoch` because nothing else the + producer holds moves with it); **detach** cancels in the dispatcher + before any teardown. + - **Witnessed: G5b(a)–(d), G5m, G5j**, each reading the CHILD'S + STREAM or the document's selection rather than the latch. The + mutations discriminate exactly: dropping the window half of the + identity check fails only the buffer leg, dropping the buffer + half fails only the window leg, dropping the geometry check fails + only that leg, and dropping detach's cancel fails only detach. + - **G5m survives all four**, correctly: two coincident causes take + the same latch, so one release, and removing either cause still + leaves one. + - **G5j has two legs and they differ**: an empty selection is + cleared without moving point, a REAL dragged region survives + anchor-and-cursor exact. Clearing every selection fails the + second. + - **REMAINING:** Q5's acceptance-shaped row, then the full head-exact + gate and the PR. - **Two test seams added for this:** an opt-in child-input tap (`start_send_tap_for_test`) and a drag-state read (`view_is_dragging_for_test`). Nothing else exposes what the child diff --git a/src/daemon.rs b/src/daemon.rs index 83dddd0..b6187c4 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -2995,8 +2995,15 @@ fn handle_dispatcher_event( } } DispatcherEvent::SessionDetached { frontend_id } => { - // Before ANY teardown: the next line drops the state that - // holds an owed release, and detach has no later chance. + // Parent 48 G5b/G5i — DETACH is the fifth authority loss, + // and the only one with no later chance at all. End the live + // gesture, then pay it, and do BOTH before any teardown: the + // next line drops the state holding the record, and + // `detach_frontend_input` below releases the terminal + // controller the release still needs. + if let Some(state) = semantic_states.get_mut(&frontend_id) { + let _ = state.cancel_accepted_gesture(); + } drain_pending_release(editor, semantic_states, frontend_id); render_states.remove(&frontend_id); semantic_states.remove(&frontend_id); @@ -8793,6 +8800,369 @@ mod tests { ); } + // ----------------------------------------------------------------- + // G5b — the common authority-loss matrix. + // + // A live gesture belongs to the presentation it was pressed on. + // Five transitions end that presentation; §5b wired `Absent` and + // left the other four armed, inert while nothing consumed the + // latch. They became defects the moment cancellation gained an + // effect, and each has its own cause even though they share one + // consequence. + // + // Every row reads the CHILD'S STREAM: the release is the effect, + // and a latch that empties without one is the exact failure §5b's + // round four named. + // ----------------------------------------------------------------- + + /// Render one frame, which is where the producer notices an + /// authority loss, and re-declare so a later gesture can be sent. + fn render_and_redeclare( + editor: &crate::editor::EditorState, + states: &mut HashMap, + fid: FrontendId, + ) { + let sem = states.get_mut(&fid).expect("projection"); + let _ = sem.render_frame(editor); + } + + /// G5b(a) — the side WINDOW is replaced: a close/reopen of the same + /// buffer takes a fresh identity, and the old gesture ends. + #[test] + fn g5b_a_a_window_replacement_cancels_and_pays() { + let fid = FrontendId(820); + let (mut editor, mut states, mut render, panel, buffer_id, epochs) = + terminal_panel_session(fid, true); + let cell = pmacs_protocol::CellCoord::new(1, 2); + let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); + let none = pmacs_protocol::Modifiers::default(); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + press, + none, + ); + assert_eq!( + child_stream(&editor), + vec![SGR_PRESS_1_2.to_vec()], + "fixture: the press reached the child" + ); + + // Close and reopen the SAME buffer: a different window, so a + // fresh panel identity. + { + let mut core = editor.core.borrow_mut(); + core.active_frontend = fid; + core.focus_window(fid, panel); + assert!(core.close_active(), "fixture: the side window closes"); + } + editor.reconcile_panel_layout(fid); + { + let mut core = editor.core.borrow_mut(); + let mut request = crate::editor_core::DisplayRequest::new(buffer_id); + request.side = Some(crate::window::Side::Bottom); + request.height = Some(4); + core.display_buffer(fid, &request) + .expect("reopen the panel"); + } + editor.reconcile_panel_layout(fid); + assert_ne!( + editor.core.borrow().side_window_for(fid).expect("reopened"), + panel, + "fixture: the successor really is a different window" + ); + + render_and_redeclare(&editor, &mut states, fid); + + assert!( + !states[&fid].has_accepted_gesture(), + "the gesture must END: it belongs to a presentation that no \ + longer exists" + ); + assert!( + states[&fid].has_pending_release() + || child_stream(&editor) == vec![SGR_RELEASE_1_2.to_vec()], + "and its release must be owed or already paid, never dropped" + ); + } + + /// G5b(b) — the side window's BUFFER is replaced. + /// + /// The release still goes to the buffer the gesture was pressed on, + /// because the domain is recorded --- delivering it to whatever is on + /// screen now would tell the wrong child to lift a button. + #[test] + fn g5b_b_a_buffer_replacement_cancels_and_pays_the_old_child() { + let fid = FrontendId(821); + let (mut editor, mut states, mut render, panel, buffer_id, epochs) = + terminal_panel_session(fid, true); + let cell = pmacs_protocol::CellCoord::new(1, 2); + let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); + let none = pmacs_protocol::Modifiers::default(); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + press, + none, + ); + let _ = child_stream(&editor); + + // Same window, different buffer. + { + let mut core = editor.core.borrow_mut(); + let replacement = core.registry.borrow_mut().create("*replacement*"); + let view = { + let registry = core.registry.clone(); + let registry = registry.borrow(); + crate::text_view::TextView::new(registry.get(replacement).expect("replacement")) + }; + let window = core.windows.get_mut(&panel).expect("panel window"); + window.buffer_id = replacement; + window.text_view = view; + } + + render_and_redeclare(&editor, &mut states, fid); + + assert!(!states[&fid].has_accepted_gesture(), "the gesture must END"); + assert!( + states[&fid].has_pending_release(), + "with its release owed to the buffer it was PRESSED on, not \ + to whatever occupies the panel now" + ); + } + + /// G5b(c) — a geometry change at an UNCHANGED cell size. + /// + /// Nothing else the producer holds moves: not the panel epoch, not + /// the identity, and on a legacy peer not a mapping key either. The + /// retained geometry epoch is the only thing that can see it. + #[test] + fn g5b_c_a_same_size_geometry_change_cancels_and_pays() { + let fid = FrontendId(822); + let (mut editor, mut states, mut render, _panel, buffer_id, epochs) = + terminal_panel_session(fid, true); + let cell = pmacs_protocol::CellCoord::new(1, 2); + let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); + let none = pmacs_protocol::Modifiers::default(); + let size_before = editor.core.borrow().panel_grid_size(fid).expect("grid"); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + press, + none, + ); + let _ = child_stream(&editor); + + // A new geometry epoch at the SAME total size --- a font or scale + // change that leaves `CellSize` identical. + editor.accept_semantic_frame_geometry(fid, 2, CellSize::new(24, 80)); + render_and_redeclare(&editor, &mut states, fid); + assert_eq!( + editor.core.borrow().panel_grid_size(fid).expect("grid"), + size_before, + "fixture: the SIZE must be unchanged, or this row is testing \ + a resize instead" + ); + + assert!( + !states[&fid].has_accepted_gesture(), + "the gesture must END: the same cells now sit on a different \ + grid, and the epochs the frontend echoes have moved under it" + ); + assert!( + states[&fid].has_pending_release(), + "and its release is owed" + ); + } + + /// G5b(d) — DETACH ends a live gesture and pays it before teardown. + /// + /// Q4 proved detach pays a release that was ALREADY owed. This one + /// starts from a live gesture, which is the case with no later + /// opportunity of any kind. + #[test] + fn g5b_d_detach_cancels_a_live_gesture_and_pays_it() { + let fid = FrontendId(823); + let (mut editor, mut states, mut render, _panel, buffer_id, epochs) = + terminal_panel_session(fid, true); + let cell = pmacs_protocol::CellCoord::new(1, 2); + let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); + let none = pmacs_protocol::Modifiers::default(); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + press, + none, + ); + assert!( + states[&fid].has_accepted_gesture(), + "fixture: the gesture is LIVE, not merely owed" + ); + let _ = child_stream(&editor); + + detach_session(&mut editor, &mut states, &mut render, fid); + + assert_eq!( + child_stream(&editor), + vec![SGR_RELEASE_1_2.to_vec()], + "detach must end the gesture AND pay it --- the frontend is \ + gone, so a release not sent here is never sent" + ); + } + + /// G5m — coincident causes cancel ONCE. + /// + /// Identity and geometry both change in one transition. Taking the + /// shared latch is what makes this one release rather than two. + #[test] + fn g5m_coincident_causes_emit_one_release() { + let fid = FrontendId(824); + let (mut editor, mut states, mut render, panel, buffer_id, epochs) = + terminal_panel_session(fid, true); + let cell = pmacs_protocol::CellCoord::new(1, 2); + let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); + let none = pmacs_protocol::Modifiers::default(); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + cell, + press, + none, + ); + let _ = child_stream(&editor); + + // BOTH at once: a replaced buffer and a moved geometry epoch. + { + let mut core = editor.core.borrow_mut(); + let replacement = core.registry.borrow_mut().create("*both*"); + let view = { + let registry = core.registry.clone(); + let registry = registry.borrow(); + crate::text_view::TextView::new(registry.get(replacement).expect("replacement")) + }; + let window = core.windows.get_mut(&panel).expect("panel window"); + window.buffer_id = replacement; + window.text_view = view; + } + editor.accept_semantic_frame_geometry(fid, 2, CellSize::new(24, 80)); + render_and_redeclare(&editor, &mut states, fid); + + assert_eq!( + states[&fid].panel_gesture_cancellations(), + 1, + "TWO causes, ONE cancellation: they take the same latch, and \ + counting per cause would emit a duplicate release" + ); + } + + /// G5j — document cancellation has two legs, and they differ. + /// + /// A press that never dragged leaves an ACTIVE BUT EMPTY selection + /// whose stale anchor would capture the next shift-motion, so it is + /// cleared. A press that DID drag leaves a real region the user + /// selected, and cancelling the gesture must not take it away. + #[test] + fn g5j_document_cancellation_clears_only_an_empty_selection() { + for dragged in [false, true] { + let fid = FrontendId(if dragged { 826 } else { 825 }); + let (mut editor, mut states, mut render, _document, panel, epochs) = + panel_session_at(LEGACY_PANEL_VERSION, fid); + let buffer_id = editor.core.borrow().windows[&panel].buffer_id; + foreign_edit(&editor, buffer_id, b"alpha beta gamma\ndelta\n"); + let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); + let drag = pmacs_protocol::MouseKind::Drag(pmacs_protocol::MouseButton::Left); + let none = pmacs_protocol::Modifiers::default(); + + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + pmacs_protocol::CellCoord::new(0, 0), + press, + none, + ); + if dragged { + send_panel( + &mut editor, + &mut states, + &mut render, + fid, + epochs, + buffer_id, + pmacs_protocol::CellCoord::new(0, 5), + drag, + none, + ); + } + let before = { + let core = editor.core.borrow(); + let window = &core.windows[&panel]; + (window.selection, window.cursor) + }; + assert!( + before.0.is_some(), + "fixture: a selection is anchored either way" + ); + + editor.accept_semantic_frame_geometry(fid, 2, CellSize::new(24, 80)); + render_and_redeclare(&editor, &mut states, fid); + drain_pending_release(&mut editor, &mut states, fid); + + let after = { + let core = editor.core.borrow(); + let window = &core.windows[&panel]; + (window.selection, window.cursor) + }; + if dragged { + assert_eq!( + after, before, + "a REAL region survives cancellation, anchor and \ + cursor exactly --- the user selected it, and ending \ + the gesture is not a reason to discard it" + ); + } else { + assert!( + after.0.is_none(), + "an EMPTY selection is cleared, or its stale anchor \ + captures the next shift-motion" + ); + assert_eq!(after.1, before.1, "and clearing it does not move point"); + } + } + } + /// P12 — a panel WIDER THAN 512 COLUMNS still routes pointer input. /// /// A panel deliberately does not inherit the terminal's per-axis PTY diff --git a/src/semantic_render.rs b/src/semantic_render.rs index e3cb9cb..f7f7f81 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -532,6 +532,15 @@ struct PanelPresentation { window_id: WindowId, buffer_id: BufferId, panel_epoch: u64, + /// The geometry epoch this presentation was shipped under. + /// + /// Retained for parent 48's authority-loss matrix: a geometry + /// change at an UNCHANGED `CellSize` moves nothing else the + /// producer holds — not the panel epoch, not the identity, and on a + /// legacy peer not a mapping key either — so without this the + /// transition is invisible and a live gesture survives a grid it no + /// longer belongs to. + geometry_epoch: u64, } /// One [`SemanticRenderState::diag_line_cache`] entry: the line-start @@ -1720,6 +1729,31 @@ impl SemanticRenderState { return; }; let identity = (projection.window_id, projection.buffer_id); + + // Parent 48 G5b — AUTHORITY LOSS. A live gesture belongs to the + // presentation it was pressed on. Three of the matrix's five + // transitions are visible right here, and each is a separate + // cause with one shared consequence: + // + // * the side WINDOW was replaced (panel-epoch change), + // * its BUFFER was replaced, + // * the GEOMETRY epoch moved, including at an unchanged size. + // + // §5b left these armed deliberately — inert while nothing + // consumed the latch — and they became defects the moment this + // lane gave cancellation an effect. Taking the latch parks the + // release; the drain pays it. + // + // G5m: coincident causes take the latch ONCE. `cancel_accepted_gesture` + // is idempotent on an empty latch, so a transition that trips + // two of these conditions still emits one release. + if let Some(presentation) = self.panel_presentation + && ((presentation.window_id, presentation.buffer_id) != identity + || presentation.geometry_epoch != geometry.geometry_epoch) + { + self.cancel_accepted_gesture(); + } + let panel_epoch = match self.panel_presentation { Some(presentation) if (presentation.window_id, presentation.buffer_id) == identity => { Some(presentation.panel_epoch) @@ -1803,6 +1837,7 @@ impl SemanticRenderState { window_id: projection.window_id, buffer_id: projection.buffer_id, panel_epoch, + geometry_epoch: geometry.geometry_epoch, }); self.last_panel_payload = Some(payload.clone()); out.push(InstanceMessage::PanelFrame(payload)); From c37c066786b8b94150d57285b924fb1754889400 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 21 Aug 2026 11:43:27 +0200 Subject: [PATCH 28/33] test(panel): the authority-loss matrix, asserted as EFFECTS across every quadrant Answers review of e2b080f. The transition detection was sound; the rows proving it were not. G5b(a)-(c) and G5m stopped at has_pending_release() or a cancellation count. Both pass while delivery or recorded-domain routing is broken, and the mutation that parks a release and never delivers it proves it: it now fails all three rows and previously failed none. Every quadrant drains explicitly and asserts the effect --- exact release bytes for a reporting terminal, the cleared empty selection for a document, and an empty slot afterwards. The matrix is now the table the framing asks for: four transitions x two families x two targets, sixteen quadrants. All four earlier rows used the legacy terminal fixture, so the mapped legs and every document leg were simply absent. G5m takes both composites the framing names --- changed-size geometry, which moves the epoch AND the mapping generation, and a buffer replacement that also moves the mapping --- rather than the one wrong-shaped combination it had. One quadrant asserts less and says so: for window replacement on a document the window the gesture belonged to is gone, so the completion has nothing left to clear and the ending is the whole effect. Written into the row, because a silently absent assertion is how a quadrant stops testing anything. Two fixture facts the failures taught: the document legs press at row 0 because foreign_edit replaces buffer contents without refreshing the window's cached line index, so only the first display row resolves to a byte and a press that anchors nothing does not arm; and cargo check --lib does not compile #[cfg(test)] code, which is why a missing test helper passed a check and failed the build. Corrects two records. The ledger had the mutation labels reversed --- dropping the BUFFER comparison misses the buffer transition, not the window one. And semantic_render still told readers these transitions were left armed on this branch, which was true of SS5b and false here since the matrix landed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 32 ++- src/daemon.rs | 545 ++++++++++++++++++++++------------------- src/semantic_render.rs | 14 +- 3 files changed, 327 insertions(+), 264 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 1a50b2f..2c0ac1c 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -366,19 +366,33 @@ from #171 and #215 — the correction the 1b lane missed, honoured here. needed a retained `geometry_epoch` because nothing else the producer holds moves with it); **detach** cancels in the dispatcher before any teardown. - - **Witnessed: G5b(a)–(d), G5m, G5j**, each reading the CHILD'S - STREAM or the document's selection rather than the latch. The - mutations discriminate exactly: dropping the window half of the - identity check fails only the buffer leg, dropping the buffer - half fails only the window leg, dropping the geometry check fails - only that leg, and dropping detach's cancel fails only detach. - - **G5m survives all four**, correctly: two coincident causes take - the same latch, so one release, and removing either cause still - leaves one. + - **Witnessed by a TABLE-DRIVEN matrix**: four transitions × two + families × two targets, sixteen quadrants, each **draining + explicitly** and asserting the effect — the exact release bytes + for a reporting terminal, the cleared empty selection for a + document, and an empty slot afterwards. An earlier version + stopped at `has_pending_release()` and would have passed while + delivery was broken; the mutation that parks a release and never + delivers it now fails every row. + - **Mutation labels, stated correctly this time**: dropping the + BUFFER comparison misses the buffer transition; dropping the + WINDOW comparison misses the window transition; dropping the + geometry comparison misses that one; dropping detach's cancel + misses detach. Each fails the matrix. + - **G5m takes both composites the framing names** — changed-size + geometry (epoch **and** mapping generation) and a buffer + replacement that also moves the mapping — and reads the child's + stream rather than a cancellation count, because a count of one + proves the latch was taken once, not that one release went out. - **G5j has two legs and they differ**: an empty selection is cleared without moving point, a REAL dragged region survives anchor-and-cursor exact. Clearing every selection fails the second. + - **One quadrant asserts less, and says so**: for window + replacement on a document, the window the gesture belonged to is + gone, so the completion has nothing left to clear and the + gesture ending is the whole effect. Written into the row rather + than left as a silently absent assertion. - **REMAINING:** Q5's acceptance-shaped row, then the full head-exact gate and the PR. - **Two test seams added for this:** an opt-in child-input tap diff --git a/src/daemon.rs b/src/daemon.rs index b6187c4..3b750a1 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -7948,10 +7948,23 @@ mod tests { ); fn terminal_panel_session(fid: FrontendId, reporting: bool) -> TerminalPanelFixture { + terminal_panel_session_at(fid, reporting, LEGACY_PANEL_VERSION) + } + + /// The same fixture at a chosen negotiated version. + /// + /// G5b's matrix runs every transition across BOTH families, so the + /// version has to be a parameter rather than the legacy default the + /// earlier rows could assume. + fn terminal_panel_session_at( + fid: FrontendId, + reporting: bool, + version: u32, + ) -> TerminalPanelFixture { use crate::terminal::TerminalSpec; let (editor, mut states, render, _document, panel, _epochs) = - panel_session_at(LEGACY_PANEL_VERSION, fid); + panel_session_at(version, fid); let mut spec = TerminalSpec::new("/bin/sh"); spec.args = vec!["-c".into(), "sleep 30".into()]; spec.rows = 4; @@ -8801,61 +8814,109 @@ mod tests { } // ----------------------------------------------------------------- - // G5b — the common authority-loss matrix. + // G5b — the common authority-loss matrix, table-driven. // - // A live gesture belongs to the presentation it was pressed on. - // Five transitions end that presentation; §5b wired `Absent` and - // left the other four armed, inert while nothing consumed the - // latch. They became defects the moment cancellation gained an - // effect, and each has its own cause even though they share one - // consequence. + // Four transitions x two families x two targets. §5b wired `Absent` + // and left these armed --- inert while nothing consumed the latch, + // defects the moment cancellation gained an effect. // - // Every row reads the CHILD'S STREAM: the release is the effect, - // and a latch that empties without one is the exact failure §5b's - // round four named. + // Every quadrant asserts the EFFECT, drained explicitly: the exact + // release bytes for a reporting terminal, the cleared empty + // selection for a document, and an empty pending slot afterwards. + // Stopping at `has_pending_release()` would pass while delivery or + // recorded-domain routing was broken, which is what an earlier + // version of these rows did. // ----------------------------------------------------------------- - /// Render one frame, which is where the producer notices an - /// authority loss, and re-declare so a later gesture can be sent. - fn render_and_redeclare( - editor: &crate::editor::EditorState, - states: &mut HashMap, - fid: FrontendId, - ) { - let sem = states.get_mut(&fid).expect("projection"); - let _ = sem.render_frame(editor); + /// Which authority loss a matrix row drives. + #[derive(Clone, Copy, Debug)] + enum LossCause { + WindowReplaced, + BufferReplaced, + GeometrySameSize, + Detach, } - /// G5b(a) — the side WINDOW is replaced: a close/reopen of the same - /// buffer takes a fresh identity, and the old gesture ends. - #[test] - fn g5b_a_a_window_replacement_cancels_and_pays() { - let fid = FrontendId(820); - let (mut editor, mut states, mut render, panel, buffer_id, epochs) = - terminal_panel_session(fid, true); - let cell = pmacs_protocol::CellCoord::new(1, 2); + /// Which target the gesture was pressed on. + #[derive(Clone, Copy, Debug)] + enum LossTarget { + Document, + ReportingTerminal, + } + + /// A panel session of the requested target kind and family. + fn loss_fixture(fid: FrontendId, version: u32, target: LossTarget) -> TerminalPanelFixture { + match target { + LossTarget::ReportingTerminal => terminal_panel_session_at(fid, true, version), + LossTarget::Document => { + let (editor, mut states, render, _document, panel, _epochs) = + panel_session_at(version, fid); + let buffer_id = editor.core.borrow().windows[&panel].buffer_id; + // A press must ANCHOR, or the row never arms. + foreign_edit(&editor, buffer_id, b"alpha beta gamma\ndelta\n"); + let epochs = shipped_declaration(&editor, fid, &mut states); + (editor, states, render, panel, buffer_id, epochs) + } + } + } + + /// Send one press in whichever family `version` negotiated. + #[expect( + clippy::too_many_arguments, + reason = "one call shape for every matrix quadrant" + )] + fn send_press( + editor: &mut crate::editor::EditorState, + states: &mut HashMap, + render: &mut HashMap, + fid: FrontendId, + version: u32, + epochs: (u64, u64), + buffer_id: crate::buffer::BufferId, + coord: pmacs_protocol::CellCoord, + ) { let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); let none = pmacs_protocol::Modifiers::default(); + if version >= pmacs_protocol::PANEL_MAPPING_MIN_VERSION { + let generation = live_generation(PanelArm::Mapped, editor, states, fid); + let event = arm_pointer( + PanelArm::Mapped, + fid, + epochs, + buffer_id, + generation, + coord, + press, + ); + dispatch_panel_event(editor, fid, version, states, render, event); + } else { + send_panel( + editor, states, render, fid, epochs, buffer_id, coord, press, none, + ); + } + } - send_panel( - &mut editor, - &mut states, - &mut render, - fid, - epochs, - buffer_id, - cell, - press, - none, - ); - assert_eq!( - child_stream(&editor), - vec![SGR_PRESS_1_2.to_vec()], - "fixture: the press reached the child" - ); + /// Replace the side window's buffer, keeping the window. + fn replace_panel_buffer(editor: &crate::editor::EditorState, panel: crate::window::WindowId) { + let mut core = editor.core.borrow_mut(); + let replacement = core.registry.borrow_mut().create("*replacement*"); + let view = { + let registry = core.registry.clone(); + let registry = registry.borrow(); + crate::text_view::TextView::new(registry.get(replacement).expect("replacement")) + }; + let window = core.windows.get_mut(&panel).expect("panel window"); + window.buffer_id = replacement; + window.text_view = view; + } - // Close and reopen the SAME buffer: a different window, so a - // fresh panel identity. + /// Close and reopen the panel on the same buffer: a new window. + fn replace_panel_window( + editor: &crate::editor::EditorState, + fid: FrontendId, + panel: crate::window::WindowId, + buffer_id: crate::buffer::BufferId, + ) { { let mut core = editor.core.borrow_mut(); core.active_frontend = fid; @@ -8877,211 +8938,194 @@ mod tests { panel, "fixture: the successor really is a different window" ); - - render_and_redeclare(&editor, &mut states, fid); - - assert!( - !states[&fid].has_accepted_gesture(), - "the gesture must END: it belongs to a presentation that no \ - longer exists" - ); - assert!( - states[&fid].has_pending_release() - || child_stream(&editor) == vec![SGR_RELEASE_1_2.to_vec()], - "and its release must be owed or already paid, never dropped" - ); } - /// G5b(b) — the side window's BUFFER is replaced. - /// - /// The release still goes to the buffer the gesture was pressed on, - /// because the domain is recorded --- delivering it to whatever is on - /// screen now would tell the wrong child to lift a button. + /// G5b — every transition, every family, every target. + #[expect( + clippy::too_many_lines, + reason = "one table whose quadrants read together; splitting it \ + hides which combinations are covered" + )] #[test] - fn g5b_b_a_buffer_replacement_cancels_and_pays_the_old_child() { - let fid = FrontendId(821); - let (mut editor, mut states, mut render, panel, buffer_id, epochs) = - terminal_panel_session(fid, true); - let cell = pmacs_protocol::CellCoord::new(1, 2); - let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); - let none = pmacs_protocol::Modifiers::default(); + fn g5b_the_authority_loss_matrix() { + use LossCause::{BufferReplaced, Detach, GeometrySameSize, WindowReplaced}; + use LossTarget::{Document, ReportingTerminal}; - send_panel( - &mut editor, - &mut states, - &mut render, - fid, - epochs, - buffer_id, - cell, - press, - none, - ); - let _ = child_stream(&editor); + let mut next_fid = 830u64; + for cause in [WindowReplaced, BufferReplaced, GeometrySameSize, Detach] { + for target in [Document, ReportingTerminal] { + for version in [LEGACY_PANEL_VERSION, PROTOCOL_VERSION] { + let fid = FrontendId(next_fid); + next_fid += 1; + let label = format!("{cause:?}/{target:?}/v{version}"); - // Same window, different buffer. - { - let mut core = editor.core.borrow_mut(); - let replacement = core.registry.borrow_mut().create("*replacement*"); - let view = { - let registry = core.registry.clone(); - let registry = registry.borrow(); - crate::text_view::TextView::new(registry.get(replacement).expect("replacement")) - }; - let window = core.windows.get_mut(&panel).expect("panel window"); - window.buffer_id = replacement; - window.text_view = view; + let (mut editor, mut states, mut render, panel, buffer_id, epochs) = + loss_fixture(fid, version, target); + // The terminal legs use (1, 2), whose exact SGR + // bytes the constants above pin. A document panel + // uses row 0: `foreign_edit` replaces the buffer's + // contents without refreshing the window's cached + // line index, so only the first display row resolves + // to a byte --- and a press that anchors nothing does + // not arm, which would make every document leg + // vacuous. + let cell = match target { + ReportingTerminal => pmacs_protocol::CellCoord::new(1, 2), + Document => pmacs_protocol::CellCoord::new(0, 2), + }; + + send_press( + &mut editor, + &mut states, + &mut render, + fid, + version, + epochs, + buffer_id, + cell, + ); + assert!( + states[&fid].has_accepted_gesture(), + "{label}: fixture --- the press must arm, or the row \ + proves nothing" + ); + if matches!(target, ReportingTerminal) { + assert_eq!( + child_stream(&editor), + vec![SGR_PRESS_1_2.to_vec()], + "{label}: fixture --- the press reached the child" + ); + } + let selection_before = editor.core.borrow().windows[&panel].selection; + + match cause { + WindowReplaced => { + replace_panel_window(&editor, fid, panel, buffer_id); + render_and_redeclare(&editor, &mut states, fid); + } + BufferReplaced => { + replace_panel_buffer(&editor, panel); + render_and_redeclare(&editor, &mut states, fid); + } + GeometrySameSize => { + editor.accept_semantic_frame_geometry(fid, 2, CellSize::new(24, 80)); + render_and_redeclare(&editor, &mut states, fid); + } + Detach => { + detach_session(&mut editor, &mut states, &mut render, fid); + } + } + // Detach drains inside its own teardown; the producer + // transitions park and are paid at the next drain. + if !matches!(cause, Detach) { + assert!( + !states[&fid].has_accepted_gesture(), + "{label}: the gesture must END --- it belongs to \ + a presentation that no longer exists" + ); + drain_pending_release(&mut editor, &mut states, fid); + assert!( + !states[&fid].has_pending_release(), + "{label}: and the slot must be empty afterwards" + ); + } + + match target { + ReportingTerminal => assert_eq!( + child_stream(&editor), + vec![SGR_RELEASE_1_2.to_vec()], + "{label}: EXACTLY the recorded release, to the \ + child the gesture was pressed on --- routing it \ + by what occupies the panel now would tell the \ + wrong child to lift a button" + ), + Document => { + assert!( + selection_before.is_some(), + "{label}: fixture --- the press anchored an \ + empty selection for the cancellation to clear" + ); + let core = editor.core.borrow(); + match core.windows.get(&panel) { + Some(window) => assert!( + window.selection.is_none(), + "{label}: the empty selection must be \ + cleared, or its stale anchor captures \ + the next shift-motion" + ), + // WINDOW REPLACED: the window the gesture + // belonged to is gone, so the completion + // has nothing left to clear and the + // gesture ENDING is the whole of the + // effect --- already asserted above. Said + // out loud rather than skipped, because a + // silently absent assertion is how a + // quadrant stops testing anything. + None => assert!( + matches!(cause, WindowReplaced), + "{label}: the panel window vanished for \ + a cause that should not remove it" + ), + } + } + } + } + } } - - render_and_redeclare(&editor, &mut states, fid); - - assert!(!states[&fid].has_accepted_gesture(), "the gesture must END"); - assert!( - states[&fid].has_pending_release(), - "with its release owed to the buffer it was PRESSED on, not \ - to whatever occupies the panel now" - ); } - /// G5b(c) — a geometry change at an UNCHANGED cell size. + /// G5m — coincident invalidations produce ONE completion effect. /// - /// Nothing else the producer holds moves: not the panel epoch, not - /// the identity, and on a legacy peer not a mapping key either. The - /// retained geometry epoch is the only thing that can see it. + /// Both composites the framing names, and each reads the effect + /// rather than the cancellation count: a count of one proves the + /// latch was taken once, not that exactly one release went out. #[test] - fn g5b_c_a_same_size_geometry_change_cancels_and_pays() { - let fid = FrontendId(822); - let (mut editor, mut states, mut render, _panel, buffer_id, epochs) = - terminal_panel_session(fid, true); - let cell = pmacs_protocol::CellCoord::new(1, 2); - let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); - let none = pmacs_protocol::Modifiers::default(); - let size_before = editor.core.borrow().panel_grid_size(fid).expect("grid"); + fn g5m_coincident_invalidations_emit_one_release() { + // (a) changed-size geometry, which moves the geometry epoch AND + // the mapping generation; (b) buffer replacement, which + // moves the identity AND the mapping. + for (label, changed_size) in [("changed-size geometry", true), ("buffer replaced", false)] { + let fid = FrontendId(if changed_size { 850 } else { 851 }); + let (mut editor, mut states, mut render, panel, buffer_id, epochs) = + terminal_panel_session_at(fid, true, PROTOCOL_VERSION); + let cell = pmacs_protocol::CellCoord::new(1, 2); - send_panel( - &mut editor, - &mut states, - &mut render, - fid, - epochs, - buffer_id, - cell, - press, - none, - ); - let _ = child_stream(&editor); + send_press( + &mut editor, + &mut states, + &mut render, + fid, + PROTOCOL_VERSION, + epochs, + buffer_id, + cell, + ); + assert_eq!( + child_stream(&editor), + vec![SGR_PRESS_1_2.to_vec()], + "{label}: fixture --- the press reached the child" + ); - // A new geometry epoch at the SAME total size --- a font or scale - // change that leaves `CellSize` identical. - editor.accept_semantic_frame_geometry(fid, 2, CellSize::new(24, 80)); - render_and_redeclare(&editor, &mut states, fid); - assert_eq!( - editor.core.borrow().panel_grid_size(fid).expect("grid"), - size_before, - "fixture: the SIZE must be unchanged, or this row is testing \ - a resize instead" - ); + if changed_size { + editor.accept_semantic_frame_geometry(fid, 2, CellSize::new(20, 60)); + } else { + replace_panel_buffer(&editor, panel); + } + render_and_redeclare(&editor, &mut states, fid); + drain_pending_release(&mut editor, &mut states, fid); - assert!( - !states[&fid].has_accepted_gesture(), - "the gesture must END: the same cells now sit on a different \ - grid, and the epochs the frontend echoes have moved under it" - ); - assert!( - states[&fid].has_pending_release(), - "and its release is owed" - ); - } - - /// G5b(d) — DETACH ends a live gesture and pays it before teardown. - /// - /// Q4 proved detach pays a release that was ALREADY owed. This one - /// starts from a live gesture, which is the case with no later - /// opportunity of any kind. - #[test] - fn g5b_d_detach_cancels_a_live_gesture_and_pays_it() { - let fid = FrontendId(823); - let (mut editor, mut states, mut render, _panel, buffer_id, epochs) = - terminal_panel_session(fid, true); - let cell = pmacs_protocol::CellCoord::new(1, 2); - let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); - let none = pmacs_protocol::Modifiers::default(); - - send_panel( - &mut editor, - &mut states, - &mut render, - fid, - epochs, - buffer_id, - cell, - press, - none, - ); - assert!( - states[&fid].has_accepted_gesture(), - "fixture: the gesture is LIVE, not merely owed" - ); - let _ = child_stream(&editor); - - detach_session(&mut editor, &mut states, &mut render, fid); - - assert_eq!( - child_stream(&editor), - vec![SGR_RELEASE_1_2.to_vec()], - "detach must end the gesture AND pay it --- the frontend is \ - gone, so a release not sent here is never sent" - ); - } - - /// G5m — coincident causes cancel ONCE. - /// - /// Identity and geometry both change in one transition. Taking the - /// shared latch is what makes this one release rather than two. - #[test] - fn g5m_coincident_causes_emit_one_release() { - let fid = FrontendId(824); - let (mut editor, mut states, mut render, panel, buffer_id, epochs) = - terminal_panel_session(fid, true); - let cell = pmacs_protocol::CellCoord::new(1, 2); - let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); - let none = pmacs_protocol::Modifiers::default(); - - send_panel( - &mut editor, - &mut states, - &mut render, - fid, - epochs, - buffer_id, - cell, - press, - none, - ); - let _ = child_stream(&editor); - - // BOTH at once: a replaced buffer and a moved geometry epoch. - { - let mut core = editor.core.borrow_mut(); - let replacement = core.registry.borrow_mut().create("*both*"); - let view = { - let registry = core.registry.clone(); - let registry = registry.borrow(); - crate::text_view::TextView::new(registry.get(replacement).expect("replacement")) - }; - let window = core.windows.get_mut(&panel).expect("panel window"); - window.buffer_id = replacement; - window.text_view = view; + assert_eq!( + child_stream(&editor), + vec![SGR_RELEASE_1_2.to_vec()], + "{label}: TWO causes, ONE release --- they take the same \ + latch, and a per-cause flag would send the child two \ + releases for one press" + ); + assert!( + !states[&fid].has_pending_release(), + "{label}: with nothing left owed" + ); } - editor.accept_semantic_frame_geometry(fid, 2, CellSize::new(24, 80)); - render_and_redeclare(&editor, &mut states, fid); - - assert_eq!( - states[&fid].panel_gesture_cancellations(), - 1, - "TWO causes, ONE cancellation: they take the same latch, and \ - counting per cause would emit a duplicate release" - ); } /// G5j — document cancellation has two legs, and they differ. @@ -9093,25 +9137,21 @@ mod tests { #[test] fn g5j_document_cancellation_clears_only_an_empty_selection() { for dragged in [false, true] { - let fid = FrontendId(if dragged { 826 } else { 825 }); - let (mut editor, mut states, mut render, _document, panel, epochs) = - panel_session_at(LEGACY_PANEL_VERSION, fid); - let buffer_id = editor.core.borrow().windows[&panel].buffer_id; - foreign_edit(&editor, buffer_id, b"alpha beta gamma\ndelta\n"); - let press = pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left); + let fid = FrontendId(if dragged { 861 } else { 860 }); + let (mut editor, mut states, mut render, panel, buffer_id, epochs) = + loss_fixture(fid, LEGACY_PANEL_VERSION, LossTarget::Document); let drag = pmacs_protocol::MouseKind::Drag(pmacs_protocol::MouseButton::Left); let none = pmacs_protocol::Modifiers::default(); - send_panel( + send_press( &mut editor, &mut states, &mut render, fid, + LEGACY_PANEL_VERSION, epochs, buffer_id, pmacs_protocol::CellCoord::new(0, 0), - press, - none, ); if dragged { send_panel( @@ -9163,7 +9203,18 @@ mod tests { } } - /// P12 — a panel WIDER THAN 512 COLUMNS still routes pointer input. + /// Render one frame, which is where the producer notices an + /// authority loss. + fn render_and_redeclare( + editor: &crate::editor::EditorState, + states: &mut HashMap, + fid: FrontendId, + ) { + let sem = states.get_mut(&fid).expect("projection"); + let _ = sem.render_frame(editor); + } + + /// P12 — a panel WIDER THAN 512 COLUMNS still routes pointer input. /// P12 — a panel WIDER THAN 512 COLUMNS still routes pointer input. /// /// A panel deliberately does not inherit the terminal's per-axis PTY /// caps (Bet B5'): a 4K surface at a small font is legitimately diff --git a/src/semantic_render.rs b/src/semantic_render.rs index f7f7f81..2484002 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -1876,14 +1876,12 @@ impl SemanticRenderState { // wire work but must still leave no live gesture behind — the // clears below are idempotent for the same reason. // - // Only the producer half lands here. The framing's G5b matrix — - // the panel-epoch, buffer-replacement, same-size geometry and - // detach transitions, each with its v24 and v25 legs and its - // document and terminal effects — is `panel-pointer-replay`'s per - // §5b's split table. Those transitions leave the latch armed on - // this branch, which is inert here (nothing consumes it) and - // becomes a defect only once replay gives it effects, in the - // branch that owns the row. + // `Absent` is one of G5b's five transitions. THE OTHER FOUR ARE + // NOW WIRED TOO, on this branch: window replacement, buffer + // replacement and a geometry-epoch change are handled where the + // declaration is built above, and detach in the dispatcher's + // teardown. This comment used to say they were left armed — true + // of §5b, false here since the authority-loss matrix landed. self.cancel_accepted_gesture(); self.panel_presentation = None; // `Absent` also clears the peer's retained mode line. A later From 70999e22acfe21f38ac69f5ee42aee7763d05272 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 21 Aug 2026 12:15:56 +0200 Subject: [PATCH 29/33] test(panel): Absent joins the matrix, and G5m proves its coincidence Answers review of c37c066. ABSENT WAS MISSING. The approved G5b table has five transitions, not four, so the matrix was 16 quadrants where it should be 20. Added, and it earns its place twice: as the fifth row, and as a CONTROL on SS5b's own cancellation --- removing that cancel now fails the Absent quadrants while the four this lane added still pass. The quadrant count is asserted in the row. A loop that quietly stops covering a combination passes exactly as loudly as one that covers them all, which is the same read-success-from-absence shape as a test filter that matches nothing. G5m ASSUMED its composites. Both scenarios asserted one release without ever checking the mapping moved, so either would pass as a single-cause transition wearing a composite's name. It now peeks the generation before and asserts it advanced after --- peeked, because reading through the authoritative accessor would advance the key and manufacture the very second cause the row is meant to observe. The assertion bites: a same-size geometry change does NOT move the mapping, and substituting one fails the fixture. The doubled P12 rustdoc line is back and removed again. I wrote the note about this exact seam after the first occurrence and then spliced through it a second time. The crate-wide sweep is now a real check rather than a grep I improvised: every `///` line containing a second `///` that is not a URL. Machine note: a foreign C++ build was running at load 114 during the final verification, and three wall-clock rows redded under it --- m6_2_pty_streaming_respects_byte_ceiling, composition_overhead_under_ten_percent and full_buffer_summary_flatten_scales_on_large_grammar_file --- each green in isolation. The gate still wants a quiet machine. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 15 +++++++++++++-- src/daemon.rs | 47 +++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 2c0ac1c..0b409fe 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -366,8 +366,13 @@ from #171 and #215 — the correction the 1b lane missed, honoured here. needed a retained `geometry_epoch` because nothing else the producer holds moves with it); **detach** cancels in the dispatcher before any teardown. - - **Witnessed by a TABLE-DRIVEN matrix**: four transitions × two - families × two targets, sixteen quadrants, each **draining + - **Witnessed by a TABLE-DRIVEN matrix**: **five** transitions × + two families × two targets, **twenty quadrants** — `Absent` + included, both as the fifth row and as a CONTROL on §5b's own + cancellation, which a mutation confirms it catches. The count is + asserted in the row, because a loop that quietly stops covering a + combination passes exactly as loudly as one that covers them all. + Each quadrant **drains explicitly** and asserting the effect — the exact release bytes for a reporting terminal, the cleared empty selection for a document, and an empty slot afterwards. An earlier version @@ -384,6 +389,12 @@ from #171 and #215 — the correction the 1b lane missed, honoured here. replacement that also moves the mapping — and reads the child's stream rather than a cancellation count, because a count of one proves the latch was taken once, not that one release went out. + It also **asserts the mapping generation actually advances**: + without that, a same-size geometry change passes as a + "composite" while being a single cause, and the row would prove + nothing about coincidence. Peeked rather than read through the + authoritative accessor, which would advance the key and + manufacture the second cause. - **G5j has two legs and they differ**: an empty selection is cleared without moving point, a REAL dragged region survives anchor-and-cursor exact. Clearing every selection fails the diff --git a/src/daemon.rs b/src/daemon.rs index 3b750a1..5df32d7 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -8831,6 +8831,12 @@ mod tests { /// Which authority loss a matrix row drives. #[derive(Clone, Copy, Debug)] enum LossCause { + /// The panel stops being presentable at all. + /// + /// §5b wired this one, so it is the matrix's CONTROL as much as + /// its fifth row: if the four this lane added regressed while + /// `Absent` still worked, the matrix would say so. + Absent, WindowReplaced, BufferReplaced, GeometrySameSize, @@ -8948,16 +8954,24 @@ mod tests { )] #[test] fn g5b_the_authority_loss_matrix() { - use LossCause::{BufferReplaced, Detach, GeometrySameSize, WindowReplaced}; + use LossCause::{Absent, BufferReplaced, Detach, GeometrySameSize, WindowReplaced}; use LossTarget::{Document, ReportingTerminal}; let mut next_fid = 830u64; - for cause in [WindowReplaced, BufferReplaced, GeometrySameSize, Detach] { + let mut quadrants = 0usize; + for cause in [ + Absent, + WindowReplaced, + BufferReplaced, + GeometrySameSize, + Detach, + ] { for target in [Document, ReportingTerminal] { for version in [LEGACY_PANEL_VERSION, PROTOCOL_VERSION] { let fid = FrontendId(next_fid); next_fid += 1; let label = format!("{cause:?}/{target:?}/v{version}"); + quadrants += 1; let (mut editor, mut states, mut render, panel, buffer_id, epochs) = loss_fixture(fid, version, target); @@ -8999,6 +9013,10 @@ mod tests { let selection_before = editor.core.borrow().windows[&panel].selection; match cause { + Absent => { + editor.hide_panel_for_test(fid); + render_and_redeclare(&editor, &mut states, fid); + } WindowReplaced => { replace_panel_window(&editor, fid, panel, buffer_id); render_and_redeclare(&editor, &mut states, fid); @@ -9064,7 +9082,9 @@ mod tests { None => assert!( matches!(cause, WindowReplaced), "{label}: the panel window vanished for \ - a cause that should not remove it" + a cause that should not remove it --- \ + `Absent` hides the panel and leaves the \ + window, so only a replacement may land here" ), } } @@ -9072,6 +9092,12 @@ mod tests { } } } + assert_eq!( + quadrants, 20, + "FIVE transitions x two families x two targets. Asserted \ + because a loop that silently stops covering a combination \ + passes exactly as loudly as one that covers them all" + ); } /// G5m — coincident invalidations produce ONE completion effect. @@ -9106,12 +9132,25 @@ mod tests { "{label}: fixture --- the press reached the child" ); + // The composite is only a composite if the MAPPING moves + // too. Peeked, not read through the authoritative accessor, + // which would advance the key and manufacture the very + // second cause this row is supposed to observe. + let mapping_before = stamped_generation(&states, fid); if changed_size { editor.accept_semantic_frame_geometry(fid, 2, CellSize::new(20, 60)); } else { replace_panel_buffer(&editor, panel); } render_and_redeclare(&editor, &mut states, fid); + assert_ne!( + stamped_generation(&states, fid), + mapping_before, + "{label}: fixture --- the mapping generation must actually \ + ADVANCE, or this is a single-cause transition wearing a \ + composite's name and the row proves nothing about \ + coincidence" + ); drain_pending_release(&mut editor, &mut states, fid); assert_eq!( @@ -9214,7 +9253,7 @@ mod tests { let _ = sem.render_frame(editor); } - /// P12 — a panel WIDER THAN 512 COLUMNS still routes pointer input. /// P12 — a panel WIDER THAN 512 COLUMNS still routes pointer input. + /// P12 — a panel WIDER THAN 512 COLUMNS still routes pointer input. /// /// A panel deliberately does not inherit the terminal's per-axis PTY /// caps (Bet B5'): a 4K surface at a small font is legitimately From 85946bf9b6335f98fb99e34a7366a6b0ca48d17d Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 21 Aug 2026 14:06:01 +0200 Subject: [PATCH 30/33] test(panel): Q5 --- the projection-seam drain, witnessed Closes the gap recorded through tasks 18 and 19. The third drain sits inside the daemon's per-frontend frame loop, which no unit row could enter, so it was written down as owed rather than assumed covered by its neighbours. The seam is now `project_semantic_frame`, extracted from that loop. It returns its messages UNWRITTEN, and that is what makes the ordering assertable: a caller holding them has by construction not sent the successor frame, so a release already delivered at that moment provably precedes it rather than merely arriving alongside it. The row arms a gesture on a reporting terminal, takes the panel away so publish_absent_panel cancels from inside projection, calls the seam, and asserts the child already has the release while the successor frame is still in the caller's hands. It bites its own drain and no other: removing the drain from the seam fails Q5 while Q1-Q4 stay green on the effect and detach drains. Grid sessions no longer reach the drain at all --- they hold no panel and no gesture --- which is tighter than the previous per-fid call. Also folds in the reported prose typo, and records the gate's precondition: a foreign C++/java build has been at load 114+ through this work, and the three wall-clock rows that redded under it were green in isolation every time. Running the gate into that would manufacture another U6/U9/U10 rotating-red incident. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 25 +++++++---- src/daemon.rs | 107 +++++++++++++++++++++++++++++++++++++++----- 2 files changed, 112 insertions(+), 20 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 0b409fe..faf37d8 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -353,10 +353,13 @@ from #171 and #215 — the correction the 1b lane missed, honoured here. and with that assert compiled out the byte-order assertion catches the same defect — which is what a release build relies on. - - **Q5 is OWED.** The projection-seam drain needs a row that drives - the real per-frontend frame loop; the unit rows call - `render_frame` directly and never enter it. Recorded rather than - treated as covered by its neighbours. + - **Q5 is CLOSED.** The projection seam is extracted as + `project_semantic_frame`, which returns its messages **unwritten** + — so a caller holding them has by construction not sent the + successor frame, and a release already delivered at that moment + provably precedes it. The row bites its own drain and no other: + removing it fails Q5 while Q1–Q4 stay green on the other two + drain points. - **LANDED: the authority-loss matrix (task 19).** §5b wired `Absent` and left the other four transitions armed — inert while nothing consumed the latch, defects the moment cancellation gained an @@ -372,8 +375,7 @@ from #171 and #215 — the correction the 1b lane missed, honoured here. cancellation, which a mutation confirms it catches. The count is asserted in the row, because a loop that quietly stops covering a combination passes exactly as loudly as one that covers them all. - Each quadrant **drains - explicitly** and asserting the effect — the exact release bytes + Each quadrant **drains explicitly and asserts the effect** — the exact release bytes for a reporting terminal, the cleared empty selection for a document, and an empty slot afterwards. An earlier version stopped at `has_pending_release()` and would have passed while @@ -404,8 +406,15 @@ from #171 and #215 — the correction the 1b lane missed, honoured here. gone, so the completion has nothing left to clear and the gesture ending is the whole effect. Written into the row rather than left as a silently absent assertion. - - **REMAINING:** Q5's acceptance-shaped row, then the full head-exact - gate and the PR. + - **REMAINING: the full head-exact gate, then the PR.** The gate + wants a QUIET machine: a foreign C++/java build has been running at + load 114+ through this work, and the wall-clock rows + (`composition_overhead_under_ten_percent`, + `m6_2_pty_streaming_respects_byte_ceiling`, + `full_buffer_summary_flatten_scales_on_large_grammar_file`) redded + under it and were green in isolation every time. That is U6/U9/U10 + territory and running the gate into it would manufacture another + rotating-red incident. - **Two test seams added for this:** an opt-in child-input tap (`start_send_tap_for_test`) and a drag-state read (`view_is_dragging_for_test`). Nothing else exposes what the child diff --git a/src/daemon.rs b/src/daemon.rs index 5df32d7..d7144fb 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1019,6 +1019,36 @@ fn panel_event_epochs_are_current( .is_some_and(|geometry| geometry.geometry_epoch == geometry_epoch) } +/// Project one SEMANTIC frontend's frame, paying any release that +/// projection itself raised before the caller can write the result +/// (parent 48, drain point three). +/// +/// **The seam exists because the cancellation happens too deep to pay +/// itself.** A mapping-generation advance cancels the live gesture +/// INSIDE `render_frame`, while the successor `PresentMapped` is still +/// being built, so "before that frame is produced" is not a place that +/// exists. This is the first place that is: projection has returned, +/// and none of what it returned has been written. +/// +/// Returning the messages UNWRITTEN is what makes the ordering +/// testable — a caller holding them has, by construction, not yet sent +/// the successor frame, so a release already delivered at that moment +/// provably precedes it. +/// +/// Grid sessions do not come here: they hold no panel and no gesture. +fn project_semantic_frame( + editor: &mut EditorState, + semantic_states: &mut HashMap, + fid: FrontendId, +) -> Vec { + let messages = semantic_states + .get_mut(&fid) + .map(|sem| sem.render_frame(editor)) + .unwrap_or_default(); + drain_pending_release(editor, semantic_states, fid); + messages +} + /// Deliver the release this frontend is owed, if any (parent 48). /// /// **Order is the ruling, not just the existence of a slot.** A @@ -1578,8 +1608,8 @@ fn dispatcher_loop( // out locally); it still receives `CursorByte` below // (semantic implies `crdt_replica`) and participates in // presence. A grid session takes the M5.2 cell path. - let messages = if let Some(sem) = semantic_states.get_mut(fid) { - sem.render_frame(editor) + let messages = if semantic_states.contains_key(fid) { + project_semantic_frame(editor, &mut semantic_states, *fid) } else { // T M10.9 — gather other-frontend presences for the // overlay paint. Reads `last_broadcast` (updated by @@ -1597,16 +1627,6 @@ fn dispatcher_loop( render_state.render_frame(editor, *fid, &terminal_snapshots, &other_presences) }; - // Parent 48 — THE PROJECTION SEAM. A mapping-generation - // advance cancels the live gesture INSIDE `render_frame`, - // while the successor `PresentMapped` is still being built, - // so "before that frame is produced" is not a place that - // exists. This is the first point that is: projection has - // returned and none of what it returned has been written. - // Draining here keeps the release ahead of the frame whose - // own new mapping is what required it. - drain_pending_release(editor, &mut semantic_states, *fid); - // Vterm Stage 3 — a semantic frontend showing a terminal has // no document cursor: the identity buffer is empty, so a // `CursorByte` would describe byte 0 of a buffer with no @@ -8740,6 +8760,69 @@ mod tests { // rather than unit shaped. Recorded as OWED rather than assumed // covered by its neighbours. + /// Q5 — a release raised by PROJECTION is paid before the frame + /// that raised it can be written. + /// + /// This is the third drain, and the only one a unit row could not + /// reach before: it sits inside the daemon's per-frontend frame + /// loop. `project_semantic_frame` is that seam, extracted --- it + /// returns the messages UNWRITTEN, so a caller holding them has by + /// construction not sent the successor frame yet, and a release + /// already delivered at that moment provably precedes it. + /// + /// Recorded as OWED through tasks 18 and 19; this closes it. + #[test] + fn q5_a_projection_raised_release_precedes_the_frame_that_raised_it() { + let fid = FrontendId(870); + let (mut editor, mut states, mut render, _panel, buffer_id, epochs) = + terminal_panel_session_at(fid, true, PROTOCOL_VERSION); + let cell = pmacs_protocol::CellCoord::new(1, 2); + + send_press( + &mut editor, + &mut states, + &mut render, + fid, + PROTOCOL_VERSION, + epochs, + buffer_id, + cell, + ); + assert_eq!( + child_stream(&editor), + vec![SGR_PRESS_1_2.to_vec()], + "fixture: the press reached the child" + ); + assert!( + states[&fid].has_accepted_gesture(), + "fixture: the gesture is live" + ); + + // The panel stops being presentable. `publish_absent_panel` + // cancels from INSIDE projection, which cannot deliver. + editor.hide_panel_for_test(fid); + + let messages = project_semantic_frame(&mut editor, &mut states, fid); + + assert_eq!( + child_stream(&editor), + vec![SGR_RELEASE_1_2.to_vec()], + "the release must already be delivered when projection hands \ + its messages back --- the caller has not written them, so \ + this is the release preceding the successor frame, not \ + merely both arriving" + ); + assert!( + !messages.is_empty(), + "fixture: projection really did produce the successor frame \ + whose own transition required that release" + ); + assert!( + !states[&fid].has_pending_release(), + "and nothing is left owed" + ); + } + /// Q6 — a SECOND PRESS with the first still live: the old release /// reaches the child before the new press. /// From 6142acc20301d78809592c3edbeb34a8efcc6286 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 21 Aug 2026 14:26:32 +0200 Subject: [PATCH 31/33] test(panel): Q5 names the successor frame it orders against `!messages.is_empty()` could be satisfied by any unrelated semantic message, so the row asserted an ordering against a frame it never confirmed was there. It now requires the unwritten messages to contain InstanceMessage::PanelFrame(PanelFramePayload::Absent) --- the successor frame whose own transition raised the release. The assertion bites: suppressing that payload while keeping the cancellation fails the row, where the emptiness check would not have noticed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- src/daemon.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/daemon.rs b/src/daemon.rs index d7144fb..5ad56f5 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -8813,9 +8813,16 @@ mod tests { merely both arriving" ); assert!( - !messages.is_empty(), - "fixture: projection really did produce the successor frame \ - whose own transition required that release" + messages.iter().any(|message| matches!( + message, + InstanceMessage::PanelFrame(pmacs_protocol::panel::PanelFramePayload::Absent) + )), + "fixture: the unwritten messages must contain THE SUCCESSOR \ + FRAME --- the `Absent` payload whose own transition raised \ + the release. A non-empty vec proves nothing: any unrelated \ + semantic message would satisfy it, and the row would then \ + assert an ordering against a frame that was never there. \ + Got {messages:?}" ); assert!( !states[&fid].has_pending_release(), From 642f2ddd00858cf76af3b9351d0c4d1dbe0f352e Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 21 Aug 2026 14:39:05 +0200 Subject: [PATCH 32/33] docs(ci-reds): U12 --- gate run 1 was 15/16, red on the budget+PTY family Head-exact gate at 6142acc: fifteen of sixteen stages green, including sweep, m4, gpu, diff-check and all eight touched acceptance suites. The red was 04-lib-crdt, where composition_overhead_under_ten_percent (1.247x against a 1.10x budget) and setsid_escapee_is_not_reaped_and_teardown_reclaims_readers failed together. Both green on isolated rerun. Filed as U12 rather than folded into U6 or U9, because both of those instruct it: U6 says one of its selectors redding without the other is a separate incident, and this is composition_overhead alone for the second time; U9 is the same budget-plus-PTY shape but in 11-sweep with a different PTY selector. src/process.rs is not touched by this branch at all. src/editor.rs is, but only in the panel-replay paths, not in composition. The row does NOT claim load caused it. It records that the run was knowingly taken on a machine that was quieter but not quiet --- load 11.04 at the start, 27.79 five-minute at the end, two foreign python processes throughout, an apt install shortly before --- which are conditions, not a mechanism. Four incidents in this family now, and the discriminating control U9 named remains unrun: pin test-binary concurrency to 1, and separately load a lone --lib binary. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- docs/active-work.md | 12 +++++++++++- docs/ci-red-signatures.md | 28 ++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/docs/active-work.md b/docs/active-work.md index faf37d8..269aca3 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -406,7 +406,17 @@ from #171 and #215 — the correction the 1b lane missed, honoured here. gone, so the completion has nothing left to clear and the gesture ending is the whole effect. Written into the row rather than left as a silently absent assertion. - - **REMAINING: the full head-exact gate, then the PR.** The gate + - **GATE RUN 1, head-exact at `6142acc`: 15/16, red on `04-lib-crdt` + only.** `composition_overhead_under_ten_percent` (1.247× against + 1.10×) and `setsid_escapee_is_not_reaped_and_teardown_reclaims_readers` + failed together; both green on isolated rerun. Recorded as **U12**. + `src/process.rs` is not touched by this branch at all. **The run + was knowingly taken on a machine that was quieter but not quiet** — + load 11.04 at start, 27.79 five-minute at end, two foreign `python` + processes throughout — so it is reported as a red under stated + conditions rather than dismissed. + - **REMAINING: a head-exact gate on a genuinely quiet machine, then + the PR.** The gate wants a QUIET machine: a foreign C++/java build has been running at load 114+ through this work, and the wall-clock rows (`composition_overhead_under_ten_percent`, diff --git a/docs/ci-red-signatures.md b/docs/ci-red-signatures.md index fdf1360..7c7a7f7 100644 --- a/docs/ci-red-signatures.md +++ b/docs/ci-red-signatures.md @@ -963,3 +963,31 @@ tree. the measured `duration_ms` in the assertion message — is **owed its own small lane**. It was deliberately kept out of #242, whose diff does not touch `src/async_runtime.rs`. + +### U12 — U9's shape in `04-lib-crdt`: a budget test and a PTY test, together + +Recorded during the panel-replay lane's gate, 2026-08-21, on head +`6142acc`. **Filed rather than folded into U6 or U9**, because both of +those tell it to be: U6 says one of its selectors redding without the +other is a different incident, and this is `composition_overhead_under_ten_percent` +alone again; U9 is that same shape but in `11-sweep` with a different +PTY selector. + +| field | value | +| --- | --- | +| **selectors** | `--lib --features crdt editor::tests::composition_overhead_under_ten_percent` **and** `process::tests::setsid_escapee_is_not_reaped_and_teardown_reclaims_readers`, failing in the same step | +| **job / step** | local (Linux), `scripts/gate` step `04-lib-crdt`, log `20260821T122852Z-2922631` | +| **required fragments** | `composition machinery added more than 10% overhead` / `live runtime probe` | +| **observed** | **1.247×** against the 1.10× budget (single 192493 ns, dispatch 240130 ns); the PTY row failed at `src/process.rs:5155`, where `active_reader_probe` found no live reader within the 2s `Started` window | +| **status** | **one occurrence, both selectors green on isolated rerun** | +| **the rest of the run** | **15 of 16 stages green**, including `sweep`, `m4`, `gpu`, `diff-check` and all eight touched acceptance suites | +| **what IS established** | both are timing-dependent by construction — one a wall-clock ratio, the other a 2-second liveness window — and each passed alone immediately afterwards. **`src/process.rs` is NOT touched by the observing branch at all**; `src/editor.rs` is, but only in the panel-replay paths, not in composition | +| **what is NOT** | that load caused it. Load was **11.04 at the gate's start and 27.79 (5-minute) at its end**, with two foreign `python` processes at ~2 cores throughout and an `apt`/`dpkg` install shortly before. Those are conditions, not a measurement of the mechanism, and the run was **knowingly taken on a machine that was quieter but not quiet** | +| **relation to U6** | its selector, alone again, in U6's own step. U6's instruction to judge that separately is honoured for the second time — see U9, which did the same | +| **relation to U9** | the same budget-plus-PTY co-failure, in `04-lib-crdt` rather than `11-sweep`, with `setsid_escapee…` where U9 had `m6_1_pty_raw_mode…` | + +**This family has now produced U6, U9, U10 and U12, and the +discriminating control U9 named is STILL UNRUN**: pin test-binary +concurrency to 1, and separately load a lone `--lib` binary. Four +incidents is enough evidence that the family will keep costing review +rounds until someone runs it. From b8c51b75ed958002d012fadcff910054322597b5 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 28 Aug 2026 10:39:28 +0200 Subject: [PATCH 33/33] docs(panel): drop the obsolete "Q5 is owed" block The block said Q5 was unwitnessed, acceptance-suite shaped, and owed. It sat immediately above the row that witnesses and closes Q5 through the extracted seam, so the source carried two rulings and the stale one first. Written when the gap was real and left behind when it was filled. Q5's own doc still records that it was owed through tasks 18 and 19, which is history rather than a standing claim. Swept for siblings: the remaining "owed" mentions all describe a release the gesture record owes a child, and the two "unwitnessed" mentions both say a PAST round was wrong to claim it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai --- src/daemon.rs | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/src/daemon.rs b/src/daemon.rs index 5ad56f5..4394d99 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -8743,23 +8743,6 @@ mod tests { ); } - // Q5 IS NOT WITNESSED HERE, and this is why. - // - // The third drain sits in the daemon's per-frontend frame loop, - // between `sem.render_frame(editor)` returning and the loop that - // writes what it returned. These rows drive `render_frame` - // directly, so they never enter that loop and cannot observe the - // seam; the two drains they DO reach — before a panel-pointer - // effect, and before detach teardown — are witnessed by Q1-Q4. - // - // The seam is still load-bearing: a cancellation raised inside - // projection with no following panel event and no detach would - // otherwise let the successor frame reach the frontend ahead of the - // release its own new mapping required. Witnessing it needs a row - // that drives the real frame loop, which is acceptance-suite shaped - // rather than unit shaped. Recorded as OWED rather than assumed - // covered by its neighbours. - /// Q5 — a release raised by PROJECTION is paid before the frame /// that raised it can be written. ///