diff --git a/docs/active-work.md b/docs/active-work.md index 98c8e17..2dfbaf0 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -5,11 +5,26 @@ landed on `main`. Read it after `docs/agent-handoff.md`. Remove completed entries when their PR merges; do not let this become a second permanent backlog. -**No lane below is retained past its merge.** The PTY terminate -diagnostic (#176) was the last one — retained because rule 4 removes a +**No lane below is retained past its merge.** This snapshot removes the +resource-op delete guard (#186) and dired Stage 2 framing (#171) lanes +the moment their PRs merged, because the same commit put their +load-bearing decisions into `docs/agent-handoff.md` §1 — rule 4's +precondition, satisfied deliberately rather than deferred. The +bottom-panel lane is not removed: 2B-2 landing leaves 2B-3 and Stage 3 +ahead of it, so the lane is rewritten to the remaining plan. + +**One open PR has no lane here: #188**, the generated-buffer +immutability framing. Its lane lives on its own branch and arrives with +it, which is deliberate — with several PRs open, a lane written here for +work that lands elsewhere re-conflicts on every merge. + +The PTY terminate +diagnostic (#176) was the last lane retained past its merge — retained +because rule 4 removes a merged lane only *after* its durable facts reach -`docs/agent-handoff.md`, and that absorption was unowned. This snapshot -owns it: #176's facts are now in the handoff (§1's arc bullet and §5's +`docs/agent-handoff.md`, and that absorption was unowned. The +2026-07-28 snapshot +owned it: #176's facts are now in the handoff (§1's arc bullet and §5's two ops lessons about ticking observers and proving child exit), so its lane is gone. The Lean 4, GPU-terminal-input, inline-math (#172), dired (#169), and terminal config + copy mode lanes were removed the same way @@ -20,14 +35,15 @@ would stop re-conflicting in this file. number appears in `git log --first-parent githubsucks/main`, it has landed regardless of what a lane says. -**Two open PRs had no lane here at all before this snapshot** — #174 and -#171. An open PR is exactly the volatile work this file exists to -record, so its absence is a ledger defect rather than a tidy omission: -#171 drifted **153 commits** while invisible here, and its still-green -old CI run describes a tree nobody has looked at since. **When a PR is -opened, give it a lane.** #174 has since merged, so per rule 4 its lane -is gone again and its durable lesson is in `docs/agent-handoff.md` §5; -#171's lane is below. +**Two open PRs had no lane here at all before the 2026-07-28 +snapshot** — #174 and #171. An open PR is exactly the volatile work this +file exists to record, so its absence is a ledger defect rather than a +tidy omission: #171 drifted **153 commits** while invisible here, and +its still-green old CI run described a tree nobody had looked at since. +**When a PR is opened, give it a lane.** All three have since merged — +#174, #171 and #186 — so per rule 4 their lanes are gone again and +their durable facts are in `docs/agent-handoff.md` (§5 for #174's +lesson, §1 for the two framings). ## Repository authority @@ -38,16 +54,17 @@ is gone again and its durable lesson is in `docs/agent-handoff.md` §5; machine-local: `origin` may name this canonical URL, a release mirror, or something else, and therefore has no authority by name alone. - Canonical base at this snapshot: - `githubsucks/main` @ `0442d78` (the M4 config-sink race fix #174, atop - bottom-panel Stage 2B-1 #184, the Journey/GPU directory-target ratchet - #183, Journey Stage 1a #182 and the previously recorded landed work). + `githubsucks/main` @ `7586905` (the docs-only coherence listview + correction #189, atop the docs-only landed-state refresh #185, the M4 + config-sink race fix #174, bottom-panel Stage 2B-1 #184, the + Journey/GPU directory-target ratchet #183, Journey Stage 1a #182 and + the previously recorded landed work). **Protocol schema support is `v6..=v21`; the production server-first `Hello` still advertises v20** — two different facts, and #184 landed only the first. The - previous snapshot named `7fd646d`, and **the + previous snapshot named `0442d78`, and **the recovery floor advances with it**: the check below now requires - `0442d78` or newer, so a tree at `7fd646d` — or at `6bee09d` — no - longer passes. That is + `7586905` or newer, so a tree at `0442d78` no longer passes. That is deliberate — the floor moves with the base, because a check that accepts an older commit than the declared base passes on a tree the rest of this file does not describe. @@ -86,7 +103,7 @@ git worktree list git status --short --branch ``` -The `git log` command must expose `0442d78` — the base named above — or a +The `git log` command must expose `7586905` — the base named above — or a newer intentional main. Keep this threshold and the canonical-base line in step: a recovery check that accepts an older commit than the base it declares canonical will pass on a tree the rest of this file does not @@ -228,17 +245,16 @@ If it does not, stop and repair the remote/fetch configuration. never been enforced. Any CI job that compiles the `crdt` targets has to fix them first or it will be red on arrival. -## Bottom-panel lane (Arc 7) — 2B-1 MERGED; 2B-2 IN FLIGHT +## Bottom-panel lane (Arc 7) — 2B-2 MERGED; 2B-3 IS NEXT -Stage 1, the Stage 2 framing, Stage 2A, and **Stage 2B-1 are all on -`main`**. Framing revision 5's three-way split of 2B was explicitly -approved on 2026-07-27; revision 6 records PR #184's review correction. -**2B-2 — the daemon panel projection and epoch machine — is under way** -on branch `bottom-panel-stage2b2`, worktree `../pmacs-bp-stage2b2`, -branched fresh from `githubsucks/main` @ `6bee09d`. It is *not* stacked -on the 2B-1 branch, which is the rule for every slice in this arc. Note -that `main` has since advanced to `0442d78`; the only difference is the -test-only #174, so the slice's integration surface is unchanged. +Stage 1, the Stage 2 framing, Stage 2A, Stage 2B-1, and **Stage 2B-2 are +all on `main`**. Framing revision 5's three-way split of 2B was +explicitly approved on 2026-07-27; revision 6 records PR #184's review +correction. **2B-2 — the daemon panel projection and epoch machine — +landed as [PR #187](https://github.com/levineuwirth/pmacs/pull/187)**, +one review round of five findings on top of the implementation, 12/12 +green, 22/22 mutations biting. Its durable lessons are in +`docs/agent-handoff.md` §1; what remains below is the 2B-3 plan. **2B-2's boundaries, restated because they are easy to overrun:** the production `Hello` stays at v20 and `panel_capable` stays `false`. The @@ -247,6 +263,52 @@ activation, the GPU band, and the negotiated capability flip are all 2B-3's, and 2B-3 may **not** simply change the unsolicited `Hello` to 21. +- **What PR #187 shipped, dark by construction:** the semantic daemon's + `FrontendCellGeometry` epoch machine; one reconciled panel grid + derivation; `PanelFrame::{Present, Absent}` projection on both document + and terminal semantic paths; stable presentation epochs; resize and + pointer validation against the live window/buffer/epochs; the panel's + own statusline context; and pre-drain semantic panel-terminal resize. + It does not add the GPU consumer or enable the capability. +- **Review round 1 closed five findings plus one sweep result at + `3ecb03d`.** The wire-area clamp became durable hide state; a stale + same-buffer reopen can no longer retain input authority; semantic panel + terminals resize before child drain; `NoMessage` retains a published + band baseline while `Invalidated` clears it; wheel activation follows + the terminal-only focus rule; and legally wide panels clamp their PTY + content without disappearing. +- **Review round 2 closed two findings at `bfaaf2b` plus this ledger + commit.** Side affinity can replace the buffer while preserving the + `WindowId`, so retained panel statusline segments are now keyed by the + full `(WindowId, BufferId)` presentation. Every authoritative `Absent` + also clears that baseline, including duplicate-suppressed `Absent`, so + a later `Present` under `NoMessage` cannot resurrect peer state that + was already cleared. Two acceptance tests bite those exact transitions. + This lane and `docs/agent-handoff.md` now name the open PR, current + landed base, checkpoint, and 2B-3 ordering instead of calling 2B-2 + merely “next.” +- **Round-2 verification at code checkpoint `bfaaf2b`:** formatting and + strict workspace Clippy; library **1,863 passed + 3 ignored** default + and **2,048 passed + 4 ignored** CRDT; bottom-panel Stage 1 / 2A / + 2B-1 / 2B-2 **46 / 17 / 16 / 28**; statusline **8 CRDT**; semantic + routing **2 CRDT**; M4 **121 passed + 3 ignored + 1 filtered**; + required GPU **202/202**; isolated-config full workspace sweep; and + `git diff --check`. The first workspace sweep had one GPU rendering + failure in `failures_and_display_math_render_as_source`; that test had + passed in the immediately preceding required-GPU run, passed alone, + and the complete workspace rerun passed. Real-daemon and managed-attach + cases were rerun outside the tool sandbox after its local-socket policy + produced `Operation not permitted`; the authoritative reruns passed. +- **Cross-machine recovery (fresh clone):** + + ```sh + git fetch githubsucks --prune + git switch --track -c bottom-panel-stage2b2 githubsucks/bottom-panel-stage2b2 + git rev-parse HEAD + ``` + + #187 has landed, so `githubsucks/main` already contains this work and + the branch is retained only for provenance. Start 2B-3 from `main`. - **Stage 2B-1 MERGED as #184** (`main` @ `6bee09d`, 2026-07-28; all twelve checks green on the reviewed head `5539b6e`; two review rounds plus a gate-found follow-up). Branch @@ -459,341 +521,6 @@ has **no branch and no framing yet**. `FrontendView.fold_projection` to `true` for semantic frontends, which Stage 2 deliberately left `false` (Q#FD21). -## Resource-op delete guard lane — PR #186 OPEN, PROPOSED, DO NOT MERGE - -- Portable branch: `githubsucks/resource-op-delete-guard`; worktree - `../pmacs-resource-op-delete`. **PR #186**, base `main`. Currently - framing only — `docs/resource-op-delete-guard-framing.md`, **revision - 5** — plus this lane entry. No runtime code yet. -- **Measured 2026-07-28, `main` @ `7586905`:** - - ``` - $ git rev-list --left-right --count HEAD...githubsucks/main - 5 0 - ``` - - Five commits ahead, **0 behind** at the pushed revision-5 head. This - count includes the revision commit itself; revision 4 recorded the - pre-commit count and was therefore one short. -- **This PR becomes the implementation PR.** Revision 2 dropped rev 1's - framing-PR-then-implementation-PR plan as a one-feature/one-branch/ - one-PR violation. The framing is revised in place; implementation - commits land on this same branch **only after explicit user - approval**. -- **Live data-loss bug, reproduced four ways against `ad41cf1`.** - `pmacs.buffer.apply_resource_op`'s delete arm removes the path from - disk and *then* drops any buffer bound to it, with no dirty check at - any link — not the arm, not `remove_buffer_and_fire`, and not - `BufferRegistry::remove`, whose only guard is `editing_in_progress`. - Reachable through any language server's `WorkspaceEdit`. The four - modes: (a) the plain case returns `Ok(())` with file and buffer both - gone; (b) `ignore_if_not_exists = true` does **zero** filesystem work - and still destroys the buffer; (c) `recursive = true` reconciles - **nothing**, so a whole tree leaves orphaned buffers — the most - destructive arm does the least reconciliation, and it bypasses any - exact-path guard; (d) removal is not `kill_buffer`, so windows are - left bound to a removed `BufferId` and the registry can be driven to - **empty**. -- **Approved in principle after review round 1; revision 5 closes round - 4's two contract P1s and the ledger-ownership P1. Still PROPOSED, - still not approved for implementation.** Settled: refuse - unconditionally; take the delete side now. Withdrawn: rev 1's - buffer-first ordering. The design is `stat/no-op/refuse → enumerate - and validate → mutate filesystem → reconcile`, which keeps - `on_removed`'s "path already gone" invariant and makes a failed - deletion leave buffers intact automatically. -- **The stable cross-lane ownership split with #171:** - - > #186 owns the urgent **pre-filesystem refusal** for synchronous - > `apply_resource_op`. #171 later owns **full post-delete lifecycle - > reconciliation**, including the **async race where a buffer becomes - > modified after dired dispatch**. - - #186 additionally **owns the shared walk query** (scan every - path-bound buffer, normalize once, component-aware `Path::starts_with`) - under the boundary's "whichever lands first owns the query"; #171 - adopts it and extends it to `reconcile_rename`. **Neither lane guards - `pmacs.fs.remove`** — zero production callers today, named out of - scope by both. -- **#171 owns its own lane entry.** Revision 4 rewrote that sibling - block and was stale before push when #171 revision 8 landed 67 seconds - earlier. Revision 5 restores the block to `main`'s tree, so #186's - diff no longer changes it. The one fact this lane depends on is the - policy split above, which is stable through #171's pushed revision 8 - and independent of its commit count. -- **Standing rule this lane learned the expensive way.** A census is a - reading, not a constant. **Do not write an ahead/behind count, a line - count, or a call-site count into this file that you have not just - produced with a command whose output you can paste.** #186 shipped a - stale line count, then a stale commit count, then a stale ledger - citation, in three consecutive revisions — each time by carrying a - measurement across a base change instead of re-running it. The - specific trap: a count taken against `ad41cf1` was reported in - present tense after `main` had moved to `7586905`, which silently - converted "0 behind" into a falsehood. -- **Four facts a re-scout should not have to rediscover**, all verified - at `ad41cf1`: - - **No caller reliably surfaces a raise.** The server pump runs under - `pcall(handle_server_requests)` (`builtin/runtime/lsp.lua:1892`), so - a raise unwinds past the `send_response` and the server is never - answered; and the two user-initiated paths route uncaught coroutine - errors through `pmacs.error`, which is **undefined** (11 call sites - in `builtin/`, zero definitions). Refusals must travel as values. - - **A partial batch is already the status quo** — verified in-repo: - two delete ops, the second raises, the first stayed applied. Any - framing claiming batch atomicity here is wrong. **Do not justify - this from the LSP spec.** Revision 2 of #186 wrote that LSP 3.18 - "assigns `FailureHandlingKind.Abort` to resource-op-bearing edits"; - **it does not** — recovery is described by the client's advertised - `workspace.workspaceEdit.failureHandling`, `Abort` is one of four - strategies, only `TextOnlyTransactional` degrades to abort for - resource changes, and **pmacs advertises none of them**. The - justification is repository evidence plus the judgement that a - visible partial refactor beats unrecoverable unsaved work. - - **`find_by_path` is singular and duplicates are reachable.** - `BufferRegistry::find_by_path` returns the first match in insertion - order, `EditorCore::find_buffer_for_path` inherits that, and - `pmacs.buffer.from_file` creates path-bound buffers with **no - dedup** — so a clean first match can hide a modified second. The - guard needs a full scan with component-aware `Path::starts_with`. - - **pmacs advertises no `workspace.workspaceEdit` capability at all** — - `"applyEdit": true` but no `documentChanges`, no - `resourceOperations`, no `failureHandling`; `grep -rn - failureHandling` returns 0. Parked, not fixed here. -- **Ownership claim, concretely.** For this lane's duration #186 owns: - the pre-filesystem refusal inside synchronous `apply_resource_op`; the - **shared walk query**; and `builtin/runtime/lsp.lua`'s - `apply_workspace_edit` plus the `workspace/applyEdit` server-request - boundary. It does **not** own: full post-delete lifecycle - reconciliation, the dired async race between dispatch and - `remove_blocking`, the rename side of the walk, or `pmacs.fs.remove`. - Do not run the two lanes concurrently over `builtin/runtime/lsp.lua` - without re-splitting that claim. -- **Two residues #186 deliberately leaves for #171**, both named rather - than silent: after a successful *clean* recursive delete, descendant - buffers stay orphaned-and-clean (widening removal would promote the - dangling-window/empty-registry defect from exact-path to tree-wide); - and after a successful delete with several clean duplicates on one - path, only the first is reconciled. #186 validates **every** match but - reconciles **one**, which is today's behaviour preserved on purpose. -- Files the implementation will touch: `src/lua_bindings/mod.rs`, - `builtin/runtime/lsp.lua`, `tests/m4_acceptance.rs`, - `tests/lsp_dispatch_seams_acceptance.rs`, - `src/bin/pmacs_fake_lsp.rs`. **Not** `src/daemon.rs`, - `pmacs-protocol/`, `builtin/runtime/dired.lua`, - `docs/agent-handoff.md` or `COHERENCE.md`. No protocol change. -- Recovery from a clean checkout: - `git fetch githubsucks && git worktree add ../pmacs-resource-op-delete - -b resource-op-delete-guard githubsucks/resource-op-delete-guard`. - -## dired Stage 2 framing lane — PR #171 AT REVISION 9, AWAITING APPROVAL - -- Portable branch: `githubsucks/dired-stage2-framing`; - worktree `../pmacs-dired-stage1`. **PR #171**, base `main`, integrated - up to canonical `main` @ `7586905`. Framing only — - `docs/dired-stage2-framing.md`, **3,844 lines measured at this - revision**, no runtime code. This lane rides that PR — it has since - rev 6, when #185 merged; do not open a standalone ledger PR. -- **Status: PROPOSED, never approved.** Revision 5 was reviewed and NOT - approved (six findings, four P1); revision 6 answered them; **revision - 7 resolved a cross-lane conflict with PR #186**; revision 8 was - reviewed and NOT approved (six findings, four P1); revision 9 answers - them. The framing is at revision 9 after seven review rounds; **that - is not the same as approval**, and it must not be read as one. -- **Round 8 (rev 9) made every rev-8 correction singular and - executable:** - - Q#DR26 no longer retains the withdrawn execution-order claim; it - owns only the homogeneous enum/vector shape and delegates sequence - semantics to Q#DR29: **bus arrival, not filesystem execution**. - - 2a owns acceptance **23–38 and 50–55, including every suffixed - item**, so rev 8's new tests cannot fall between implementation PRs. - - The LSP gate is one manager-owned, generation-scoped - `HashSet<(LspServerId, String)>`. Both - `absorb_publish_diagnostics` and the now-server-aware - `mark_document_stale` check the exact pair; `did_open` clears one - pair, and server teardown clears all and only that server's pairs. - It is reclaimed, not falsely called size-bounded. Acceptance covers - both writers and both reclamation paths. - - Buffer-name following now uses explicit - `BufferNameOrigin::{Explicit, PathDerived}` state. The acceptance - collision is a file `${cwd}/notes` explicitly named `notes`, which - defeats rev 8's path-equivalence heuristic. -- **Round 7 (rev 8) found five contract defects of ONE family, and the - family is the transferable lesson: a guarantee assumed from a - mechanism whose implementation was never read.** Reply order assumed - to be execution order; a refusal assumed to be inert; `forget_uri` - assumed to cover every writer. Three of the five were literally that. - The decisions: - - **Reply order is NOT execution order.** `AsyncRuntime::tick` - (`src/async_runtime.rs:1003`) is `while let Ok(env) = - self.main.try_recv()` — a bus drain with **no execution token**, so - a worker can finish, be descheduled before sending, and have a later - reply arrive first. The ordering guarantee was **withdrawn, not - engineered**: reconciliation is order-independent, and `fs.lua:155-165` - already recommends serialization. Rev 9 makes the public contract - exact: overlapping mutations dispatch serially, awaiting one before - the next. A lock was rejected — it would serialize every fs mutation - for a hazard absent from shipped in-tree callers. - - **`EditorCore::kill_buffer` damages editor state BEFORE it can - refuse.** It clears `round_trip_buffers`, closes side windows and - redirects ordinary windows, and only then can - `BufferRegistry::remove` return `ConcurrentEdit`. So "refused" is - not "nothing happened". Fixed by preflighting `editing_in_progress` - — sound because phase 1 is pure `EditorCore` and holds no Lua - handle, so nothing can re-enter between check and removal. - - **Buffer names are set from `path.display()` AS GIVEN** while only - `file_path` is normalized (`src/editor_core.rs:917`, - `src/lua_bindings/mod.rs:3112`), so a relative open leaves a short - name that a string-equality rule mistakes for user-chosen. Rev 8 - moved to path-equivalence; rev 9 replaces that inference with - explicit provenance because a custom name can equal the path. - - **`diag_store` has ZERO correlated writers**, and - `DiagnosticStore.by_uri` is keyed by **URI alone** with no server - component (`src/diag.rs:198`) — so the store most needing protection - is the one a `pending_routes` purge cannot help. Rev 8 incorrectly - inferred that the tombstone must share the store's URI-only key; - rev 9 gates in `LspManager`, where `sid` is still available. - - **A first pass of rev 8 checked only `handle_notification` and - concluded `publishDiagnostics` was the only uncorrelated writer. - Wrong lens boundary** — `pub fn mark_document_stale` - (`src/lsp.rs:3108`) takes **no `LspServerId`** and creates URI keys - across three stores for every server. Rev 9 changes that surface and - its private Lua binding to `(sid, uri)` and pins it independently. -- **Both new hooks are `all-must-succeed`, not short-circuit** — a gap - the review did not raise and the sweep found. `src/hook.rs` defines - three `HookKind`s; registered short-circuit, one `resource.renamed` - subscriber returning falsey would silently stop every later - subscriber reconciling. -- **CROSS-LANE SPLIT WITH PR #186 — settled, recorded verbatim so the - two lanes cannot diverge again:** - - > #186 owns the urgent **pre-filesystem refusal** for synchronous - > `apply_resource_op`. #171 later owns **full post-delete lifecycle - > reconciliation**, including the **async race where a buffer becomes - > modified after dired dispatch**. #171's revision 7 adopts the - > refusal and stops saying LSP intentionally deletes modified files. - - **PR #186 (`resource-op-delete-guard`) owns the pre-filesystem - refusal.** Do not implement a competing dirty check from #171. Rev 7 - adopts #186's Q#RD1 (refuse, do not prompt, do not save, do not back - up) and its Q#RD6 shared query (scan every path-bound buffer, not - first-match; normalize once; component-aware `Path::starts_with`); - whichever lane lands first owns that query and the other adopts it. - The two fit deliberately: #186's Q#RD5 keeps reconciliation - **exact-path** so it does not promote the dangling-window and - last-buffer defects tree-wide, and #171's Q#DR27 is the lane that - makes widening safe. -- **What rev 7 removed.** Rev 6 said an LSP-authored delete removes the - file and orphans the modified buffer as accepted residue. That is - withdrawn — **20 matched lines across 13 passages** were censused and - reclassified: 4 ownership claims reassigned to #186, 4 policy claims - deleted, 3 ground-truth statements kept but attributed, 2 async-race - claims kept and narrowed. -- **What rev 7 kept, and it is now one path not two.** The async race — - a buffer modified between dired's pre-dispatch check and - `remove_blocking`'s syscall — survives #186's refusal, because **dired - never goes through `apply_resource_op`**: it calls `pmacs.fs.remove`, - which dispatches a worker no in-applier guard can see. Rev 6 carried - this as "two paths, LSP and dired"; after #186 the LSP path has no - residue at all. -- **A gap neither lane closes, named in rev 7 §11:** `pmacs.fs.remove` - is public Lua API with **no dirty check of its own**. After both lanes - land the guards sit one layer above it on each side — at - `apply_resource_op` (#186) and in dired's policy layer (#171) — so a - third caller inherits neither. Latent, not live: it has zero - production callers today. -- **LSP failure-handling, verified against the 3.18 spec** because #186 - got it wrong and this lane was checked for the same error. **This lane - never made the claim** — it asserts nothing about `WorkspaceEdit` - recovery. For the record: only `textOnlyTransactional` degrades to - abort for resource operations; `transactional` covers them; and the - specification states **no default** for a client that does not - advertise `failureHandling` (pmacs advertises none). So "Abort applies - by omission" does not follow. -- **Round 5's single theme, worth carrying because it will recur:** rev - 5 changed the slice split and the ownership of a decision, and the - prose did not follow. Four of the six findings were that same defect - in different places — stale slice labels, an acceptance allocation - that contradicted the code split, and a PR body still describing - revision *1*'s two-slice plan. **When a cut changes, sweep for the - label, do not patch the instance.** Rev 6's own sweep found four more - the review had not cited, one of which was wrong in both halves - (`x`'s report tagged 2a, `remove_dir_all` tagged 2b). -- **Rev 4 was scouted at `c8ec8f3`, which is dired Stage 1's own merge - commit (#165)** — so dired Stage 1 and find-file (#162) were always - its base, not new arrivals. Eighteen PRs landed on top before the - re-scout. Rev 5 is that re-scout, against `6bee09d` and then - integrated forward. -- **Seven of rev 4's own claims about pmacs were wrong**, which is the - lane's most transferable lesson: a framing can verify all its external - facts and still be wrong about the codebase it is for. The load-bearing - one: §5 named `drain_external_cancelled` but cited `lsp.rs:1596`. - **Those are two different functions 35 lines apart** — - `drain_external_cancelled` (`:1561`) is the unconditional - server-scoped drain and is the right precedent; - `drain_cancelled_externals` (`:1596`) is a per-tick token/timeout - sweep. A rename flips no token, so following the line number yields a - `forget_uri` whose drain half is a silent no-op and the awaiting - coroutine hangs forever — exactly the failure that step exists to - prevent. -- **The rename path-owner census is SIX, not five.** The sixth is - `lean.lua`'s `M.file_progress`, a URI-keyed Lua module table living in - no Rust store, so `forget_uri` structurally cannot reach it. That is - the first case outside dired proving the **hook**, not the Rust - method, is the mechanism that scales. The other five: buffer path, - buffer name, the URI-keyed LSP stores plus `DiagnosticView` (URI set - once at construction), dired's pathless handles, and a captured Lua - local no transaction can reach. -- **The journey ratchet is split across two files**, and both are gates: - `tests/journey_acceptance.rs` (24 tests, "stages add rows, none - removes them", seven rows asserting on dired) and - `gpu_invocation_acceptance.rs`, where #183 actually put the GPU row. - A scout that checks only the first will think #183 added nothing. -- **`open_directory` now commits under `pmacs.window.commit_to`, whose - scope REFUSES an `await`** (#182). That directly constrains the - serialize-and-await batch contract the framing proposes. -- **The typed-edit chain does not reach dired** — verified rather than - assumed. Its lessons bind the framing's two *new* hooks instead. -- **Scope MOVED OUT of this lane, and rev 6 records the handoff.** - Rev 5 added Q#DR25 to adopt `set_generated_contents` at the head of - 2b, because dired's listing is a generated buffer whose paint bypasses - an intercept over a still writable rope. That turned out to be a - **class bug, not dired's** — the same idiom is in listview, compile - and search/grep, and **no Lua caller anywhere sets `read_only`** - because there is no Lua `set_read_only` to call — so it is owned by - the generated-buffer immutability lane. **Done: rev 6 withdraws - Q#DR25**, and §3.1 hands that lane what this one's re-scout found, - including the trap that - `dired_buffer_is_read_only_and_round_trips_input` passes **either - way** and is therefore not coverage of the adoption. Do not implement - Q#DR25 from this document. -- **Two substrate facts rev 6 added that outlive this lane.** (1) Buffer - removal is **two phases** and **no existing Rust path composes them**: - `EditorCore::kill_buffer` (`src/editor_core.rs:4590`) does windows and - registry, `after_buffer_removed` (`src/lua_bindings/mod.rs:1602`) does - keymaps/config/folds/`on_removed`, and only the Lua binding - `pmacs.buffer.kill` (`mod.rs:5476-5491`) runs both — while - `apply_resource_op`'s delete uses `remove_buffer_and_fire`, which - skips phase 1, **so a window displaying the deleted buffer keeps a - removed id**. That is a third defect on that arm beside the missing - dirty check and the first-match lookup. (2) The drain outcome must - carry deletions as well as renames in one `Vec` so every - kind is expressible, but its sequence is **bus-arrival order, not - filesystem execution order**; Q#DR29 requires callers with - interdependent mutations to serialize. -- Intended serial implementation once approved: **2a** rename/delete - reconciliation substrate with no dired UI, **2b** marks and - operations, **2c** mkdir/copy/recursive-delete primitives, then Stage - 3 wdired. The re-scout re-examined this cut and it holds unchanged. -- **Ownership warning:** dired 2a overlaps `src/editor_core.rs`, - `builtin/runtime/lsp.lua`, and the URI-keyed LSP state with other - coherence work. Do not run it concurrently with Journey Stage 1b - without assigning those files to one lane first. -- **Two live bugs on `main` this lane confirmed but does NOT fix**, both - now owned elsewhere: `apply_resource_op`'s delete arm destroys unsaved - work (no dirty check anywhere on the path), and `View` still lacks - `rename_resource`. - ## Parked lane: kill-ring browser + persistence - Portable branch: `githubsucks/kill-ring-browser` diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index 6c99502..f514cec 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -1,9 +1,15 @@ # Agent handoff — cross-machine continuity -**Last updated: 2026-07-28, after the M4 config-sink race fix (#174) and -bottom-panel Stage 2B-1 (#184) merged; the canonical landed base is -`0442d78`. #174 is test-only. #184 is the substantive one — the reserved -protocol-v21 +**Last updated: 2026-07-28, as bottom-panel Stage 2B-2 (#187) — the +daemon panel projection and the epoch machine — lands on `6c9e765`, +which is the dired Stage 2 framing (#171) atop the resource-op delete +guard framing (#186). Those two are framing-only: both are approved +documents with no runtime code, and neither has begun implementation. +Beneath them, the docs-only coherence listview +correction (#189) and landed-state refresh (#185); the canonical +landed base beneath those is `7586905`. The runtime anchor beneath them is the M4 +config-sink race fix (#174) and bottom-panel Stage 2B-1 (#184). #174 is +test-only. #184 is the substantive one — the reserved protocol-v21 bottom-panel wire family, dark by construction, with the production handshake deliberately still advertising v20 — following the Journey/GPU directory-target ratchet (#183), following Journey Stage 1a (#182), @@ -51,9 +57,14 @@ commands, read `docs/active-work.md` immediately after this file. ## 1. Where the project stands (2026-07-28) -- `main` @ `0442d78` (the M4 config-sink race fix #174 — test-only — - atop bottom-panel Stage 2B-1 #184, the Journey/GPU - directory-target ratchet #183, Journey Stage 1a #182, incorporating +- `main` @ `6c9e765` (the dired Stage 2 framing #171 and the resource-op + delete guard framing #186 — both framing-only, no runtime code, no + implementation started — atop + the docs-only coherence listview correction #189, + atop the docs-only landed-state refresh #185, the M4 config-sink race + fix #174 — test-only — atop bottom-panel Stage 2B-1 #184, the + Journey/GPU directory-target ratchet #183, Journey Stage 1a #182, + incorporating terminal configuration + copy mode landed docs #180, Lean 4 Stage 4b #181, the dired Stage 1 landed docs #169 and the PTY-terminate diagnostic #176, terminal copy mode @@ -518,20 +529,23 @@ commands, read `docs/active-work.md` immediately after this file. `bottom_panel_stage1_acceptance` 46; kill ring 30; compile 67; M4 121; required GPU 152; initial-target 14 CRDT; all three vterm suites; folding Stage 2 48. All 12 CI checks green at merge. - - **Stage 2 (the GPU panel band) is FRAMED, and its first two slices - have LANDED** — `docs/bottom-panel-stage2-framing.md` rev 6, four + - **Stage 2 (the GPU panel band) is FRAMED; its first two slices have + LANDED and its third is open as PR #187** — + `docs/bottom-panel-stage2-framing.md` rev 6, four framing review rounds, no open framing items; the rev-5 implementation split was explicitly approved 2026-07-27 and rev 6 records PR #184's server-first compatibility and gate correction. It reserves protocol **v21** and ships as four serial implementation slices: **2A** classified census routing + per-window painter extraction (no wire change, #177), **2B-1** the - wire (#184), **2B-2** the daemon projection and epoch machine, - then **2B-3** the GPU band, compatible v21 activation, and the + wire (#184), **2B-2** the daemon projection and epoch machine + (implemented but not landed in PR #187), then **2B-3** the GPU band, + compatible v21 activation, and the negotiated `panel_capable` flip. Production attachment remains v20 through 2B-2. Parent acceptance 37–55 remains authoritative. - Stage 3 is the adopter default flip. **2B-2 is the next slice, and - it branches from `0442d78` or newer.** + Stage 3 is the adopter default flip. **Do not start 2B-3 until PR + #187 lands.** Its branch, checkpoints, two review rounds, verification, + and exact recovery commands live in `docs/active-work.md`. - **The §1.3 census is CLASSIFIED, not uniformly redirected.** Only the Projection class (#1–#12, #21–#22) routes through `primary_document_window`; focus/input (#13–#15, #23), focus chrome @@ -597,6 +611,97 @@ commands, read `docs/active-work.md` immediately after this file. statusline and Vterm Stage 3 ladders still pinned v20 and rejected v21, in both structural and real-headless-probe form. Grep for the outgoing version across `tests/` before calling a bump complete. +- **Bottom panel Stage 2B-2 (daemon projection + epoch machine) LANDED + — #187** (one review round of five findings on top of the + implementation; 12/12 green; 22/22 mutations bite). What it + establishes, beyond the feature: + - **A durable transition implemented as a per-frame effect is a bug + shape, not a style.** Four of the five review findings were one + defect: a renderer-side conditional with a durable twin that + disagreed with it. Wire-area exhaustion hid the rendered frame but + left panel state live; a stale `panel_epoch` could address a + reopened same-buffer panel; `NoMessage` cleared statusline segments + it should have retained. The fix that generalizes is + `presentable_panel_grid` — **one** private derivation behind both + the renderer and `reconcile_panel_layout_core`, because two + derivations of one predicate is *how* they drift. When touching + panel rendering, ask of every conditional: is there a durable twin, + and does it agree? + - **Ask what the other frontend kind's equivalent does.** The + semantic panel terminal missed the pre-child-drain resize its + document sibling had; the repro is a 4×20 panel reporting 3×120. + The new `sync_semantic_panel_terminal_layout` is disjoint from that + sibling *by construction* — one resolves through + `primary_document_window`, the other through `side_window_for` — so + the SIGWINCH storm the original extraction prevented cannot recur + through the new path. + - **A panel may legitimately be wider than a PTY.** `>512` columns + are legal on the wire (2B-1's `WireGridLimits` parameter), but + `snapshot_for_view` refused the band's content rect and the band + went `Absent` with `panel_hidden` false. Clamp the *terminal + projection*, not the band: the child takes the columns a PTY can + have and the remainder is band background. + - **Mutation testing cannot reach behaviour never modelled.** All 16 + original mutations passed while five real defects stood; one of + them even fired, removing an `Absent` the test asserted. Mutations + prove assertions bite the code that exists. They say nothing about + the assertion never written. + - **One durability hole is accepted deliberately:** epoch exhaustion + stays a per-frame `Absent`, flagged rather than hidden, because + making it durable needs a new "presentation permanently + unavailable" reason in `FrontendView` for a state requiring 2^64 + presentations in one session. +- **dired Stage 2 framing LANDED (document only) — #171** + (`docs/dired-stage2-framing.md`, revision 9; seven review rounds). + **Approved as a framing; no runtime code and no implementation + started.** Load-bearing decisions a reader must not re-derive: + - **Reconciliation is order-independent, deliberately.** Reply order + is *not* execution order — `AsyncRuntime::tick` drains the bus with + `try_recv`, so a worker can finish first and be descheduled before + sending. An ordering token was rejected rather than built: no + static rule rescues the hazard anyway (rename `dir`→`newdir` racing + delete `dir/child.txt` needs opposite orders depending on which ran + first), and no production path can produce it. The trigger to + revisit is named: the first production caller that fire-and-forgets + two interdependent mutations. + - **A refused mid-edit kill is already destructive.** + `kill_buffer` clears round-trip state and moves windows *before* + `BufferRegistry::remove` can return `ConcurrentEdit`, so + `editing_in_progress` must be preflighted rather than relied on to + refuse cleanly. + - **Path-backed buffer names are not full paths.** + `get_or_load_buffer` takes the name from the path *as given* and + normalizes only the stored path, so a relative open is named + `foo.rs`. Rename provenance is path-*equivalence*, not equality. + - **URI-keyed stores have uncorrelated writers.** + `textDocument/publishDiagnostics` is absorbed unconditionally, and + `mark_document_stale` takes no `LspServerId` at all while creating + URI keys across three stores. A route purge keyed on request + responses cannot cover either. +- **Resource-op delete guard framing LANDED (document only) — #186** + (`docs/resource-op-delete-guard-framing.md`, revision 5; five review + rounds). **Approved as a framing; no runtime code.** It owns the + urgent **pre-filesystem refusal** for synchronous `apply_resource_op` + — which on `main` today destroys unsaved work — while #171 owns full + post-delete lifecycle reconciliation. Carried facts: + - The sequence is `stat/no-op -> enumerate and validate -> mutate + filesystem -> reconcile`. Validation inspects without removing, so + a failed deletion leaves buffers intact and `on_removed` still + observes the path already gone. + - **`EditorCore::find_buffer_for_path` is the wrong lookup** — it + delegates to first-match-only `find_by_path`, and + `pmacs.buffer.from_file` creates path-bound buffers with no dedup, + so a clean first match can hide a modified second. + - **LSP does not promise batch atomicity, and pmacs advertises no + `workspace.workspaceEdit` capability at all** — no + `documentChanges`, no `resourceOperations`, no `failureHandling`. + Recovery is the client's declared choice, and there is no spec + default for a client that declares none. Say only that observed + pmacs behaviour *resembles* abort-style application. + - **`pmacs.fs.remove` has no dirty check of its own** and neither + lane adds one. After both land the guards sit one layer above it. + Latent — zero production callers — but "both lanes guard deletion" + reads as the primitive being guarded, and it is not. - **GPU initial target LANDED — #148** (`docs/gpu-initial-target-framing.md` rev 3; merge `0dd16a5`; two review rounds). `pmacs --gpu [--socket NAME|PATH] FILE` transports exact Unix path diff --git a/src/daemon.rs b/src/daemon.rs index b665d07..c97e032 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -69,8 +69,8 @@ use crate::protocol::crossterm_translate::{key_to_crossterm, mouse_to_crossterm} use crate::protocol::{ ADVERTISED_PROTOCOL_VERSION, AttachRequest, FrontendEvent, FrontendId, GoodbyeReason, Hello, InitialTarget, InitialTargetResult, InstanceCapabilities, InstanceIdentity, InstanceMessage, - InstanceSignal, MAX_INITIAL_TARGET_ERROR_BYTES, MAX_INITIAL_TARGET_PATH_BYTES, PointerKind, - SelectionSnapshot, SessionBootstrapRequest, + InstanceSignal, MAX_INITIAL_TARGET_ERROR_BYTES, MAX_INITIAL_TARGET_PATH_BYTES, + PANEL_MIN_VERSION, PointerKind, SelectionSnapshot, SessionBootstrapRequest, }; use crate::socket_path::{SocketPathError, ensure_runtime_subdir}; use crate::transport::{read_message, write_message}; @@ -898,13 +898,22 @@ fn peer_declared_terminal_support( /// Q#BP13). /// /// Grid sessions paint the whole cell grid the daemon composes, so a side -/// window is just another leaf for them. A semantic session needs the -/// Stage 2 `PanelFrame` band, which does not exist yet — so Stage 1 -/// answers `false` for every semantic peer, whatever it declares. No -/// client-asserted standalone boolean is trusted: the answer is derived -/// from the daemon's own negotiated state, and Stage 2 turns the version -/// arm on (`semantic_render && negotiated_protocol_version >= -/// PANEL_MIN_VERSION`). +/// window is just another leaf for them. A semantic session needs the GPU +/// band, which does not exist yet — so this still answers `false` for +/// every semantic peer, whatever it declares. No client-asserted +/// standalone boolean is trusted: the answer is derived from the daemon's +/// own negotiated state. +/// +/// **Stage 2B-2 deliberately does not turn the version arm on.** The +/// daemon-side projection and epoch machine below are complete and +/// exercised through a test-only panel-capable view, but the production +/// flip (`semantic_render && negotiated_protocol_version >= +/// PANEL_MIN_VERSION`) belongs to Stage 2B-3, together with the +/// compatibility-preserving activation the server-first `Hello` requires: +/// [`ADVERTISED_PROTOCOL_VERSION`](pmacs_protocol::ADVERTISED_PROTOCOL_VERSION) +/// is still 20, so no session can negotiate 21 yet, and denying only the +/// events while still *placing* such a peer in a side window would leave +/// its window invisible. fn peer_declared_panel_support(session_state: crate::presence::SessionState) -> bool { !session_state.negotiated_capabilities.semantic_render } @@ -917,6 +926,91 @@ fn peer_accepts_terminal_message(protocol_version: u32, message: &InstanceMessag protocol_version >= 19 || !matches!(message, InstanceMessage::TerminalFrame(_)) } +/// The same belt-and-braces write-loop gate for the additive +/// protocol-v21 panel frame (Q#BP9). +/// +/// The producer already skips construction for a peer below +/// [`PANEL_MIN_VERSION`]; this filter independently prevents an unknown +/// discriminant reaching one, so neither gate alone is load-bearing. +fn peer_accepts_panel_message(protocol_version: u32, message: &InstanceMessage) -> bool { + protocol_version >= PANEL_MIN_VERSION || !matches!(message, InstanceMessage::PanelFrame(_)) +} + +/// Whether an authenticated source may send the v21 panel event family +/// (Q#BP9's "every gate keys on the daemon's own state"). +/// +/// All three inbound events require the same four facts, and they are +/// checked together so no arm can satisfy three and forget the fourth: +/// an installed **semantic** projection, a negotiated version that +/// carries the variants, and a `FrontendView` this daemon itself marked +/// panel-capable. A grid session, a pre-panel semantic peer, or a +/// non-panel-capable view is rejected before any payload state is +/// trusted. +/// +/// Q#BP16 steps 2–4: the event addresses the panel declaration this +/// session most recently shipped, under the geometry it most recently +/// accepted, and that declaration still describes the panel on screen. +/// +/// Four facts, one predicate, because they close four different holes +/// and no three of them subsume the fourth: +/// +/// * the latest declaration is a `Present` (an `Absent` cleared input +/// authority, so nothing is addressable), +/// * its echoed `geometry_epoch` equals both the payload's **and** the +/// daemon's latest accepted declaration — the font/scale/resize race, +/// * its `panel_epoch` equals the payload's — close/hide/reopen of the +/// same persistent buffer, which a `buffer_id` alone cannot see, +/// * and the presentation behind it is the side window that is live +/// **now** — because a close/reopen inside one dispatcher burst does +/// not invalidate the shipped declaration, only the window it named +/// (review round 1, R1-2). +fn panel_event_epochs_are_current( + editor: &EditorState, + semantic_states: &HashMap, + source: FrontendId, + geometry_epoch: u64, + panel_epoch: u64, +) -> bool { + let core = editor.core.borrow(); + let live_presentation = core.side_window_for(source).and_then(|window_id| { + core.windows + .get(&window_id) + .map(|window| (window_id, window.buffer_id)) + }); + semantic_states.get(&source).is_some_and(|sem| { + sem.panel_declaration_matches(geometry_epoch, panel_epoch, live_presentation) + }) && core + .frame_geometry_for(source) + .is_some_and(|geometry| geometry.geometry_epoch == geometry_epoch) +} + +/// Whether an authenticated source may send the v21 panel event family +/// (Q#BP9's "every gate keys on the daemon's own state"). +/// +/// All three inbound events require the same three facts, and they are +/// checked together so no arm can satisfy two and forget the third: an +/// installed **semantic** projection, a negotiated version that carries +/// the variants, and a `FrontendView` this daemon itself marked +/// panel-capable. A grid session, a pre-panel semantic peer, or a +/// non-panel-capable view is rejected before any payload state is +/// trusted. +/// +/// The claimed `frontend_id` in the payload is never consulted anywhere: +/// routing is by the authenticated transport `source`, so a forged id +/// addresses nothing. +fn peer_may_send_panel_events( + editor: &EditorState, + session_registry: &SessionRegistry, + semantic_states: &HashMap, + source: FrontendId, +) -> bool { + semantic_states.contains_key(&source) + && session_registry + .session_state(source) + .is_some_and(|state| state.negotiated_protocol_version >= PANEL_MIN_VERSION) + && editor.core.borrow().panel_capable_for(source) +} + /// T M10.8 — dispatcher loop. The single thread that owns the editor. /// /// All attached frontends' inputs arrive via the `dispatcher_rx` @@ -1376,6 +1470,12 @@ fn dispatcher_loop( if !peer_accepts_statusline_message(negotiated_protocol_version, msg) { continue; } + // Bottom panel Q#BP9 — PanelFrame gated at v21. A v20 + // peer receives no band and, per Q#BP13, is never + // placed in a side window either. + if !peer_accepts_panel_message(negotiated_protocol_version, msg) { + continue; + } // T M10.10 Day 4 / M10.11 F2 — the criterion-1 // jitter site: render-write latency. // @@ -1950,10 +2050,17 @@ fn handle_session_established( term_sizes.insert(frontend_id, initial_size); // Bottom-panel arc (Q#BP2b): a grid session's real attach size IS its // authoritative geometry declaration, cached BEFORE any input can - // reach it. A semantic session deliberately stays UNKNOWN — Stage 2's + // reach it. A semantic session deliberately stays UNKNOWN — its // authenticated `FrontendCellGeometry` fills it, and the permanent // 24x80 attach placeholder is never consulted for panel layout. - if editor.core.borrow().panel_capable_for(frontend_id) { + // + // Stage 2B-2: the gate is `!semantic_render`, NOT `panel_capable`. + // Stage 1 could conflate them because panel capability implied grid; + // once a semantic session can be panel-capable, a capability-keyed + // gate would feed it exactly the placeholder Q#BP15a forbids, and + // parent acceptance 40 would fail through this line rather than + // through the projection. + if !semantic_render && editor.core.borrow().panel_capable_for(frontend_id) { editor.sync_frame_geometry(frontend_id, initial_size); } @@ -2043,7 +2150,17 @@ fn handle_dispatcher_event( // longer satisfy the panel hides it, moves focus out, // and releases its terminal controller here — before // the next drained event dispatches. - if editor.core.borrow().panel_capable_for(source) { + // + // Stage 2B-2: gated on the absence of a semantic + // projection as well as on capability. A semantic + // frontend's `Resize` describes its own surface in + // whatever units it chose; only `FrontendCellGeometry` + // is its authoritative cell-equivalent declaration + // (Q#BP15a), and letting `Resize` mint an epoch here + // would let the two allocators interleave. + if !semantic_states.contains_key(&source) + && editor.core.borrow().panel_capable_for(source) + { editor.sync_frame_geometry(source, size); } } @@ -2189,6 +2306,80 @@ fn handle_dispatcher_event( ); } } + FrontendEvent::FrontendCellGeometry { + geometry_epoch, + total, + .. + } => { + // Bottom panel Q#BP15a — the frontend's authoritative + // cell-equivalent layout capacity. Routed by the + // authenticated `source`; the payload's `frontend_id` + // is never read. + // + // Deliberately does NOT require a side window: the + // daemon needs columns before it can paint a first + // panel frame, so gating this on panel presence would + // deadlock the first open. "Without a side window" + // refers to side-window presence only — the protocol, + // session, and capability gates all still apply. + if peer_may_send_panel_events(editor, session_registry, semantic_states, source) + { + editor.accept_semantic_frame_geometry(source, geometry_epoch, total); + } + } + FrontendEvent::PanelResizeRows { + geometry_epoch, + panel_epoch, + rows, + .. + } => { + // Bottom panel Q#BP15a / Q#BP16 — a divider drag's + // requested rows. Accepted only against the currently + // visible `Present` declaration, matching BOTH the + // latest accepted frontend geometry and that + // declaration's presentation epoch, so a drag racing a + // font change or a panel reopen cannot resize its + // successor. + if peer_may_send_panel_events(editor, session_registry, semantic_states, source) + && panel_event_epochs_are_current( + editor, + semantic_states, + source, + geometry_epoch, + panel_epoch, + ) + { + editor.apply_panel_resize_rows(source, rows); + } + } + FrontendEvent::PanelPointer { + geometry_epoch, + panel_epoch, + buffer_id, + coord, + kind, + .. + } => { + // Bottom panel Q#BP16 — a gesture the frontend + // hit-tested to a panel CELL. Steps 1, 3, and 4 of the + // ladder are checked here (authenticated source, both + // epochs against the declaration the frontend was + // looking at); steps 2, 5, and 6 are re-derived from + // the daemon's own state inside the dispatcher. Any + // failure drops the event before any view, controller, + // selection, menu, or PTY mutation. + if peer_may_send_panel_events(editor, session_registry, semantic_states, source) + && panel_event_epochs_are_current( + editor, + semantic_states, + source, + geometry_epoch, + panel_epoch, + ) + { + editor.dispatch_semantic_panel_pointer(source, buffer_id, coord, kind); + } + } FrontendEvent::Pointer { buffer_id, byte, @@ -3255,6 +3446,16 @@ fn sync_terminal_layouts_for_tick( if let Some((buffer_id, size)) = state.terminal_viewport() { editor.sync_semantic_terminal_layout(*frontend_id, buffer_id, size); } + // Bottom-panel R1-3: the band is the semantic frontend's + // OTHER terminal surface, and it has no declaration to + // consult — the daemon derives its geometry (Q#BP15a). The + // grid arm below gets this for free because it resolves + // through `controller_view_for_frontend`, which is + // window-agnostic; the semantic arm above is keyed to the + // document declaration and structurally cannot see a side + // window. Both calls target disjoint windows, so this is a + // second CASE, not a second resize of the same child. + editor.sync_semantic_panel_terminal_layout(*frontend_id); } else if let Some(size) = term_sizes.get(frontend_id).copied() { editor.sync_terminal_grid_geometry(*frontend_id, size); } @@ -3401,6 +3602,11 @@ fn apply_event( // A grid session has no panel band at all, so one arriving // here is a protocol violation; drop it rather than letting // a payload-trusted id reach a view. + // + // Stage 2B-2 added the real routing arms, which `peer_may_ + // send_panel_events` already refuses for a grid session, so + // this is now the belt-and-braces half of the same gate — + // exactly like the `Pointer` and `TerminalResize` arms above. eprintln!( "pmacs daemon: panel declaration from a grid session; dropping \ (grid sessions negotiate no panel band)" @@ -5537,4 +5743,1032 @@ mod tests { "#9: a focused TERMINAL panel must not suppress the document viewport — the document window should still have aligned to the declared buffer" ); } + + // ----------------------------------------------------------------- + // Bottom-panel Stage 2B-2 — inbound panel-event routing, driven + // through `handle_dispatcher_event` (the real dispatcher seam). + // + // Deliberately NOT `crdt`-gated: CI never enables that feature, so a + // gated pin is dark exactly where it needs to run. + // ----------------------------------------------------------------- + + /// A semantic, panel-capable frontend. `with_panel` decides whether + /// it also owns a side window — `FrontendCellGeometry` must be + /// accepted **without** one (Q#BP15a breaks the first-open cycle), + /// while the two gesture events must not be. + fn semantic_panel_view( + editor: &crate::editor::EditorState, + fid: FrontendId, + with_panel: bool, + ) -> (crate::window::WindowId, Option) { + use crate::window::{FrontendView, Layout, LayoutNode, Orientation, Window, WindowParams}; + + let mut core = editor.core.borrow_mut(); + let doc_buf = core.active_window().buffer_id; + let document = crate::window::WindowId::next(); + let doc_view = { + let reg = core.registry.borrow(); + crate::text_view::TextView::new(reg.get(doc_buf).expect("doc")) + }; + core.windows + .insert(document, Window::new(document, doc_buf, doc_view)); + let panel = with_panel.then(|| { + let panel_buf = core.registry.borrow_mut().create("*panel*"); + let panel_id = crate::window::WindowId::next(); + let panel_view = { + let reg = core.registry.borrow(); + crate::text_view::TextView::new(reg.get(panel_buf).expect("panel")) + }; + let mut window = Window::new(panel_id, panel_buf, panel_view); + let mut params = WindowParams::default(); + params.side = Some(crate::window::Side::Bottom); + params.fixed_rows = Some(4); + window.params = params; + core.windows.insert(panel_id, window); + panel_id + }); + let layout = match panel { + Some(panel) => Layout { + root: LayoutNode::Split { + orientation: Orientation::Horizontal, + children: vec![LayoutNode::Leaf(document), LayoutNode::Leaf(panel)], + weights: vec![1, 1], + }, + }, + None => Layout::single(document), + }; + core.register_frontend_view( + fid, + FrontendView { + layout, + active: document, + fold_projection: false, + // Stage 2B-2 is dark: production negotiation still sets + // this `false` for every semantic session, so the + // projection is exercised through a test-only view (the + // framing's §7.2.2 posture). + panel_capable: true, + frame_geometry: None, + panel_hidden: false, + }, + ); + (document, panel) + } + + fn session(version: u32, semantic: bool) -> crate::presence::SessionState { + crate::presence::SessionState { + negotiated_protocol_version: version, + negotiated_capabilities: crate::protocol::NegotiatedCapabilities { + semantic_render: semantic, + crdt_replica: true, + ..Default::default() + }, + color_slot: 0, + } + } + + /// Drive one authenticated event through the real dispatcher while + /// keeping the caller's projection state, so a test can ship a + /// `PanelFrame` first and then send an event addressing it. + /// + /// The session is registered because the dispatcher drops any event + /// from an uninstalled session before it reaches a handler. + fn dispatch_panel_event( + editor: &mut crate::editor::EditorState, + fid: FrontendId, + version: u32, + semantic_states: &mut HashMap, + render_states: &mut HashMap, + event: FrontendEvent, + ) { + let mut streams = HashMap::new(); + let mut term_sizes = HashMap::new(); + term_sizes.insert(fid, CellSize::new(24, 80)); + 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(version, !render_states.contains_key(&fid))); + handle_dispatcher_event( + DispatcherEvent::FrontendEvent { source: fid, event }, + editor, + render_states, + semantic_states, + &mut streams, + &mut term_sizes, + &mut last_idle, + &mut last_active, + &mut bells, + &mut registry, + ); + } + + fn geometry_event(claimed: FrontendId, epoch: u64, rows: u32, cols: u32) -> FrontendEvent { + FrontendEvent::FrontendCellGeometry { + frontend_id: claimed, + geometry_epoch: epoch, + total: CellSize::new(rows, cols), + } + } + + /// Criterion 50 (accept half) + Q#BP15a: the declaration is valid + /// with no side window at all. Gating it on panel presence would + /// deadlock the first open, because the daemon needs columns before + /// it can paint a first frame. + #[test] + fn frontend_cell_geometry_is_accepted_without_a_side_window() { + let mut editor = crate::editor::EditorState::new(); + let fid = FrontendId(701); + semantic_panel_view(&editor, fid, false); + let mut semantic_states = HashMap::new(); + semantic_states.insert( + fid, + crate::semantic_render::SemanticRenderState::for_peer(fid, PROTOCOL_VERSION), + ); + + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut semantic_states, + &mut HashMap::new(), + geometry_event(fid, 1, 40, 100), + ); + + let stored = editor.core.borrow().frame_geometry_for(fid); + assert_eq!( + stored.map(|geometry| (geometry.geometry_epoch, geometry.total)), + Some((1, CellSize::new(40, 100))), + "a panel-capable semantic source declares geometry with no side window" + ); + } + + /// Criterion 50 (reject half): a GRID session has no panel band, so + /// its declaration is dropped before it can reach a view. + #[test] + fn frontend_cell_geometry_from_a_grid_session_is_dropped() { + let mut editor = crate::editor::EditorState::new(); + let fid = FrontendId(702); + semantic_panel_view(&editor, fid, false); + // A grid session: a `RenderState`, no semantic projection. + let mut render_states = HashMap::new(); + render_states.insert(fid, RenderState::new(CellSize::new(24, 80))); + // The grid arm would otherwise mint an epoch from its own attach + // size, so start from a known state and assert the epoch never + // answers the wire declaration. + let before = editor.core.borrow().frame_geometry_for(fid); + + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut HashMap::new(), + &mut render_states, + geometry_event(fid, 9, 40, 100), + ); + + let after = editor.core.borrow().frame_geometry_for(fid); + assert_eq!( + before, after, + "a grid session's panel declaration is dropped" + ); + assert_eq!(after, None, "…and nothing was stored at all"); + } + + /// Criterion 50 (reject half): a peer that negotiated v20 never + /// negotiated these variants, so its declaration is not trusted even + /// though its view is panel-capable. + #[test] + fn frontend_cell_geometry_below_the_panel_version_is_dropped() { + let mut editor = crate::editor::EditorState::new(); + let fid = FrontendId(703); + semantic_panel_view(&editor, fid, false); + let mut semantic_states = HashMap::new(); + semantic_states.insert( + fid, + crate::semantic_render::SemanticRenderState::for_peer(fid, PANEL_MIN_VERSION - 1), + ); + + dispatch_panel_event( + &mut editor, + fid, + PANEL_MIN_VERSION - 1, + &mut semantic_states, + &mut HashMap::new(), + geometry_event(fid, 1, 40, 100), + ); + + assert_eq!( + editor.core.borrow().frame_geometry_for(fid), + None, + "a pre-panel semantic peer's declaration is dropped" + ); + } + + /// Criterion 50 (reject half) + Q#BP13: capability is the gate, not + /// only the wire version. A semantic view this daemon did not mark + /// panel-capable declares nothing. + #[test] + fn frontend_cell_geometry_from_a_non_panel_capable_view_is_dropped() { + let mut editor = crate::editor::EditorState::new(); + let fid = FrontendId(704); + let view = build_fresh_frontend_view(&mut editor, false, false); + editor.core.borrow_mut().register_frontend_view(fid, view); + let mut semantic_states = HashMap::new(); + semantic_states.insert( + fid, + crate::semantic_render::SemanticRenderState::for_peer(fid, PROTOCOL_VERSION), + ); + + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut semantic_states, + &mut HashMap::new(), + geometry_event(fid, 1, 40, 100), + ); + + assert_eq!( + editor.core.borrow().frame_geometry_for(fid), + None, + "a non-panel-capable semantic view declares no panel geometry" + ); + } + + /// Criterion 50 (forged-source half): routing is by the + /// authenticated transport source, never the payload's claimed id, so + /// a forged id reaches no other frontend's view. + #[test] + fn a_forged_frontend_id_in_a_geometry_payload_addresses_nothing() { + let mut editor = crate::editor::EditorState::new(); + let source = FrontendId(705); + let victim = FrontendId(706); + semantic_panel_view(&editor, source, false); + semantic_panel_view(&editor, victim, false); + let mut semantic_states = HashMap::new(); + semantic_states.insert( + source, + crate::semantic_render::SemanticRenderState::for_peer(source, PROTOCOL_VERSION), + ); + + dispatch_panel_event( + &mut editor, + source, + PROTOCOL_VERSION, + &mut semantic_states, + &mut HashMap::new(), + geometry_event(victim, 1, 40, 100), + ); + + let core = editor.core.borrow(); + assert_eq!( + core.frame_geometry_for(victim), + None, + "the claimed id must not be able to declare another frontend's geometry" + ); + assert!( + core.frame_geometry_for(source).is_some(), + "…while the authenticated source's own declaration still lands" + ); + } + + /// Ship one real `PanelFrame` so the session holds a live `Present` + /// declaration, and return its two epochs. + fn shipped_declaration( + editor: &crate::editor::EditorState, + fid: FrontendId, + semantic_states: &mut HashMap, + ) -> (u64, u64) { + let sem = semantic_states.get_mut(&fid).expect("semantic projection"); + let messages = sem.render_frame(editor); + assert!( + messages + .iter() + .any(|msg| matches!(msg, InstanceMessage::PanelFrame(_))), + "fixture precondition: the frame must actually ship a panel declaration" + ); + let frame = sem.panel_declaration().expect("a Present declaration"); + (frame.geometry_epoch, frame.panel_epoch) + } + + /// Criterion 50: a gesture from a source whose latest declaration is + /// not a visible `Present` is dropped. + #[test] + fn a_panel_pointer_without_a_present_declaration_is_dropped() { + let mut editor = crate::editor::EditorState::new(); + let fid = FrontendId(707); + let (document, panel) = semantic_panel_view(&editor, fid, true); + let panel = panel.expect("panel window"); + let mut semantic_states = HashMap::new(); + semantic_states.insert( + fid, + crate::semantic_render::SemanticRenderState::for_peer(fid, PROTOCOL_VERSION), + ); + // No geometry declared yet, so nothing has been shipped and the + // seeded baseline is `Absent`. + let buffer_id = editor.core.borrow().windows[&panel].buffer_id; + + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut semantic_states, + &mut HashMap::new(), + FrontendEvent::PanelPointer { + frontend_id: fid, + geometry_epoch: 1, + panel_epoch: 1, + buffer_id, + coord: pmacs_protocol::CellCoord::new(0, 0), + kind: pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left), + mods: pmacs_protocol::Modifiers::default(), + }, + ); + + assert_eq!( + editor.core.borrow().views[&fid].active, + document, + "a gesture with no live Present declaration must not focus the panel" + ); + } + + /// Criterion 49: the accepted case, and the two epoch races beside + /// it. Without the accepted arm the drops above would pass for a + /// dispatcher that ignores the event family entirely. + #[test] + fn panel_pointer_epochs_decide_focus() { + let mut editor = crate::editor::EditorState::new(); + let fid = FrontendId(708); + let (document, panel) = semantic_panel_view(&editor, fid, true); + let panel = panel.expect("panel window"); + let mut semantic_states = HashMap::new(); + semantic_states.insert( + fid, + crate::semantic_render::SemanticRenderState::for_peer(fid, PROTOCOL_VERSION), + ); + editor.accept_semantic_frame_geometry(fid, 1, CellSize::new(24, 80)); + let (geometry_epoch, panel_epoch) = shipped_declaration(&editor, fid, &mut semantic_states); + let buffer_id = editor.core.borrow().windows[&panel].buffer_id; + let down = |geometry_epoch, panel_epoch| FrontendEvent::PanelPointer { + frontend_id: fid, + geometry_epoch, + panel_epoch, + buffer_id, + coord: pmacs_protocol::CellCoord::new(0, 0), + kind: pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left), + mods: pmacs_protocol::Modifiers::default(), + }; + + for (label, event) in [ + ( + "stale geometry epoch", + down(geometry_epoch + 1, panel_epoch), + ), + ( + "stale presentation epoch", + down(geometry_epoch, panel_epoch + 1), + ), + ] { + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut semantic_states, + &mut HashMap::new(), + event, + ); + assert_eq!( + editor.core.borrow().views[&fid].active, + document, + "{label}: the gesture must drop before it can move focus" + ); + } + + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut semantic_states, + &mut HashMap::new(), + down(geometry_epoch, panel_epoch), + ); + assert_eq!( + editor.core.borrow().views[&fid].active, + panel, + "a gesture matching BOTH epochs activates the panel (click-to-focus)" + ); + } + + /// Criterion 49's font/scale/resize race, and the reason Q#BP16 step + /// 3 compares the payload's geometry epoch against **both** the + /// shipped declaration and the daemon's latest accepted one. + /// + /// A declaration accepted between two renders makes the two diverge: + /// the frontend is still looking at a frame answering the old epoch, + /// so a gesture matching that frame passes the session-side check + /// alone. Only the daemon-side comparison catches it — which is + /// exactly "an older retained frame neither paints nor accepts input + /// after a new `geometry_epoch` until a matching `Present` arrives". + #[test] + fn a_gesture_answering_a_superseded_geometry_is_dropped() { + let mut editor = crate::editor::EditorState::new(); + let fid = FrontendId(712); + let (document, panel) = semantic_panel_view(&editor, fid, true); + let panel = panel.expect("panel window"); + let mut semantic_states = HashMap::new(); + semantic_states.insert( + fid, + crate::semantic_render::SemanticRenderState::for_peer(fid, PROTOCOL_VERSION), + ); + editor.accept_semantic_frame_geometry(fid, 1, CellSize::new(24, 80)); + let (geometry_epoch, panel_epoch) = shipped_declaration(&editor, fid, &mut semantic_states); + let buffer_id = editor.core.borrow().windows[&panel].buffer_id; + + // Q#BP2S1: a font or scale change that leaves `CellSize` + // IDENTICAL still advances the epoch, and no frame answering it + // has been painted yet. + assert_eq!( + editor.accept_semantic_frame_geometry(fid, 2, CellSize::new(24, 80)), + crate::editor_core::GeometryUpdate::Advanced + ); + assert_eq!( + semantic_states[&fid] + .panel_declaration() + .map(|frame| frame.geometry_epoch), + Some(geometry_epoch), + "fixture precondition: the SHIPPED declaration still answers the \ + old epoch, so the session-side check alone would accept" + ); + + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut semantic_states, + &mut HashMap::new(), + FrontendEvent::PanelPointer { + frontend_id: fid, + geometry_epoch, + panel_epoch, + buffer_id, + coord: pmacs_protocol::CellCoord::new(0, 0), + kind: pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left), + mods: pmacs_protocol::Modifiers::default(), + }, + ); + + assert_eq!( + editor.core.borrow().views[&fid].active, + document, + "a gesture hit-tested against superseded geometry must drop" + ); + + // The accepted counterpart: once a frame answering the new epoch + // ships, the same gesture shape is honored again. + let (fresh_geometry, fresh_panel) = shipped_declaration(&editor, fid, &mut semantic_states); + assert_eq!(fresh_geometry, 2, "the new frame answers the new epoch"); + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut semantic_states, + &mut HashMap::new(), + FrontendEvent::PanelPointer { + frontend_id: fid, + geometry_epoch: fresh_geometry, + panel_epoch: fresh_panel, + buffer_id, + coord: pmacs_protocol::CellCoord::new(0, 0), + kind: pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left), + mods: pmacs_protocol::Modifiers::default(), + }, + ); + assert_eq!( + editor.core.borrow().views[&fid].active, + panel, + "…and a gesture answering the CURRENT geometry is accepted" + ); + } + + /// Criteria 49/50 for the resize event: matching epochs move the + /// stored request, a stale presentation epoch does not. + #[test] + fn panel_resize_rows_honors_both_epochs() { + let mut editor = crate::editor::EditorState::new(); + let fid = FrontendId(709); + let (_document, panel) = semantic_panel_view(&editor, fid, true); + let panel = panel.expect("panel window"); + let mut semantic_states = HashMap::new(); + semantic_states.insert( + fid, + crate::semantic_render::SemanticRenderState::for_peer(fid, PROTOCOL_VERSION), + ); + editor.accept_semantic_frame_geometry(fid, 1, CellSize::new(24, 80)); + let (geometry_epoch, panel_epoch) = shipped_declaration(&editor, fid, &mut semantic_states); + let before = editor.core.borrow().windows[&panel].params.fixed_rows; + + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut semantic_states, + &mut HashMap::new(), + FrontendEvent::PanelResizeRows { + frontend_id: fid, + geometry_epoch, + panel_epoch: panel_epoch + 1, + rows: 9, + }, + ); + assert_eq!( + editor.core.borrow().windows[&panel].params.fixed_rows, + before, + "a stale presentation epoch must not resize the panel" + ); + + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut semantic_states, + &mut HashMap::new(), + FrontendEvent::PanelResizeRows { + frontend_id: fid, + geometry_epoch, + panel_epoch, + rows: 9, + }, + ); + assert_eq!( + editor.core.borrow().windows[&panel].params.fixed_rows, + Some(9), + "matching epochs move the stored request" + ); + } + + /// Parent acceptance 40, at the seam it would actually break: a + /// semantic frontend's `Resize` must not mint frame geometry, even + /// when its view is panel-capable. + /// + /// Stage 1's gate was `panel_capable_for`, which was equivalent to + /// "grid" only because no semantic session could be panel-capable. + /// Once one can be, that gate feeds it exactly the placeholder + /// Q#BP15a forbids — and the failure would show up as a wrongly + /// sized first panel, far from this line. + #[test] + fn a_semantic_resize_does_not_mint_frame_geometry() { + let mut editor = crate::editor::EditorState::new(); + let fid = FrontendId(710); + semantic_panel_view(&editor, fid, true); + let mut semantic_states = HashMap::new(); + semantic_states.insert( + fid, + crate::semantic_render::SemanticRenderState::for_peer(fid, PROTOCOL_VERSION), + ); + + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut semantic_states, + &mut HashMap::new(), + FrontendEvent::Resize { + frontend_id: fid, + size: CellSize::new(24, 80), + }, + ); + + assert_eq!( + editor.core.borrow().frame_geometry_for(fid), + None, + "40: only FrontendCellGeometry is a semantic frontend's \ + authoritative cell-equivalent declaration" + ); + + // The discriminating half: a GRID frontend's resize still is its + // declaration, so the gate is not simply switched off. + let grid = FrontendId(711); + let view = build_fresh_frontend_view(&mut editor, true, true); + editor.core.borrow_mut().register_frontend_view(grid, view); + let mut render_states = HashMap::new(); + render_states.insert(grid, RenderState::new(CellSize::new(24, 80))); + dispatch_panel_event( + &mut editor, + grid, + PROTOCOL_VERSION, + &mut HashMap::new(), + &mut render_states, + FrontendEvent::Resize { + frontend_id: grid, + size: CellSize::new(30, 90), + }, + ); + assert_eq!( + editor + .core + .borrow() + .frame_geometry_for(grid) + .map(|geometry| geometry.total), + Some(CellSize::new(30, 90)), + "a grid frontend's real frame size IS its declaration" + ); + } + + // ----------------------------------------------------------------- + // Review round 1 — three findings the mutation pass could not reach, + // because each is a behaviour that was never modelled rather than a + // line that was written wrong. + // ----------------------------------------------------------------- + + /// R1-2: closing and reopening the SAME persistent buffer inside one + /// dispatcher burst — before the next render can ship a new + /// declaration — must not let a stale gesture address the successor. + /// + /// Same buffer, same size, same geometry: `buffer_id` and the grid + /// bounds are identical on both sides, so the only thing that can + /// tell the two presentations apart is the presentation identity + /// itself (Q#BP16). Validating the last SHIPPED declaration alone is + /// not enough — it still describes the dead window. + #[test] + #[allow( + clippy::too_many_lines, + reason = "one close/reopen transaction plus both event kinds and the accepted counterpart" + )] + fn a_stale_panel_epoch_cannot_address_a_reopened_same_buffer_panel() { + let mut editor = crate::editor::EditorState::new(); + let fid = FrontendId(713); + let (document, panel) = semantic_panel_view(&editor, fid, true); + let first_panel = panel.expect("panel window"); + let mut semantic_states = HashMap::new(); + semantic_states.insert( + fid, + crate::semantic_render::SemanticRenderState::for_peer(fid, PROTOCOL_VERSION), + ); + editor.accept_semantic_frame_geometry(fid, 1, CellSize::new(24, 80)); + let (geometry_epoch, panel_epoch) = shipped_declaration(&editor, fid, &mut semantic_states); + let buffer_id = editor.core.borrow().windows[&first_panel].buffer_id; + + // Close and reopen the SAME buffer, with no render in between. + { + let mut core = editor.core.borrow_mut(); + core.active_frontend = fid; + core.focus_window(fid, first_panel); + assert!( + core.close_active(), + "closing a side window is legal even as the only other window" + ); + } + 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); + let second_panel = editor.core.borrow().side_window_for(fid).expect("reopened"); + assert_ne!( + second_panel, first_panel, + "fixture precondition: the successor is a different window" + ); + assert_eq!( + editor.core.borrow().windows[&second_panel].buffer_id, + buffer_id, + "…showing the SAME persistent buffer, which is what makes it \ + indistinguishable by buffer id" + ); + assert_eq!( + editor.core.borrow().views[&fid].active, + document, + "fixture precondition: focus is on the document after the reopen" + ); + + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut semantic_states, + &mut HashMap::new(), + FrontendEvent::PanelPointer { + frontend_id: fid, + geometry_epoch, + panel_epoch, + buffer_id, + coord: pmacs_protocol::CellCoord::new(0, 0), + kind: pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left), + mods: pmacs_protocol::Modifiers::default(), + }, + ); + assert_eq!( + editor.core.borrow().views[&fid].active, + document, + "R1-2: a gesture aimed at the CLOSED presentation must not \ + activate its successor" + ); + + // …and the resize event follows the same ladder. + let before = editor.core.borrow().windows[&second_panel] + .params + .fixed_rows; + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut semantic_states, + &mut HashMap::new(), + FrontendEvent::PanelResizeRows { + frontend_id: fid, + geometry_epoch, + panel_epoch, + rows: 9, + }, + ); + assert_eq!( + editor.core.borrow().windows[&second_panel] + .params + .fixed_rows, + before, + "R1-2: nor resize it" + ); + + // The accepted counterpart: once a frame describing the successor + // ships, the same gesture shape is honored. + let (fresh_geometry, fresh_panel_epoch) = + shipped_declaration(&editor, fid, &mut semantic_states); + assert_ne!( + fresh_panel_epoch, panel_epoch, + "the successor took a fresh presentation identity" + ); + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut semantic_states, + &mut HashMap::new(), + FrontendEvent::PanelPointer { + frontend_id: fid, + geometry_epoch: fresh_geometry, + panel_epoch: fresh_panel_epoch, + buffer_id, + coord: pmacs_protocol::CellCoord::new(0, 0), + kind: pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left), + mods: pmacs_protocol::Modifiers::default(), + }, + ); + assert_eq!( + editor.core.borrow().views[&fid].active, + second_panel, + "…and the CURRENT presentation is addressable" + ); + } + + /// R2-5: Q#BP16 keeps scroll-without-focus for a NON-terminal panel. + /// Non-`Move` activation is the terminal-specific clause, because the + /// shared terminal adapter claims the controller for wheel steps too. + #[test] + #[allow( + clippy::too_many_lines, + reason = "the two panel kinds are the discriminating pair and must share one fixture shape" + )] + fn a_wheel_step_focuses_a_terminal_panel_but_not_a_document_panel() { + use crate::terminal::TerminalSpec; + + // --- non-terminal panel: the wheel must NOT focus ------------- + let mut editor = crate::editor::EditorState::new(); + let fid = FrontendId(714); + let (document, panel) = semantic_panel_view(&editor, fid, true); + let panel = panel.expect("panel window"); + let mut semantic_states = HashMap::new(); + semantic_states.insert( + fid, + crate::semantic_render::SemanticRenderState::for_peer(fid, PROTOCOL_VERSION), + ); + editor.accept_semantic_frame_geometry(fid, 1, CellSize::new(24, 80)); + let (geometry_epoch, panel_epoch) = shipped_declaration(&editor, fid, &mut semantic_states); + let buffer_id = editor.core.borrow().windows[&panel].buffer_id; + let wheel = |buffer_id, geometry_epoch, panel_epoch| FrontendEvent::PanelPointer { + frontend_id: fid, + geometry_epoch, + panel_epoch, + buffer_id, + coord: pmacs_protocol::CellCoord::new(0, 0), + kind: pmacs_protocol::MouseKind::ScrollUp, + mods: pmacs_protocol::Modifiers::default(), + }; + + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut semantic_states, + &mut HashMap::new(), + wheel(buffer_id, geometry_epoch, panel_epoch), + ); + assert_eq!( + editor.core.borrow().views[&fid].active, + document, + "R2-5: wheel motion over a document panel scrolls without focus" + ); + + // …while a press still focuses it, so the assertion above is not + // "panel pointers do nothing". + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut semantic_states, + &mut HashMap::new(), + FrontendEvent::PanelPointer { + frontend_id: fid, + geometry_epoch, + panel_epoch, + buffer_id, + coord: pmacs_protocol::CellCoord::new(0, 0), + kind: pmacs_protocol::MouseKind::Down(pmacs_protocol::MouseButton::Left), + mods: pmacs_protocol::Modifiers::default(), + }, + ); + assert_eq!( + editor.core.borrow().views[&fid].active, + panel, + "click-to-focus is unchanged" + ); + + // --- terminal panel: every non-Move gesture DOES focus -------- + let mut editor = crate::editor::EditorState::new(); + let fid = FrontendId(715); + let (document, panel) = semantic_panel_view(&editor, fid, true); + let panel = panel.expect("panel window"); + 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 text_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 = text_view; + } + let mut semantic_states = HashMap::new(); + semantic_states.insert( + fid, + crate::semantic_render::SemanticRenderState::for_peer(fid, PROTOCOL_VERSION), + ); + editor.accept_semantic_frame_geometry(fid, 1, CellSize::new(24, 80)); + let (geometry_epoch, panel_epoch) = shipped_declaration(&editor, fid, &mut semantic_states); + assert_eq!( + editor.core.borrow().views[&fid].active, + document, + "fixture precondition: the terminal panel starts passive" + ); + + dispatch_panel_event( + &mut editor, + fid, + PROTOCOL_VERSION, + &mut semantic_states, + &mut HashMap::new(), + wheel(terminal_buffer, geometry_epoch, panel_epoch), + ); + assert_eq!( + editor.core.borrow().views[&fid].active, + panel, + "R2-5: a TERMINAL panel activates on every non-Move gesture, \ + because the shared adapter claims the controller for wheel \ + steps too" + ); + } + + /// R1-3: a semantic frontend's PANEL terminal must be resized to the + /// daemon-derived content grid before the child's output is drained. + /// + /// The two terminal-layout syncs are twins and must stay alternatives + /// (that is why `sync_terminal_layouts_for_tick` exists), but the grid + /// twin resolves through `controller_view_for_frontend` and therefore + /// covers a side window for free, while the semantic twin consulted + /// only the full-document declaration — which a panel terminal + /// deliberately does not have. + #[test] + fn a_semantic_panel_terminal_is_resized_before_the_child_drain() { + use crate::terminal::{TerminalSpec, view::TerminalViewKey}; + + let mut editor = crate::editor::EditorState::new(); + let fid = FrontendId(716); + let (_document, panel) = semantic_panel_view(&editor, fid, true); + let panel = panel.expect("panel window"); + 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 text_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 = text_view; + } + // The panel view owns the child: only the durable controller's + // declaration reaches the PTY, and the per-tick liveness pass + // releases a controller whose window is not focused, so the panel + // has to actually own focus. Registering the view first mirrors + // what the first projection does. + let key = TerminalViewKey::new(fid, panel, terminal_buffer); + editor.core.borrow_mut().focus_window(fid, panel); + { + let mut manager = editor.terminal_manager.borrow_mut(); + manager.record_view_size(key, CellSize::new(4, 20)); + assert!( + manager.claim_controller(key), + "fixture precondition: the panel view controls the child" + ); + } + + let mut semantic_states = HashMap::new(); + semantic_states.insert( + fid, + crate::semantic_render::SemanticRenderState::for_peer(fid, PROTOCOL_VERSION), + ); + editor.accept_semantic_frame_geometry(fid, 1, CellSize::new(24, 120)); + assert_eq!( + editor.core.borrow().panel_grid_size(fid), + Some(CellSize::new(4, 120)), + "fixture precondition: the daemon derives a 4x120 band, so its \ + CONTENT grid is 3x120" + ); + assert_eq!( + editor + .terminal_manager + .borrow() + .screen_size(terminal_buffer), + Some(CellSize::new(4, 20)), + "fixture precondition: the child still has its opening size" + ); + + sync_terminal_layouts_for_tick( + &mut editor, + &[fid], + &HashMap::from([(fid, CellSize::new(24, 120))]), + &semantic_states, + ); + + assert_eq!( + editor + .terminal_manager + .borrow() + .screen_size(terminal_buffer), + Some(CellSize::new(3, 120)), + "R1-3: the panel terminal adopts the daemon-derived content \ + grid at the tick's layout step, BEFORE tick_processes drains \ + the child" + ); + } + + /// Q#BP9's write-loop gate, independent of the producer's own flag. + #[test] + fn the_panel_frame_write_gate_rejects_v20_independently() { + let frame = InstanceMessage::PanelFrame(pmacs_protocol::panel::PanelFramePayload::Absent); + assert!(!peer_accepts_panel_message(PANEL_MIN_VERSION - 1, &frame)); + assert!(peer_accepts_panel_message(PANEL_MIN_VERSION, &frame)); + assert!( + peer_accepts_panel_message( + PANEL_MIN_VERSION - 1, + &InstanceMessage::DispatchIdle { idle: true } + ), + "the filter must be scoped to the panel variant" + ); + } } diff --git a/src/editor.rs b/src/editor.rs index 935ee4c..4be27be 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -25,7 +25,7 @@ use unicode_width::UnicodeWidthStr; use crate::async_runtime::SharedAsyncRuntime; use crate::cell::{CellCoord, CellSize}; -use crate::editor_core::EditorCore; +use crate::editor_core::{EditorCore, GeometryUpdate}; use crate::frontend::{Event, Frontend, KeyEvent, KeyEventKind, MouseEvent, install_panic_hook}; use crate::key::{Chord, display_sequence}; use crate::keymap_stack::{Action, KeyDispatcher}; @@ -1196,13 +1196,49 @@ impl EditorState { /// (Q#BP2b / Q#BP15a). /// /// The single seam for grid and `LOCAL` views, whose real attach and - /// resize sizes ARE the declaration. A semantic view never calls this - /// in Stage 1; its geometry stays **unknown**. - pub fn sync_frame_geometry(&self, frontend_id: FrontendId, total: CellSize) { - self.core + /// resize sizes ARE the declaration. A semantic view never calls this; + /// its geometry arrives through + /// [`Self::accept_semantic_frame_geometry`]. + /// + /// Reconciliation runs on every call, not only on + /// [`GeometryUpdate::Advanced`]: panel presentability depends on the + /// layout as well as on the geometry, and this is also the defensive + /// pre-paint reconciliation point. The exhaustion arm is exactly why + /// it must still run after a `Rejected` — `declare_frame_geometry` + /// cleared the declaration to unknown, and the panel has to hide. + pub fn sync_frame_geometry(&self, frontend_id: FrontendId, total: CellSize) -> GeometryUpdate { + let update = self + .core .borrow_mut() .declare_frame_geometry(frontend_id, total); self.reconcile_panel_layout(frontend_id); + update + } + + /// Accept an authenticated semantic frontend's + /// `FrontendEvent::FrontendCellGeometry` declaration (Q#BP15a). + /// + /// The three outcomes are acted on differently, and that is the whole + /// point of the three-valued result: `Advanced` reconciles panel + /// layout, `Duplicate` returns without touching panel state, and + /// `Rejected` drops the event before any reconciliation. A + /// `Duplicate` that reconciled would do redundant work on every + /// repeated declaration; a `Rejected` that reconciled would let a + /// stale or conflicting declaration move the panel. + pub fn accept_semantic_frame_geometry( + &self, + frontend_id: FrontendId, + geometry_epoch: u64, + total: CellSize, + ) -> GeometryUpdate { + let update = + self.core + .borrow_mut() + .accept_frame_geometry(frontend_id, geometry_epoch, total); + if update == GeometryUpdate::Advanced { + self.reconcile_panel_layout(frontend_id); + } + update } /// Local-frontend compatibility wrapper. @@ -1834,6 +1870,91 @@ impl EditorState { } } + /// Sync a semantic frontend's **panel** terminal to the + /// daemon-derived content grid (Q#BP7 / Q#BP15a). + /// + /// The sibling of [`Self::sync_semantic_terminal_layout`], and the + /// case review round 1 (R1-3) found missing. The two arms are + /// disjoint by construction rather than by discipline: + /// `sync_semantic_terminal_layout` resolves its window through + /// `primary_document_window`, so it can never reach a side window, + /// and this one resolves through `side_window_for`, so it can never + /// reach the document. Nothing is ever resized twice per tick — the + /// failure mode the extraction of `sync_terminal_layouts_for_tick` + /// exists to prevent. + /// + /// A panel terminal has **no** `FrontendEvent::TerminalResize` + /// declaration to consult: the daemon derives its geometry, the + /// frontend never asserts it (Q#BP15a). So the size comes from + /// `panel_grid_size` minus the panel's one mode line, and it must be + /// applied at the tick's layout step — before `tick_processes` + /// drains the child — or the program formats its output against a + /// geometry the band is not showing. + /// + /// Recording the view size is unconditional for a resolvable panel + /// (that is what gives a passive view its own clipped projection); + /// only the durable controller resizes the shared PTY. + /// + /// Returns whether the shared screen geometry actually changed. + pub fn sync_semantic_panel_terminal_layout(&mut self, frontend_id: FrontendId) -> bool { + let Some((window_id, buffer_id, content)) = ({ + let core = self.core.borrow(); + core.panel_grid_size(frontend_id).and_then(|size| { + let window_id = core.side_window_for(frontend_id)?; + let buffer_id = core.windows.get(&window_id)?.buffer_id; + Some(( + window_id, + buffer_id, + CellSize::new(size.rows.saturating_sub(1), size.cols), + )) + }) + }) else { + return false; + }; + if content.rows == 0 || content.cols == 0 { + return false; + } + if !self.terminal_manager.borrow().is_terminal(buffer_id) { + return false; + } + let key = TerminalViewKey::new(frontend_id, window_id, buffer_id); + let content = terminal_projection_size(content); + if !self + .terminal_manager + .borrow_mut() + .record_view_size(key, content) + { + return false; + } + let controls = self + .terminal_manager + .borrow() + .controller(buffer_id) + .is_some_and(|controller| controller.matches(key)); + if !controls { + return false; + } + if self.terminal_manager.borrow().screen_size(buffer_id) == Some(content) { + return false; + } + let (Ok(rows), Ok(cols)) = (u16::try_from(content.rows), u16::try_from(content.cols)) + else { + return false; + }; + let result = self.terminal_manager.borrow_mut().resize( + buffer_id, + rows, + cols, + &mut self.process_supervisor.borrow_mut(), + ); + if let Err(error) = result { + self.core.borrow_mut().status = error.to_string(); + false + } else { + true + } + } + /// Apply a semantic frontend's terminal-cell pointer gesture. /// /// The gesture must name the authenticated frontend's active @@ -1875,6 +1996,257 @@ impl EditorState { true } + /// Paint one semantic frontend's side window into a panel-sized grid + /// (Q#BP8, Q#BP15, Q#BP15a, Q#BP17). + /// + /// Returns `None` for every non-presentable state — no side window, + /// a hidden panel, unknown geometry, a zero-column frame, or a grid + /// too small for the structural floor. The caller turns that into an + /// **authoritative** + /// [`pmacs_protocol::panel::PanelFramePayload::Absent`]: silence + /// would leave the receiver's retained band on screen forever. + /// + /// `statusline` is the side window's evaluated segments, supplied by + /// the caller from the *same* provider invocation that produced the + /// document's wire segments (parent acceptance 45). Evaluating again + /// here would run every provider twice per frame. + /// + /// **Folds are gated on the OWNING frontend (Q#BP17).** The panel is + /// painted for `frontend_id`, which is not necessarily the acting + /// frontend, so `EditorCore::fold_map_for_window` — which gates on + /// the *active* frontend — is the wrong source and is deliberately + /// not called. + #[must_use] + #[allow( + clippy::too_many_lines, + reason = "one panel paint transaction: derive the grid, paint the window, resolve the caret" + )] + pub fn prepare_panel_projection( + &self, + frontend_id: FrontendId, + statusline: Option<&crate::statusline::StatuslineWindowSegments>, + ) -> Option { + let (size, window_id, buffer_id, focused, fold_projection) = { + let core = self.core.borrow(); + let size = core.panel_grid_size(frontend_id)?; + let window_id = core.side_window_for(frontend_id)?; + let buffer_id = core.windows.get(&window_id)?.buffer_id; + let view = core.views.get(&frontend_id)?; + ( + size, + window_id, + buffer_id, + view.active == window_id, + view.fold_projection, + ) + }; + let outer = Rect::new(0, 0, size.rows, size.cols); + let content = Rect::new(0, 0, size.rows.saturating_sub(1), size.cols); + let placement = WindowPlacement { outer, content }; + let theme = { + let handle = self.syntax_registry.theme(); + let t = handle.lock().expect("theme mutex poisoned"); + t.clone() + }; + let mut cells = vec![crate::cell::Cell::default(); (size.rows * size.cols) as usize]; + let mut grid = crate::cell::CellGrid { + cells: &mut cells, + stride: size.cols, + size, + }; + + // Q#BP7 / Q#BP15a: a terminal panel's grid excludes its one mode + // line, and its geometry reaches the shared screen through the + // same view-size path the grid frontends use — never the 24×80 + // attach placeholder and never the full-window declaration. + let terminal = self.terminal_manager.borrow().is_terminal(buffer_id); + let cursor = if terminal { + let key = TerminalViewKey::new(frontend_id, window_id, buffer_id); + let snapshot = self + .terminal_manager + .borrow_mut() + .snapshot_for_view(key, terminal_projection_size(content.size))?; + paint_terminal_snapshot(&mut grid, content, &snapshot, &theme); + let registry = self.core.borrow().registry.clone(); + let reg = registry.borrow(); + if let Ok(buf) = reg.get(buffer_id) { + let coord = snapshot.cursor.unwrap_or_default(); + let scroll = if snapshot.scroll_offset == 0 { + String::new() + } else { + format!("↑{}", snapshot.scroll_offset) + }; + paint_mode_line( + &mut grid, + &outer, + buf.name(), + false, + focused, + coord.row, + coord.col, + &scroll, + "", + mode_line_style(&theme), + statusline.map_or(&[], |segments| segments.left.as_slice()), + statusline.map_or(&[], |segments| segments.right.as_slice()), + &theme, + ); + } + snapshot + .cursor + .filter(|coord| coord.row < content.size.rows && coord.col < content.size.cols) + } else { + let registry = self.core.borrow().registry.clone(); + let reg = registry.borrow(); + let diag_store = self.lsp_manager.borrow().diag_store(); + let mut core = self.core.borrow_mut(); + let window = core.windows.get_mut(&window_id)?; + let buf = reg.get(buffer_id).ok()?; + let folds = if fold_projection { + crate::fold_view::map_for_window(&self.fold_registry, window) + } else { + None + }; + window.last_visible_rows = content.size.rows; + // A2A-3 / parent 48: the auto-scroll clamp belongs to the + // FOCUSED window only. Running it for a passive panel would + // move a `view_top` the user is not driving. + if focused { + prepare_window_cursor_visible(window, buf, content.size.rows, folds.as_ref()); + } + paint_window_content( + &mut grid, + window, + buf, + placement, + folds.as_ref(), + focused, + &theme, + statusline, + &diag_store, + ); + window_cursor_cell(window, buf, folds.as_ref(), outer) + }; + + Some(PanelProjection { + window_id, + buffer_id, + size, + cells, + cursor, + focused, + }) + } + + /// Apply an accepted `FrontendEvent::PanelResizeRows` (Q#BP15a). + /// + /// The request is expressed as a boundary move rather than a direct + /// `fixed_rows` write, so it lands on the same Q#BP5b clamp path a + /// TUI divider drag takes — including the interactive + /// `window.min-height` preference resolved per leaf. A request the + /// clamp cannot satisfy is a no-op, not an error. + /// + /// Returns whether the effective allocation actually moved. + pub fn apply_panel_resize_rows(&self, frontend_id: FrontendId, rows: u32) -> bool { + let (side, area_rows, current) = { + let core = self.core.borrow(); + match ( + core.side_window_for(frontend_id), + core.frontend_area_rows(frontend_id), + ) { + (Some(side), Some(area_rows)) => ( + side, + area_rows, + core.panel_allocation(frontend_id, area_rows), + ), + _ => return false, + } + }; + let Some(current) = current else { + return false; + }; + let Ok(rows) = EditorCore::clamp_panel_rows(rows) else { + return false; + }; + let Ok(delta) = i32::try_from(i64::from(rows) - i64::from(current)) else { + return false; + }; + if delta == 0 { + return false; + } + let _ = self.resize_window_boundary(frontend_id, side, delta, area_rows); + self.reconcile_panel_layout(frontend_id); + self.core + .borrow() + .panel_allocation(frontend_id, area_rows) + .is_some_and(|now| now != current) + } + + /// Apply an accepted `FrontendEvent::PanelPointer` gesture (Q#BP16). + /// + /// 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 + /// buffer matches the payload, and a coordinate inside the grid the + /// daemon derived. Steps 1, 3, and 4 (source authentication and both + /// epochs) belong to the caller, because only the session holds the + /// declaration the frontend was actually looking at. + /// + /// **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. + /// Everything else depends on what the panel holds: + /// + /// * a **terminal** panel activates on *every* non-`Move` gesture, + /// because the shared terminal adapter claims the controller for + /// wheel, press, drag, and release alike — leaving a wheel step + /// unactivated would hand the child to a window that does not own + /// focus; + /// * a **document** panel keeps today's **scroll-without-focus** + /// behaviour, matching `dispatch_mouse`, where a wheel notch moves + /// a viewport without selecting the window (and preserves a kill + /// chain for the same reason). + /// + /// 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. + pub fn dispatch_semantic_panel_pointer( + &self, + frontend_id: FrontendId, + buffer_id: crate::buffer::BufferId, + coord: CellCoord, + kind: pmacs_protocol::MouseKind, + ) -> bool { + 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) { + return false; + } + let activates = if is_terminal { + !matches!(kind, pmacs_protocol::MouseKind::Move) + } else { + matches!(kind, pmacs_protocol::MouseKind::Down(_)) + }; + if activates { + core.focus_window(frontend_id, side); + core.active_frontend = frontend_id; + } + true + } + /// Precompute owned terminal view snapshots before entering paint borrows. pub fn prepare_terminal_views( &mut self, @@ -3085,6 +3457,59 @@ const SCROLL_LINES: i32 = 3; /// therefore only appears when a line-number mode reserves a gutter. const FOLD_GUTTER_GLYPH: char = '▸'; +/// The largest viewport the terminal subsystem will actually project, +/// for a window content rect that may legitimately be larger. +/// +/// 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 512 columns, and `PanelFrame` answers only to the shared area +/// bound. The terminal *screen* keeps its own policy, so without this +/// clamp `snapshot_for_view` refused the panel's content rect, the whole +/// projection collapsed to `None`, and the band went per-frame `Absent` +/// while `panel_hidden` still said "visible" — review round 1's R1-1 +/// shape, found again by its own sweep. +/// +/// Clamping rather than hiding is the right answer because the band is +/// legitimately that wide: the child occupies the columns a PTY can +/// have, and the remainder paints as band background exactly as a +/// snapshot narrower than its window already does. Rows are shed for the +/// area bound rather than columns, so a wide band keeps its full width. +fn terminal_projection_size(content: CellSize) -> CellSize { + let cols = content + .cols + .min(u32::from(crate::terminal::MAX_TERMINAL_COLS)); + let rows = content + .rows + .min(u32::from(crate::terminal::MAX_TERMINAL_ROWS)); + let rows_within_area = + u32::try_from(crate::terminal::MAX_TERMINAL_VISIBLE_CELLS / (cols as usize).max(1)) + .unwrap_or(u32::MAX); + CellSize::new(rows.min(rows_within_area), cols) +} + +/// One painted side window, ready to become a +/// [`pmacs_protocol::panel::PanelFrame`] (bottom-panel Stage 2B-2). +/// +/// The producer carries the identity fields as well as the cells because +/// the presentation epoch is allocated from them: `window_id` changes on +/// a new side window and `buffer_id` on a replacement, and either one +/// moving is what makes a stale `PanelPointer` unaddressable (Q#BP16). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PanelProjection { + /// The side window this frame projects. + pub window_id: WindowId, + /// Buffer that window is currently showing. + pub buffer_id: crate::buffer::BufferId, + /// Panel grid dimensions, mode line included. + pub size: CellSize, + /// Row-major cells; exactly `size.area()` entries. + pub cells: Vec, + /// Panel caret, or `None` when it is scrolled out of the band. + pub cursor: Option, + /// Whether the panel currently owns this frontend's focus. + pub focused: bool, +} + /// Shared outer/content geometry consumed by terminal paint and PTY resize. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) struct WindowPlacement { @@ -3943,44 +4368,65 @@ pub fn paint_frame( let registry = core.registry.clone(); let reg = registry.borrow(); let aw = &core.windows[&active]; - let inner_rows = inner_rows(&active_rect); let buf = reg.get(aw.buffer_id).ok()?; - // Arc 6 Stage 2 (Q#FD16, round-2 F3): a logical cursor on a hidden - // line renders at its hidden component's head POSITION — the visible - // head row *and* that head's end-of-content column, i.e. exactly - // where Stage 1 moves point on a fold-at-cursor. Row-only clamping - // would leave the column unspecified; resolving through the merged - // component (rather than the innermost containing fold) also keeps a - // crossing overlap from landing on another hidden position. let folds = crate::fold_view::map_for_window(&state.fold_registry, aw); - let cursor = match folds.as_ref() { - Some(map) => map.visible_position(aw.text_view.line_at_offset(aw.cursor), aw.cursor), - None => aw.cursor, + window_cursor_cell(aw, buf, folds.as_ref(), active_rect) +} + +/// Where one window's caret lands in the cell grid, or `None` when it is +/// scrolled out of that window's text area. +/// +/// Extracted from `paint_frame`'s tail for bottom-panel Stage 2B-2: the +/// panel band ships its own caret in +/// [`pmacs_protocol::panel::PanelFrame::cursor`], and a second derivation +/// would be the exact shape of Stage 1's `Layout::compute` two-caller +/// defect — one consumer silently reckoning against different geometry. +/// +/// Arc 6 Stage 2 (Q#FD16, round-2 F3): a logical cursor on a hidden line +/// renders at its hidden component's head POSITION — the visible head row +/// *and* that head's end-of-content column, i.e. exactly where Stage 1 +/// moves point on a fold-at-cursor. Row-only clamping would leave the +/// column unspecified; resolving through the merged component (rather +/// than the innermost containing fold) also keeps a crossing overlap from +/// landing on another hidden position. +fn window_cursor_cell( + window: &crate::window::Window, + buf: &crate::buffer::Buffer, + folds: Option<&crate::fold_view::VisibleLineMap>, + rect: Rect, +) -> Option { + let inner_rows = inner_rows(&rect); + let cursor = match folds { + Some(map) => map.visible_position( + window.text_view.line_at_offset(window.cursor), + window.cursor, + ), + None => window.cursor, }; - let disp = aw.text_view.pos_to_display(buf, cursor)?; - let row_offset = match folds.as_ref() { + let disp = window.text_view.pos_to_display(buf, cursor)?; + let row_offset = match folds { Some(map) => { - let top = map.clamp_view_top(aw.view_top); + let top = map.clamp_view_top(window.view_top); let row = disp.row as usize; if row < top { return None; } map.visible_rows_between(top, row) } - None => (disp.row as usize).checked_sub(aw.view_top)?, + None => (disp.row as usize).checked_sub(window.view_top)?, }; if row_offset >= inner_rows as usize { return None; } - // UX gutter: the terminal caret sits in the text area, past the - // reserved gutter strip (mirrors the viewport shift above). + // UX gutter: the caret sits in the text area, past the reserved + // gutter strip (mirrors the viewport shift in `paint_window_content`). let gutter_w = { - let w = aw.gutter_width(); - if w >= active_rect.size.cols { 0 } else { w } + let w = window.gutter_width(); + if w >= rect.size.cols { 0 } else { w } }; - let grid_row = active_rect.origin.row + u32::try_from(row_offset).ok()?; - let max_col = active_rect.origin.col + active_rect.size.cols.saturating_sub(1); - let grid_col = (active_rect.origin.col + gutter_w + disp.col).min(max_col); + let grid_row = rect.origin.row + u32::try_from(row_offset).ok()?; + let max_col = rect.origin.col + rect.size.cols.saturating_sub(1); + let grid_col = (rect.origin.col + gutter_w + disp.col).min(max_col); Some(CellCoord::new(grid_row, grid_col)) } diff --git a/src/editor_core.rs b/src/editor_core.rs index 7fd90c6..661b767 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -236,6 +236,28 @@ pub struct PanelReconciliation { pub released_terminal: Option, } +/// What a frame-geometry declaration did (Q#BP2S1, Stage 2 §3.1). +/// +/// Three-valued rather than a boolean because the caller must act +/// differently on each, and collapsing the middle arm is a defect in one +/// direction or the other: folded into `Advanced` it reconciles panel +/// layout on every repeated declaration; folded into `Rejected` it +/// reports a stale-event condition that never happened. A `Duplicate` +/// **is** accepted — which is why a narrower internal boolean would have +/// to be named `advanced`, never `accepted`. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum GeometryUpdate { + /// The epoch advanced and the declaration was stored verbatim. Run + /// panel reconciliation. + Advanced, + /// Same epoch, same total: already current. Do no work. + Duplicate, + /// Same epoch with a different total, a lower epoch, the reserved + /// epoch `0`, an unknown frontend, or allocator exhaustion. Drop the + /// event before any reconciliation. + Rejected, +} + /// Row extent of an arbitrary subtree, derived from its leaves' computed /// rects: leaves tile their parent, so the union's height is the node's. fn node_row_extent(node: &LayoutNode, placements: &HashMap) -> u32 { @@ -3240,29 +3262,182 @@ impl EditorCore { (geometry.total.rows >= 2 && geometry.total.cols > 0).then(|| geometry.total.rows - 1) } - /// Cache a frontend's authoritative frame capacity (Q#BP2b). + /// A frontend's current authoritative frame-geometry declaration. + /// + /// The panel producer echoes `geometry_epoch` into every + /// [`pmacs_protocol::panel::PanelFrame`] it ships, and the daemon + /// compares an inbound panel event's epoch against it (Q#BP16 step + /// 3), so the epoch has to be readable, not only the size. + #[must_use] + pub fn frame_geometry_for( + &self, + fid: FrontendId, + ) -> Option { + self.views.get(&fid)?.frame_geometry + } + + /// Cache a frontend's authoritative frame capacity — the **grid / + /// `LOCAL`** allocator (Q#BP2b, Stage 2 §3.1). /// /// Grid / `LOCAL` views call this from their real attach and resize - /// sizes with an internally minted epoch; a semantic view stays - /// `None` until Stage 2's authenticated declaration. A repeated - /// identical size is not a new declaration. - pub fn declare_frame_geometry(&mut self, fid: FrontendId, total: crate::cell::CellSize) { + /// sizes with an internally minted epoch; a semantic view never takes + /// this path at all — it goes through + /// [`Self::accept_frame_geometry`], which applies the frontend-owned + /// epoch verbatim and does **no** value dedup. + /// + /// Value dedup is correct *here* and only here: a grid frontend's + /// cells are the unit it declares, so an unchanged grid under + /// unchanged metrics leaves any existing frame valid. It is wrong on + /// the semantic path, where a font or scale change can invalidate a + /// panel frame while [`crate::cell::CellSize`] is identical — the + /// case daemon-side dedup cannot see (Q#BP2S1). + /// + /// **Exhaustion fails closed.** Allocation is checked rather than + /// saturating: `saturating_add` pins at `u64::MAX`, after which two + /// different geometries share one declaration id. On exhaustion the + /// authoritative declaration is *cleared* to `None` (unknown), which + /// is already non-presentable under Q#BP2b, so the caller's + /// reconciliation hides the panel. Retaining the last valid geometry + /// would keep painting a panel sized to a frame that no longer + /// exists. + pub fn declare_frame_geometry( + &mut self, + fid: FrontendId, + total: crate::cell::CellSize, + ) -> GeometryUpdate { let Some(view) = self.views.get_mut(&fid) else { - return; + return GeometryUpdate::Rejected; }; if view .frame_geometry .is_some_and(|geometry| geometry.total == total) { - return; + return GeometryUpdate::Duplicate; } - let next = view - .frame_geometry - .map_or(1, |geometry| geometry.geometry_epoch.saturating_add(1)); + let next = match view.frame_geometry { + None => Some(1), + Some(geometry) => geometry.geometry_epoch.checked_add(1), + }; + let Some(next) = next else { + view.frame_geometry = None; + return GeometryUpdate::Rejected; + }; view.frame_geometry = Some(crate::window::DeclaredFrameGeometry { geometry_epoch: next, total, }); + GeometryUpdate::Advanced + } + + /// Accept a **semantic** frontend's authoritative geometry + /// declaration (Q#BP15a, Stage 2 §3.1). + /// + /// Deliberately a second method rather than + /// [`Self::declare_frame_geometry`] with an optional epoch: the two + /// regimes differ in whether value dedup applies, and one ambiguous + /// entry point would let a future caller silently take the wrong one. + /// + /// | Incoming declaration | Result | + /// | --- | --- | + /// | epoch **greater** than stored | [`GeometryUpdate::Advanced`], stored **verbatim**, even when `total` is unchanged | + /// | same epoch, same `total` | [`GeometryUpdate::Duplicate`] | + /// | same epoch, **different** `total` | [`GeometryUpdate::Rejected`] | + /// | **lower** epoch, any `total` | [`GeometryUpdate::Rejected`] | + /// + /// The last row is not an optimization: a lower epoch carrying + /// *identical* data is still stale, and accepting it would let a + /// reordered declaration resurrect geometry the frontend has moved + /// past. + /// + /// Epoch `0` is reserved for "never declared" and is rejected on the + /// wire. + pub fn accept_frame_geometry( + &mut self, + fid: FrontendId, + geometry_epoch: u64, + total: crate::cell::CellSize, + ) -> GeometryUpdate { + if geometry_epoch == 0 { + return GeometryUpdate::Rejected; + } + let Some(view) = self.views.get_mut(&fid) else { + return GeometryUpdate::Rejected; + }; + match view.frame_geometry { + Some(stored) if geometry_epoch < stored.geometry_epoch => GeometryUpdate::Rejected, + Some(stored) if geometry_epoch == stored.geometry_epoch => { + if stored.total == total { + GeometryUpdate::Duplicate + } else { + GeometryUpdate::Rejected + } + } + _ => { + view.frame_geometry = Some(crate::window::DeclaredFrameGeometry { + geometry_epoch, + total, + }); + GeometryUpdate::Advanced + } + } + } + + /// The **third** geometry of Q#BP15a: the panel grid the daemon + /// derives and paints, or `None` when no panel is presentable. + /// + /// Columns are the frontend's full declared width. Rows are the + /// stored `fixed_rows` request clamped by Q#BP2's recursive document + /// minimum ([`Self::panel_allocation`]) and then by the shared wire + /// area budget, so a very wide frame cannot produce a frame the + /// protocol would reject. **The stored request is never rewritten** + /// — a later narrower geometry restores it. + /// + /// Returns `None` — the Q#BP2b hidden arm — when geometry is unknown, + /// when the panel is hidden or absent, when the frame declares zero + /// columns, or when even [`MIN_WINDOW_OUTER_ROWS`] rows would exceed + /// the area budget. + #[must_use] + pub fn panel_grid_size(&self, fid: FrontendId) -> Option { + if self.views.get(&fid)?.panel_hidden { + return None; + } + self.presentable_panel_grid(fid) + } + + /// The panel grid this frontend's layout and geometry **could** + /// present, ignoring the cached `panel_hidden` bit. + /// + /// This is the single derivation behind both [`Self::panel_grid_size`] + /// and [`Self::reconcile_panel_layout_core`]'s satisfiability test, + /// and it is one function on purpose (review round 1, R1-1). When the + /// wire-area clamp lived only in the renderer, the daemon shipped an + /// authoritative `Absent` while `panel_hidden` stayed `false` — so + /// keys still reached the invisible window and a panel terminal kept + /// its controller. Q#BP2b is explicit that hiding is a **durable + /// state transition**, never a per-frame effect, and two derivations + /// of "can this panel be shown" is exactly how it became one. + /// + /// The area bound is a transport-safety limit rather than a frontend + /// policy, so it is applied uniformly rather than only on the + /// semantic path. It cannot bind for a grid frontend at any physically + /// reachable width — two rows fit until roughly 131,000 columns — so + /// one shared rule costs nothing and removes the drift. + #[must_use] + fn presentable_panel_grid(&self, fid: FrontendId) -> Option { + let view = self.views.get(&fid)?; + self.side_window_for(fid)?; + let geometry = view.frame_geometry?; + let cols = geometry.total.cols; + if cols == 0 { + return None; + } + let area_rows = self.frontend_area_rows(fid)?; + let rows = self.panel_allocation(fid, area_rows)?; + let budget_rows = + u32::try_from(pmacs_protocol::panel::MAX_PANEL_VISIBLE_CELLS / (cols as usize).max(1)) + .unwrap_or(u32::MAX); + let rows = rows.min(budget_rows); + (rows >= MIN_WINDOW_OUTER_ROWS).then(|| crate::cell::CellSize::new(rows, cols)) } /// Core half of the idempotent panel-reconciliation transaction @@ -3285,13 +3460,14 @@ impl EditorCore { return result; }; let was_hidden = self.views.get(&fid).is_some_and(|view| view.panel_hidden); - // Unknown geometry (a semantic view before Stage 2's declaration) - // and a zero-column frame are both non-presentable, and follow the - // hidden arm rather than being sized against a placeholder. - let satisfiable = self - .frontend_area_rows(fid) - .and_then(|rows| self.panel_allocation(fid, rows)) - .is_some(); + // Unknown geometry (a semantic view before Stage 2's declaration), + // a zero-column frame, a layout that cannot spare the rows, and a + // grid the shared wire budget cannot carry are ALL non-presentable + // and all follow the hidden arm. One derivation, shared with the + // renderer (R1-1): a condition that only the renderer knew about + // produced a blank band with the durable state still saying + // "visible". + let satisfiable = self.presentable_panel_grid(fid).is_some(); let Some(view) = self.views.get_mut(&fid) else { return result; }; diff --git a/src/protocol.rs b/src/protocol.rs index baf2709..30341f0 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -60,6 +60,21 @@ use std::path::PathBuf; // directly. pub use pmacs_protocol::*; +/// Lowest negotiated protocol version that carries the bottom-panel wire +/// family (Q#BP9): [`InstanceMessage::PanelFrame`] daemon→frontend, and +/// `FrontendEvent::{FrontendCellGeometry, PanelResizeRows, PanelPointer}` +/// frontend→daemon. +/// +/// One constant rather than a literal at each gate, because the panel +/// bump gates in **both** directions: the send filter, the producer's +/// peer flag, and the three inbound event gates must move together or one +/// side starts trusting a wire the other never negotiated. +/// +/// Distinct from [`ADVERTISED_PROTOCOL_VERSION`], which the production +/// daemon still holds at 20: the v21 schema is reserved, and the +/// compatibility-preserving activation is bottom-panel Stage 2B-3's. +pub const PANEL_MIN_VERSION: u32 = 21; + // --------------------------------------------------------------------------- // Attachment // --------------------------------------------------------------------------- diff --git a/src/semantic_render.rs b/src/semantic_render.rs index db96fc4..c7f5984 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -38,14 +38,16 @@ use crate::cell::{CellSize, Style}; use crate::editor::EditorState; use crate::protocol::{ AdornmentContent, AdornmentPlacement, ByteRange, Decoration, DecorationKind, DecorationSegment, - FrontendId, InlineAdornment, InstanceMessage, MenuPromptRow, StatuslineSegment, StyleSegment, - StyleSpan, + FrontendId, InlineAdornment, InstanceMessage, MenuPromptRow, PANEL_MIN_VERSION, + StatuslineSegment, StyleSegment, StyleSpan, }; use crate::statusline::{ StatuslineEvaluation, StatuslineEvaluationOutcome, StatuslineEvaluationTarget, - evaluate_statusline, + StatuslineWindowSegments, evaluate_statusline, }; use crate::terminal::TerminalFrame; +use crate::window::WindowId; +use pmacs_protocol::panel::{PanelFrame, PanelFramePayload}; /// The viewport a `semantic_render` frontend last declared. #[derive(Clone, Debug, Eq, PartialEq)] @@ -317,6 +319,64 @@ pub struct SemanticRenderState { terminal_error_latched: bool, /// Whether the most recent render pass projected a terminal. terminal_active: bool, + /// Whether the peer negotiated protocol v21, which is where + /// [`InstanceMessage::PanelFrame`] was appended (Q#BP9). A v20 peer + /// receives no band at all — and, per Q#BP13, is never *placed* in a + /// side window either, because denying only the message would leave + /// its window invisible. + peer_knows_panel_frames: bool, + /// The panel payload this peer last received, compared in FULL. + /// + /// Seeded to `Absent` rather than `None`: a fresh session starts with + /// no band, so the opening state is a fact rather than an absence, + /// and seeding it keeps every session from paying one redundant + /// `Absent` before it ever shows a panel. + /// + /// `Absent` is authoritative and duplicate-suppressed like any other + /// payload — hide and close must both send it, because the receiver + /// retains its last valid frame and silence would leave a stale band + /// on screen indefinitely (Q#BP15). + last_panel_payload: Option, + /// Highest presentation epoch allocated for this session; `0` means + /// none has been. Advanced only when a frame is actually shipped, so + /// a frame that fails validation does not burn an identity the peer + /// never saw. + panel_epoch_used: u64, + /// Identity behind the `Present` in `last_panel_payload`, or `None` + /// when the last payload was `Absent`. + /// + /// Cleared by every `Absent`, which is what makes hide/reopen and + /// close/reopen of the **same** persistent buffer allocate a fresh + /// epoch — the hole a `buffer_id` alone cannot close (Q#BP16). + panel_presentation: Option, + /// Whether an invalid panel frame was already reported since the last + /// valid one. Bounds the log exactly like `terminal_error_latched`. + panel_error_latched: bool, + /// The band's last PUBLISHED statusline segments and the side-window + /// presentation they belong to (review rounds 1 and 2, R2-4). + /// + /// The band repaints its whole mode line every frame, so + /// "publish nothing" has to be expressed as "paint what was published + /// last" — there is no wire-level suppression to fall back on the way + /// `StatuslineSegments` has. Side affinity can replace a buffer in the + /// same window, so both identities are required to prevent one panel + /// presentation from inheriting its predecessor's provider text. + last_panel_statusline: Option<((WindowId, BufferId), StatuslineWindowSegments)>, +} + +/// The presentation identity a shipped [`PanelFrame`] carries. +/// +/// `window_id` moves when a new side window is created and `buffer_id` +/// when the panel's buffer is replaced; either one changing allocates a +/// new `panel_epoch`, and that is what stops a stale `PanelPointer` from +/// addressing a reopened panel as if it were the old one (Q#BP16). +/// `WindowId` deliberately stays off the wire — the epoch is the opaque +/// stand-in for it. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +struct PanelPresentation { + window_id: WindowId, + buffer_id: BufferId, + panel_epoch: u64, } /// One [`SemanticRenderState::diag_line_cache`] entry: the line-start @@ -413,6 +473,7 @@ impl SemanticRenderState { s.peer_knows_font_facts = negotiated_protocol_version >= 17; s.peer_knows_statusline_segments = negotiated_protocol_version >= 18; s.peer_knows_terminal_frames = negotiated_protocol_version >= 19; + s.peer_knows_panel_frames = negotiated_protocol_version >= PANEL_MIN_VERSION; s } @@ -464,9 +525,69 @@ impl SemanticRenderState { last_terminal_frame: None, terminal_error_latched: false, terminal_active: false, + peer_knows_panel_frames: true, + // Q#BP15: a fresh session has no band, and that is a fact the + // peer already holds. Seeding the baseline keeps the first + // frame from shipping a redundant authoritative `Absent`. + last_panel_payload: Some(PanelFramePayload::Absent), + panel_epoch_used: 0, + panel_presentation: None, + panel_error_latched: false, + last_panel_statusline: None, } } + /// The `Present` panel declaration this session last shipped. + /// + /// The daemon reads it to run steps 3 and 4 of Q#BP16's validation + /// ladder: an inbound `PanelPointer` or `PanelResizeRows` must name + /// the geometry and presentation epochs of the frame the frontend was + /// actually looking at. `None` after an `Absent` — which is precisely + /// how `Absent` "clears input authority". + #[must_use] + pub fn panel_declaration(&self) -> Option<&PanelFrame> { + match self.last_panel_payload.as_ref()? { + PanelFramePayload::Present(frame) => Some(frame), + PanelFramePayload::Absent => None, + } + } + + /// Whether the last shipped declaration is a `Present` whose epochs + /// both match an inbound panel event **and** which still describes + /// the side window that is live now (Q#BP16 steps 2–4). + /// + /// Rolled into one predicate so no caller can check the geometry + /// epoch and forget the presentation epoch: they close different + /// holes and neither subsumes the other. + /// + /// `live_presentation` is the frontend's **current** side window and + /// its buffer. Comparing it is what closes review round 1's R1-2: + /// closing and reopening the same *persistent* buffer inside one + /// dispatcher burst leaves the shipped declaration intact while the + /// window it describes is already dead, and same-buffer/same-size + /// makes the successor indistinguishable by every other field. A + /// presentation epoch only identifies a presentation if something + /// checks that the presentation it names is still the one on screen. + /// + /// `None` means "no side window right now", which never matches — an + /// event cannot address a panel that does not exist. + #[must_use] + pub fn panel_declaration_matches( + &self, + geometry_epoch: u64, + panel_epoch: u64, + live_presentation: Option<(WindowId, BufferId)>, + ) -> bool { + let Some((window_id, buffer_id)) = live_presentation else { + return false; + }; + self.panel_presentation.is_some_and(|presentation| { + presentation.window_id == window_id && presentation.buffer_id == buffer_id + }) && self.panel_declaration().is_some_and(|frame| { + frame.geometry_epoch == geometry_epoch && frame.panel_epoch == panel_epoch + }) + } + /// Record the frontend's declared on-screen byte range. Called by /// the dispatcher when it receives /// [`crate::protocol::FrontendEvent::Viewport`]. Replaces any @@ -616,8 +737,15 @@ impl SemanticRenderState { return messages; } let Some(vp) = self.viewport.clone() else { - // Emit nothing before the frontend declares a viewport. - return Vec::new(); + // Emit nothing document-scoped before the frontend declares a + // viewport — but the band is a SEPARATE surface (Q#BP15a), + // and gating it on the document declaration would make the + // first panel unpaintable on a frontend that has not declared + // one yet. Its statusline is `None` here for the same reason: + // the semantic fan-out is keyed on the declared buffer. + let mut out = Vec::new(); + self.emit_panel_frame(state, None, &mut out); + return out; }; // Evaluate callbacks before any long-lived core borrow and before @@ -825,9 +953,17 @@ impl SemanticRenderState { out.extend(self.theme_facts_msg(state)); out.extend(self.font_facts_msg(state)); // Q#SL6/Q#SL8: face inventory must precede segment text. + // Parent acceptance 45: ONE provider invocation supplies both the + // primary-document wire segments and the panel mode line, so the + // side half is taken from this same evaluation before it is + // consumed. + let panel_presentation = self.panel_statusline_presentation(state); + let panel_statusline = + self.panel_statusline(statusline_evaluation.as_ref(), panel_presentation); if let Some(evaluation) = statusline_evaluation { self.emit_statusline_segments(evaluation, statusline_document_window, &mut out); } + self.emit_panel_frame(state, panel_statusline.as_ref(), &mut out); out } @@ -965,9 +1101,17 @@ impl SemanticRenderState { out.extend(self.theme_facts_msg(state)); out.extend(self.font_facts_msg(state)); // Q#SL6/Q#SL8: face inventory must precede segment text. + // The band rides the terminal path too: a frontend whose DOCUMENT + // surface is a full-window terminal can still hold a side window, + // and suppressing the panel here would leave the peer's retained + // band on screen with no way to clear it. + let panel_presentation = self.panel_statusline_presentation(state); + let panel_statusline = + self.panel_statusline(statusline_evaluation.as_ref(), panel_presentation); if let Some(evaluation) = statusline_evaluation { self.emit_statusline_segments(evaluation, statusline_document_window, &mut out); } + self.emit_panel_frame(state, panel_statusline.as_ref(), &mut out); out } @@ -1033,6 +1177,231 @@ impl SemanticRenderState { } } + /// The side window's evaluated segments from **this frame's** single + /// provider invocation (parent acceptance 45). + /// + /// Selected by window identity, exactly like the document half: the + /// fan-out yields the primary document *and* the visible side window, + /// and taking "some context for my frontend" would depend on capture + /// order and could paint the document's status text into the panel's + /// mode line. + /// + /// The three outcomes are **not** interchangeable, and review round 1 + /// (R2-4) found two of them collapsed: + /// + /// * `Ready` is authoritative — including an empty result. It + /// replaces the retained baseline. + /// * `Invalidated` discards all evaluated text: a callback mutated + /// registry, layout, or focus mid-evaluation, so the band clears to + /// its plain mode line and the baseline dies with it. + /// * `NoMessage` means **publish nothing**. Phase 1 was already stale + /// — most reachably a buffer-follow mismatch, where the primary + /// document window has moved off the buffer the frontend declared. + /// The band therefore keeps what it last published. Treating this + /// like `Invalidated` *removes* provider text on a transient + /// condition that said nothing about it. + /// + /// The baseline is keyed by both window and buffer identity. Side + /// affinity deliberately replaces the buffer in an existing side + /// window, and that replacement is a new presentation even though the + /// `WindowId` is stable. + fn panel_statusline_presentation(&self, state: &EditorState) -> Option<(WindowId, BufferId)> { + let core = state.core.borrow(); + let window_id = core.side_window_for(self.frontend_id)?; + let buffer_id = core.windows.get(&window_id)?.buffer_id; + Some((window_id, buffer_id)) + } + + fn panel_statusline( + &mut self, + evaluation: Option<&StatuslineEvaluation>, + panel_presentation: Option<(WindowId, BufferId)>, + ) -> Option { + let Some((side_window, side_buffer)) = panel_presentation else { + // No band to publish for; drop any baseline so a later panel + // cannot inherit a dead window's text. + self.last_panel_statusline = None; + return None; + }; + let retained = |state: &Self| { + state + .last_panel_statusline + .as_ref() + .filter(|(presentation, _)| *presentation == (side_window, side_buffer)) + .map(|(_, segments)| segments.clone()) + }; + let Some(evaluation) = evaluation else { + // No evaluation ran at all (an unsupported peer, or a frame + // before the document viewport exists). Nothing was + // published, so nothing is retracted. + return retained(self); + }; + match &evaluation.outcome { + StatuslineEvaluationOutcome::Ready(windows) => { + let found = windows + .iter() + .find(|window| { + window.context.frontend_id == self.frontend_id + && window.context.window_id == side_window + && window.context.buffer_id == side_buffer + }) + .cloned(); + self.last_panel_statusline = found + .clone() + .map(|segments| ((side_window, side_buffer), segments)); + found + } + StatuslineEvaluationOutcome::Invalidated { .. } => { + self.last_panel_statusline = None; + None + } + StatuslineEvaluationOutcome::NoMessage(_) => retained(self), + } + } + + /// Project this frontend's side window as an + /// [`InstanceMessage::PanelFrame`] (Q#BP15). + /// + /// Runs on every frame of a panel-capable v21 semantic session, + /// independently of the document byte viewport: the band is a + /// separate surface, and gating it on a declared viewport would leave + /// the first panel unpaintable on a frontend that has not yet + /// declared one. + /// + /// Not reset by [`Self::on_buffer_snapshot_sent`]: a `BufferSnapshot` + /// resets *document* mirror state, and the band is neither + /// buffer-scoped to the document nor rebuilt from it. + fn emit_panel_frame( + &mut self, + state: &EditorState, + statusline: Option<&StatuslineWindowSegments>, + out: &mut Vec, + ) { + // Q#BP13: capability, not merely wire version. A session that + // cannot render a band must not be shipped one — and, on the + // production path, is never placed in a side window either. + if !self.peer_knows_panel_frames || !state.core.borrow().panel_capable_for(self.frontend_id) + { + return; + } + // Q#BP15a: `Present` echoes the daemon's latest ACCEPTED geometry + // declaration. Read before painting so the frame cannot answer a + // declaration that arrived mid-projection. + let geometry = state.core.borrow().frame_geometry_for(self.frontend_id); + let projection = + geometry.and_then(|_| state.prepare_panel_projection(self.frontend_id, statusline)); + let (Some(geometry), Some(projection)) = (geometry, projection) else { + self.publish_absent_panel(out); + return; + }; + let identity = (projection.window_id, projection.buffer_id); + let panel_epoch = match self.panel_presentation { + Some(presentation) if (presentation.window_id, presentation.buffer_id) == identity => { + Some(presentation.panel_epoch) + } + // A new side window, a replaced buffer, or any `Absent` → + // `Present` transition (which cleared `panel_presentation`) + // takes a fresh identity. + _ => self.panel_epoch_used.checked_add(1), + }; + let Some(panel_epoch) = panel_epoch else { + // Q#BP15: allocation is checked and exhaustion fails closed + // to `Absent`. Wrapping would let a new panel inherit a live + // identity and accept gestures aimed at its predecessor. + // + // **The one knowingly per-frame `Absent` left in this file**, + // and it is recorded rather than fixed. Review round 1's R1-1 + // established that a band cleared on the wire must also move + // the durable `panel_hidden` state, or keys keep reaching an + // invisible window; this arm cannot, because the producer + // holds session state and `panel_hidden` is recomputed by + // core reconciliation from geometry alone. Making it durable + // needs a new "presentation permanently unavailable" reason + // in `FrontendView`, which is machinery for a state that + // takes 2^64 shipped presentation changes in ONE session to + // reach — unlike the wire-area exhaustion in + // `presentable_panel_grid`, which any frontend can trigger + // with one declaration. If the epoch ever becomes + // frontend-supplied, this stops being unreachable and needs + // the durable arm. + self.publish_absent_panel(out); + return; + }; + let payload = PanelFramePayload::Present(PanelFrame { + buffer_id: projection.buffer_id, + panel_epoch, + geometry_epoch: geometry.geometry_epoch, + size: projection.size, + cells: projection.cells, + cursor: projection.cursor, + focused: projection.focused, + }); + // Complete-payload comparison FIRST, like the terminal pass: only + // validated payloads are ever stored, so a payload equal to the + // baseline has already passed and re-running the per-cell width + // and topology checks would recompute a verdict we hold. + if self.last_panel_payload.as_ref() == Some(&payload) { + self.panel_error_latched = false; + return; + } + let PanelFramePayload::Present(frame) = &payload else { + unreachable!("the Present payload was constructed immediately above"); + }; + match frame.validate() { + Ok(()) => { + self.panel_error_latched = false; + self.panel_epoch_used = self.panel_epoch_used.max(panel_epoch); + self.panel_presentation = Some(PanelPresentation { + window_id: projection.window_id, + buffer_id: projection.buffer_id, + panel_epoch, + }); + self.last_panel_payload = Some(payload.clone()); + out.push(InstanceMessage::PanelFrame(payload)); + } + Err(error) => { + // Atomic rejection: the peer keeps its last valid frame, + // this session keeps the presentation identity behind it, + // and one bounded log line marks the condition. Advancing + // `panel_epoch_used` here would burn an identity the peer + // never saw. + if !self.panel_error_latched { + self.panel_error_latched = true; + eprintln!( + "pmacs: panel frame for {:?} on {:?} failed validation, \ + retaining the last valid frame: {error}", + projection.buffer_id, self.frontend_id + ); + } + } + } + } + + /// Publish the authoritative `Absent` for every non-presentable state + /// (Q#BP15, Q#BP2b). + /// + /// Clears the declared presentation on this side before any later + /// event can validate against it — that is what "`Absent` clears + /// input authority" means. The whole-frame geometry declaration + /// deliberately survives: it is answered by the frontend, not by the + /// panel's presence. + fn publish_absent_panel(&mut self, out: &mut Vec) { + self.panel_presentation = None; + // `Absent` also clears the peer's retained mode line. A later + // `Present` under `NoMessage` therefore has nothing it can + // legitimately retain, even if the same window and buffer reopen. + self.last_panel_statusline = None; + if self.last_panel_payload.as_ref() == Some(&PanelFramePayload::Absent) { + // A duplicate `Absent` does no wire work — but both clears + // above still run, so the state stays idempotent rather than + // depending on which duplicate arrived first. + return; + } + self.panel_error_latched = false; + self.last_panel_payload = Some(PanelFramePayload::Absent); + out.push(InstanceMessage::PanelFrame(PanelFramePayload::Absent)); + } + fn emit_statusline_payload( &mut self, buffer_id: BufferId, diff --git a/tests/bottom_panel_stage2b_daemon_acceptance.rs b/tests/bottom_panel_stage2b_daemon_acceptance.rs new file mode 100644 index 0000000..28eed30 --- /dev/null +++ b/tests/bottom_panel_stage2b_daemon_acceptance.rs @@ -0,0 +1,1336 @@ +// bottom_panel_stage2b_daemon_acceptance.rs --- bottom-panel Stage 2B-2 +// (docs/bottom-panel-stage2-framing.md §7.2.2; parent acceptance 38, 39 +// receiver half, 40, 41 daemon half, 42, 45, 49, 51, 52, plus A2B-1). + +//! The daemon panel projection and the epoch machine. +//! +//! Everything here runs through a **test-only** panel-capable semantic +//! view: production negotiation still sets `panel_capable = false` for +//! every semantic session, and the compatibility-preserving v21 +//! activation is Stage 2B-3's. Nothing in this slice is user-reachable. +//! +//! Two disciplines the framing is explicit about: +//! +//! * **Every geometry claim is asserted against the frame the producer +//! actually shipped**, never against `panel_grid_size` alone — the +//! grid the daemon derives is only meaningful if it reaches the wire. +//! * **Every drop is paired with its accepted counterpart in the same +//! fixture.** A suite that only proved "stale events are dropped" +//! would pass against a producer that ships nothing at all. + +use std::collections::HashMap; + +use pmacs::cell::{CellSize, Glyph}; +use pmacs::editor::EditorState; +use pmacs::editor_core::GeometryUpdate; +use pmacs::protocol::{FrontendId, InstanceMessage, PROTOCOL_VERSION}; +use pmacs::semantic_render::SemanticRenderState; +use pmacs::window::{FrontendView, Layout, Window, WindowId}; +use pmacs_protocol::panel::{PanelFrame, PanelFramePayload}; + +// --------------------------------------------------------------------------- +// Harness +// --------------------------------------------------------------------------- + +const FID: FrontendId = FrontendId(64); +/// Deliberately NOT `24x80`: parent acceptance 40 requires the first +/// open to be sized from the frontend's own declaration, and a fixture +/// that happened to match the attach placeholder could not tell the two +/// apart. +const ROWS: u32 = 40; +const COLS: u32 = 120; + +struct Session { + state: EditorState, + render: SemanticRenderState, + document: WindowId, +} + +impl Session { + /// A semantic, panel-capable frontend with one document window and + /// no geometry declared yet. + fn new() -> Self { + let state = EditorState::new(); + exec(&state, "pmacs.lsp.config = {}"); + let document = { + let mut core = state.core.borrow_mut(); + let buffer_id = core.active_buffer_id(); + let text_view = { + let reg = core.registry.borrow(); + pmacs::text_view::TextView::new(reg.get(buffer_id).expect("buffer")) + }; + let window = WindowId::next(); + core.windows + .insert(window, Window::new(window, buffer_id, text_view)); + core.register_frontend_view( + FID, + FrontendView { + layout: Layout::single(window), + active: window, + // Semantic sessions do not project folds (Q#FD21); + // parent acceptance 52 depends on it. + fold_projection: false, + // Test-only. Production negotiation still says false. + panel_capable: true, + // Q#BP15a: UNKNOWN, never the attach placeholder. + frame_geometry: None, + panel_hidden: false, + }, + ); + // Programmatic Lua calls act for the ambient active frontend, + // so every `pmacs.window.*` call below targets this session. + core.active_frontend = FID; + window + }; + Self { + state, + render: SemanticRenderState::for_peer(FID, PROTOCOL_VERSION), + document, + } + } + + fn declare(&self, epoch: u64, rows: u32, cols: u32) -> GeometryUpdate { + self.state + .accept_semantic_frame_geometry(FID, epoch, CellSize::new(rows, cols)) + } + + /// Project one frame and return the panel payload it carried, or + /// `None` when the frame said nothing about the band (which is what + /// duplicate suppression looks like from the wire). + fn frame(&mut self) -> Option { + let messages = self.render.render_frame(&self.state); + let mut panels = messages.into_iter().filter_map(|message| match message { + InstanceMessage::PanelFrame(payload) => Some(payload), + _ => None, + }); + let first = panels.next(); + assert!( + panels.next().is_none(), + "one frame ships at most one panel payload" + ); + first + } + + fn present(&mut self) -> PanelFrame { + match self.frame() { + Some(PanelFramePayload::Present(frame)) => frame, + other => panic!("expected a Present panel payload, got {other:?}"), + } + } + + fn side_window(&self) -> Option { + self.state.core.borrow().side_window_for(FID) + } +} + +fn exec(state: &EditorState, src: &str) { + state.lua_host.lua().load(src.to_string()).exec().unwrap(); +} + +/// The panel grid's text as rows of strings, mode line included. +fn rows_of(frame: &PanelFrame) -> Vec { + frame + .cells + .chunks(frame.size.cols as usize) + .map(|row| { + row.iter() + .map(|cell| match &cell.glyph { + Glyph::Char(ch) => ch.to_string(), + Glyph::Cluster(bytes) => String::from_utf8_lossy(bytes).into_owned(), + Glyph::Continuation => String::new(), + }) + .collect::() + }) + .collect() +} + +/// Replace the panel buffer's contents through the real Lua data API. +fn set_panel_text(session: &Session, text: &str) { + exec( + &session.state, + &format!( + "local len = PANEL_BUF:len() + if len > 0 then PANEL_BUF:delete(0, len) end + PANEL_BUF:insert(0, {text:?})" + ), + ); +} + +/// Close the panel: focus it, then take the real `close_active` path. +fn close_panel(session: &Session) { + let panel = session.side_window().expect("side window"); + session.state.core.borrow_mut().focus_window(FID, panel); + exec(&session.state, "pmacs.window.close()"); + session.state.reconcile_panel_layout(FID); +} + +fn open_panel(session: &Session, name: &str, rows: u32) { + exec( + &session.state, + &format!( + "PANEL_BUF = pmacs.buffer.create(\"{name}\") + pmacs.window.display(PANEL_BUF, {{ side = \"bottom\", height = {rows} }})" + ), + ); +} + +// --------------------------------------------------------------------------- +// 40 + A2B-1 — unknown geometry is first-class; the placeholder is never +// consulted +// --------------------------------------------------------------------------- + +#[test] +fn acc40_a_panel_opened_before_any_declaration_stays_absent() { + let mut session = Session::new(); + open_panel(&session, "*panel*", 4); + assert!(session.side_window().is_some(), "the side window exists"); + + assert_eq!( + session.frame(), + None, + "with geometry UNKNOWN the band is non-presentable, and the seeded \ + Absent baseline means there is nothing new to say" + ); + assert_eq!( + session.state.core.borrow().panel_grid_size(FID), + None, + "no grid is derivable before a real declaration" + ); +} + +#[test] +fn acc40_the_first_frame_is_sized_from_the_declaration_not_the_placeholder() { + let mut session = Session::new(); + open_panel(&session, "*panel*", 4); + assert_eq!(session.declare(1, ROWS, COLS), GeometryUpdate::Advanced); + + let frame = session.present(); + assert_eq!( + frame.size.cols, COLS, + "columns come from the frontend's declaration; the permanent 24x80 \ + attach placeholder would have produced 80" + ); + assert_eq!( + frame.size.rows, 4, + "rows are the clamped fixed_rows request" + ); + assert_eq!( + frame.geometry_epoch, 1, + "the frame echoes the declaration it answers" + ); + assert!(frame.panel_epoch >= 1, "presentation epochs start at 1"); +} + +// --------------------------------------------------------------------------- +// 38 / 49 — the full lifecycle, and a new epoch at every identity change +// --------------------------------------------------------------------------- + +#[test] +fn acc38_open_replace_hide_reappear_close() { + let mut session = Session::new(); + session.declare(1, ROWS, COLS); + open_panel(&session, "*first*", 4); + + // --- open --------------------------------------------------------- + let opened = session.present(); + let first_buffer = opened.buffer_id; + + // --- replace the panel's buffer ----------------------------------- + exec( + &session.state, + "SECOND = pmacs.buffer.create(\"*second*\") + pmacs.window.display(SECOND, { side = \"bottom\" })", + ); + let replaced = session.present(); + assert_ne!( + replaced.buffer_id, first_buffer, + "the panel really is showing another buffer" + ); + assert!( + replaced.panel_epoch > opened.panel_epoch, + "49: buffer replacement must move the presentation epoch \ + ({} -> {})", + opened.panel_epoch, + replaced.panel_epoch + ); + + // --- hidden by a frame too small to satisfy it -------------------- + // Q#BP2b: hiding is a durable transition, and the band must be + // cleared AUTHORITATIVELY. Silence would leave the retained frame + // on the peer's screen indefinitely. + session.declare(2, 4, COLS); + assert_eq!( + session.frame(), + Some(PanelFramePayload::Absent), + "38: hiding sends an authoritative Absent" + ); + assert!( + session.side_window().is_some(), + "…while the side window itself survives, with its request intact" + ); + + // --- reappear ----------------------------------------------------- + session.declare(3, ROWS, COLS); + let reappeared = session.present(); + assert!( + reappeared.panel_epoch > replaced.panel_epoch, + "49: every Absent -> Present transition takes a fresh epoch \ + ({} -> {})", + replaced.panel_epoch, + reappeared.panel_epoch + ); + assert_eq!( + reappeared.buffer_id, replaced.buffer_id, + "…even though the SAME persistent buffer came back, which is exactly \ + the hole a buffer id alone cannot close" + ); + + // --- close -------------------------------------------------------- + close_panel(&session); + assert_eq!(session.side_window(), None, "the side window is gone"); + assert_eq!( + session.frame(), + Some(PanelFramePayload::Absent), + "38: closing sends an authoritative Absent" + ); +} + +#[test] +fn acc49_close_and_reopen_of_the_same_buffer_takes_a_new_epoch() { + let mut session = Session::new(); + session.declare(1, ROWS, COLS); + exec( + &session.state, + "PERSISTENT = pmacs.buffer.create(\"*persistent*\") + pmacs.window.display(PERSISTENT, { side = \"bottom\", height = 4 })", + ); + let first = session.present(); + + close_panel(&session); + assert_eq!(session.frame(), Some(PanelFramePayload::Absent)); + + exec( + &session.state, + "pmacs.window.display(PERSISTENT, { side = \"bottom\", height = 4 })", + ); + let second = session.present(); + + assert_eq!( + second.buffer_id, first.buffer_id, + "the SAME persistent buffer is back — a buffer id alone cannot \ + distinguish this from the original presentation" + ); + assert!( + second.panel_epoch > first.panel_epoch, + "49: the presentation epoch must have moved ({} -> {})", + first.panel_epoch, + second.panel_epoch + ); +} + +// --------------------------------------------------------------------------- +// 39 (receiver half) — duplicates do no work; an invalid frame is +// rejected atomically and the previous valid frame is retained +// --------------------------------------------------------------------------- + +#[test] +fn acc39_a_duplicate_frame_does_no_work() { + let mut session = Session::new(); + session.declare(1, ROWS, COLS); + open_panel(&session, "*panel*", 4); + let _first = session.present(); + + assert_eq!( + session.frame(), + None, + "39: nothing changed, so the second frame ships no panel payload" + ); + assert_eq!( + session.frame(), + None, + "…and it stays quiet, rather than alternating" + ); +} + +#[test] +fn acc39_a_duplicate_absent_does_no_work() { + let mut session = Session::new(); + session.declare(1, ROWS, COLS); + open_panel(&session, "*panel*", 4); + let _present = session.present(); + + close_panel(&session); + assert_eq!( + session.frame(), + Some(PanelFramePayload::Absent), + "the first Absent is authoritative and must be sent" + ); + assert_eq!( + session.frame(), + None, + "39: a duplicate Absent is suppressed exactly like any other payload" + ); +} + +/// 39, receiver half: rejection is atomic and the peer keeps its last +/// valid frame. +/// +/// The producer-reachable route to an invalid frame runs through the +/// **mode line**, not the text: `TextView::render` drops zero-width marks +/// and never emits a cluster, and the terminal screen caps its clusters +/// at `MAX_TERMINAL_GRAPHEME_BYTES` — but `prepare_mode_line_runs` emits +/// whole grapheme clusters verbatim, and one of its inputs is the buffer +/// **name**. A name carrying a cluster past +/// `MAX_WIRE_GRID_GRAPHEME_BYTES` therefore produces a structurally +/// invalid panel frame through the ordinary display path. +#[test] +fn acc39_an_invalid_frame_is_rejected_and_the_previous_one_is_retained() { + let mut session = Session::new(); + session.declare(1, ROWS, COLS); + open_panel(&session, "*panel*", 4); + set_panel_text(&session, "before"); + let valid = session.present(); + assert!( + rows_of(&valid)[0].starts_with("before"), + "fixture precondition: the retained frame really shows the old text" + ); + + // One grapheme cluster far past the shared per-cell byte ceiling. + let monster = format!("x{}", "\u{301}".repeat(300)); + assert!( + monster.len() > pmacs_protocol::wire_grid::MAX_WIRE_GRID_GRAPHEME_BYTES, + "fixture precondition: the cluster really exceeds the shared ceiling" + ); + exec( + &session.state, + &format!( + "BAD = pmacs.buffer.create({monster:?}) + pmacs.window.display(BAD, {{ side = \"bottom\" }})" + ), + ); + + assert_eq!( + session.frame(), + None, + "39: an invalid frame is not shipped, whole or in part" + ); + let retained = session + .render + .panel_declaration() + .expect("the previous valid frame is retained"); + assert_eq!( + retained, &valid, + "39: the receiver's authority is unchanged — same cells, same epochs" + ); + + // The rejection also burned no presentation identity: nothing the + // peer ever saw carried the epoch the rejected frame would have used. + exec( + &session.state, + "GOOD = pmacs.buffer.create(\"*good*\") + pmacs.window.display(GOOD, { side = \"bottom\" })", + ); + let recovered = session.present(); + assert_eq!( + recovered.panel_epoch, + valid.panel_epoch + 1, + "the next shipped identity is the one the rejected frame did not \ + consume" + ); +} + +// --------------------------------------------------------------------------- +// 41 (daemon half) — the daemon alone derives the grid +// --------------------------------------------------------------------------- + +#[test] +fn acc41_row_clamping_preserves_the_stored_request() { + let mut session = Session::new(); + session.declare(1, ROWS, COLS); + open_panel(&session, "*panel*", 12); + assert_eq!(session.present().size.rows, 12, "the request fits at first"); + + let panel = session.side_window().expect("side window"); + // A frame with room for the document minimum plus three panel rows. + session.declare(2, 6, COLS); + let clamped = session.present(); + assert_eq!( + clamped.size.rows, 3, + "the panel is clamped, not the document subtree" + ); + assert_eq!( + session.state.core.borrow().windows[&panel] + .params + .fixed_rows, + Some(12), + "41: the STORED request survives the clamp, so a later wider frame \ + can restore it" + ); + + session.declare(3, ROWS, COLS); + assert_eq!( + session.present().size.rows, + 12, + "41: and it is restored exactly" + ); +} + +#[test] +fn acc41_the_wire_area_budget_clamps_rows_and_can_hide_the_panel() { + let max_cells = pmacs_protocol::panel::MAX_PANEL_VISIBLE_CELLS as u32; + + // Wide enough that only four rows fit inside the shared area bound. + let mut session = Session::new(); + let cols = max_cells / 4; + session.declare(1, 60, cols); + open_panel(&session, "*panel*", 20); + let frame = session.present(); + assert_eq!(frame.size.cols, cols); + assert_eq!( + frame.size.rows, 4, + "41: rows are clamped by the shared wire-area budget, not only by \ + the layout" + ); + frame + .validate() + .expect("41: a clamped frame is one the shared validator accepts"); + + // Wide enough that not even the structural two-row floor fits. + session.declare(2, 60, max_cells); + assert_eq!( + session.frame(), + Some(PanelFramePayload::Absent), + "41: when even two rows exceed the budget the panel follows the \ + Q#BP2b hidden arm" + ); +} + +#[test] +fn acc41_degenerate_geometry_fails_closed_to_zero_usable_grid() { + for (label, rows, cols) in [ + ("zero columns", ROWS, 0), + ("zero rows", 0, COLS), + ("a frame shorter than its own status row", 1, COLS), + ] { + let mut session = Session::new(); + session.declare(1, ROWS, COLS); + open_panel(&session, "*panel*", 4); + assert!( + matches!(session.frame(), Some(PanelFramePayload::Present(_))), + "{label}: fixture precondition — a band was visible first" + ); + + session.declare(2, rows, cols); + assert_eq!( + session.frame(), + Some(PanelFramePayload::Absent), + "{label}: declares zero usable geometry and hides, without \ + overflow or an oversized allocation" + ); + } +} + +// --------------------------------------------------------------------------- +// A2B-1 — the epoch state machine, row by row +// --------------------------------------------------------------------------- + +#[test] +fn a2b1_the_semantic_acceptance_table_holds_row_by_row() { + let session = Session::new(); + let total = CellSize::new(ROWS, COLS); + + assert_eq!( + session.declare(0, ROWS, COLS), + GeometryUpdate::Rejected, + "epoch 0 is reserved for 'never declared' and is rejected on the wire" + ); + assert_eq!( + session.state.core.borrow().frame_geometry_for(FID), + None, + "…and stores nothing" + ); + + assert_eq!(session.declare(5, ROWS, COLS), GeometryUpdate::Advanced); + assert_eq!(session.declare(5, ROWS, COLS), GeometryUpdate::Duplicate); + assert_eq!( + session.declare(5, ROWS, COLS + 1), + GeometryUpdate::Rejected, + "the same epoch with a different total is conflicting" + ); + assert_eq!( + session.declare(4, ROWS, COLS), + GeometryUpdate::Rejected, + "a LOWER epoch carrying identical data is still stale" + ); + assert_eq!( + session + .state + .core + .borrow() + .frame_geometry_for(FID) + .map(|geometry| (geometry.geometry_epoch, geometry.total)), + Some((5, total)), + "every rejection left the stored declaration untouched" + ); + + assert_eq!( + session.declare(6, ROWS, COLS), + GeometryUpdate::Advanced, + "Q#BP2S1: a greater epoch is accepted even when the total is \ + IDENTICAL — the font/scale case daemon-side value dedup cannot see" + ); + assert_eq!( + session + .state + .core + .borrow() + .frame_geometry_for(FID) + .map(|geometry| geometry.geometry_epoch), + Some(6), + "…and it is stored verbatim" + ); +} + +#[test] +fn a2b1_a_duplicate_reconciles_nothing_while_an_advance_does() { + let session = Session::new(); + session.declare(1, ROWS, COLS); + open_panel(&session, "*panel*", 4); + + // Force the derived visibility cache to a value only reconciliation + // can correct. `Duplicate` must leave it alone; `Advanced` must not. + let force_hidden = |session: &Session| { + session + .state + .core + .borrow_mut() + .views + .get_mut(&FID) + .expect("view") + .panel_hidden = true; + }; + + force_hidden(&session); + assert_eq!(session.declare(1, ROWS, COLS), GeometryUpdate::Duplicate); + assert!( + session.state.core.borrow().panel_hidden_for(FID), + "a Duplicate returns without touching panel state" + ); + + assert_eq!( + session.declare(2, ROWS, COLS), + GeometryUpdate::Advanced, + "…while an advance with the same total still reconciles" + ); + assert!( + !session.state.core.borrow().panel_hidden_for(FID), + "Advanced ran the reconciliation the Duplicate skipped" + ); +} + +#[test] +fn a2b1_a_rejected_declaration_reconciles_nothing() { + let session = Session::new(); + session.declare(4, ROWS, COLS); + open_panel(&session, "*panel*", 4); + session + .state + .core + .borrow_mut() + .views + .get_mut(&FID) + .expect("view") + .panel_hidden = true; + + assert_eq!(session.declare(3, ROWS, COLS), GeometryUpdate::Rejected); + assert!( + session.state.core.borrow().panel_hidden_for(FID), + "a Rejected declaration is dropped BEFORE any reconciliation" + ); +} + +#[test] +fn a2b1_grid_allocator_exhaustion_clears_the_declaration_and_hides() { + // The grid/LOCAL allocator, which mints its own epochs. `LOCAL` is + // panel-capable, so this is the production path for a TUI. + let state = EditorState::new(); + exec(&state, "pmacs.lsp.config = {}"); + state.sync_frame_geometry(FrontendId::LOCAL, CellSize::new(ROWS, COLS)); + exec( + &state, + "pmacs.window.display(pmacs.buffer.create(\"*panel*\"), \ + { side = \"bottom\", height = 4 })", + ); + assert!( + !state.core.borrow().panel_hidden_for(FrontendId::LOCAL), + "fixture precondition: the panel is visible before exhaustion" + ); + + // Drive the allocator to its last id. + state + .core + .borrow_mut() + .views + .get_mut(&FrontendId::LOCAL) + .expect("view") + .frame_geometry = Some(pmacs::window::DeclaredFrameGeometry { + geometry_epoch: u64::MAX, + total: CellSize::new(ROWS, COLS), + }); + + // A REAL resize now needs an id the allocator cannot mint. + assert_eq!( + state.sync_frame_geometry(FrontendId::LOCAL, CellSize::new(ROWS + 1, COLS)), + GeometryUpdate::Rejected, + "checked allocation refuses rather than pinning at u64::MAX, where \ + two different geometries would share one id" + ); + assert_eq!( + state.core.borrow().frame_geometry_for(FrontendId::LOCAL), + None, + "A2B-1: exhaustion CLEARS the authoritative declaration; retaining \ + the old one would keep painting a panel sized to a frame that no \ + longer exists" + ); + assert!( + state.core.borrow().panel_hidden_for(FrontendId::LOCAL), + "A2B-1: unknown geometry is non-presentable, so the panel hides" + ); + assert_eq!( + state.core.borrow().panel_grid_size(FrontendId::LOCAL), + None, + "…and no stale-geometry grid is derivable afterwards" + ); +} + +// --------------------------------------------------------------------------- +// 51 — a non-panel-capable semantic frontend gets no band at all +// --------------------------------------------------------------------------- + +#[test] +fn acc51_a_pre_panel_semantic_frontend_is_never_sent_a_panel_frame() { + let mut session = Session::new(); + session + .state + .core + .borrow_mut() + .views + .get_mut(&FID) + .expect("view") + .panel_capable = false; + session.declare(1, ROWS, COLS); + // The Stage 1 fallback discards `side`, so this lands in the document + // window; assert that directly rather than assuming it. + open_panel(&session, "*panel*", 4); + assert_eq!( + session.side_window(), + None, + "51: capability fallback placed the buffer in a document window" + ); + assert_eq!( + session.frame(), + None, + "51: and no band message is produced for it in any case" + ); +} + +#[test] +fn acc51_a_v20_peer_is_sent_no_panel_frame_even_when_capable() { + let mut session = Session::new(); + session.render = SemanticRenderState::for_peer(FID, PROTOCOL_VERSION - 1); + session.declare(1, ROWS, COLS); + open_panel(&session, "*panel*", 4); + + assert!( + session.state.core.borrow().panel_grid_size(FID).is_some(), + "fixture precondition: the daemon CAN derive a grid here" + ); + assert_eq!( + session.frame(), + None, + "51: a peer below the panel version receives no PanelFrame" + ); +} + +// --------------------------------------------------------------------------- +// 42 — focusing the panel does not disturb the document projection +// --------------------------------------------------------------------------- + +#[test] +fn acc42_focusing_the_panel_leaves_the_document_surface_alone() { + let mut session = Session::new(); + session.declare(1, ROWS, COLS); + open_panel(&session, "*panel*", 4); + let panel = session.side_window().expect("side window"); + let document_buffer = session.state.core.borrow().windows[&session.document].buffer_id; + + let unfocused = session.present(); + assert!(!unfocused.focused, "the panel does not own focus yet"); + + session.state.core.borrow_mut().focus_window(FID, panel); + let focused = session.present(); + assert!(focused.focused, "the frame reports the focus transition"); + + let core = session.state.core.borrow(); + assert_eq!( + core.primary_document_window(FID), + Some(session.document), + "42: the document surface is unchanged while the panel is focused" + ); + assert_eq!( + core.primary_document_buffer(FID), + Some(document_buffer), + "42: and still names the document buffer, not the panel's" + ); +} + +// --------------------------------------------------------------------------- +// 52 — the panel honors the OWNING frontend's fold projection +// --------------------------------------------------------------------------- + +#[test] +fn acc52_a_non_projecting_frontend_sees_every_source_line_in_its_panel() { + let mut session = Session::new(); + session.declare(1, ROWS, COLS); + open_panel(&session, "*panel*", 6); + set_panel_text(&session, "alpha\nbravo\ncharlie\ndelta"); + exec( + &session.state, + "assert(pmacs.fold.fold(PANEL_BUF, { start = 0, ['end'] = 17 }))", + ); + + let rows = rows_of(&session.present()); + let painted = rows.join("\n"); + for line in ["alpha", "bravo", "charlie"] { + assert!( + painted.contains(line), + "52: fold_projection = false means the panel collapses nothing; \ + {line:?} is missing from\n{painted}" + ); + } + + // The discriminating half: the same fold DOES collapse for a + // projecting frontend, so the assertion above is not vacuous. + session + .state + .core + .borrow_mut() + .views + .get_mut(&FID) + .expect("view") + .fold_projection = true; + let projected = rows_of(&session.present()).join("\n"); + assert!( + !projected.contains("bravo"), + "sanity: with projection on, the folded lines really do disappear \ + from\n{projected}" + ); +} + +// --------------------------------------------------------------------------- +// 45 — one provider invocation supplies both surfaces +// --------------------------------------------------------------------------- + +#[test] +fn acc45_one_statusline_invocation_serves_the_document_and_the_panel() { + let mut session = Session::new(); + session.declare(1, ROWS, COLS); + open_panel(&session, "*panel*", 4); + let panel_buffer = { + let core = session.state.core.borrow(); + let side = core.side_window_for(FID).expect("side window"); + core.windows[&side].buffer_id + }; + // The semantic fan-out is keyed on the DECLARED viewport buffer, so + // the document half only runs once the frontend has declared one. + let document_buffer = session.state.core.borrow().windows[&session.document].buffer_id; + session.render.set_viewport( + document_buffer, + pmacs::protocol::ByteRange { start: 0, end: 0 }, + 0, + ); + + exec( + &session.state, + "CALLS = 0 + pmacs.statusline.register { + name = 'probe', side = 'left', + fn = function(ctx) CALLS = CALLS + 1; return 'W' .. tostring(ctx.window) end, + }", + ); + + let messages = session.render.render_frame(&session.state); + let calls: u32 = session + .state + .lua_host + .lua() + .load("return CALLS") + .eval() + .expect("counter"); + assert_eq!( + calls, 2, + "45: exactly one invocation per visible context — the primary \ + document and the visible side window — and no second evaluation \ + for the band" + ); + + let panel_rows = messages + .iter() + .find_map(|message| match message { + InstanceMessage::PanelFrame(PanelFramePayload::Present(frame)) => Some(rows_of(frame)), + _ => None, + }) + .expect("a panel frame"); + let mode_line = panel_rows.last().expect("mode line").clone(); + let panel_name = { + let core = session.state.core.borrow(); + let reg = core.registry.borrow(); + reg.get(panel_buffer) + .expect("panel buffer") + .name() + .to_owned() + }; + assert!( + mode_line.contains(&panel_name), + "45: the band's mode line carries the SIDE window's provider text, \ + not the document's; got {mode_line:?}" + ); +} + +// --------------------------------------------------------------------------- +// The producer never touches a passive panel's scroll state +// --------------------------------------------------------------------------- + +#[test] +fn a_passive_panel_keeps_its_view_top_while_a_focused_one_scrolls_to_its_caret() { + let mut session = Session::new(); + session.declare(1, ROWS, COLS); + open_panel(&session, "*panel*", 4); + let panel = session.side_window().expect("side window"); + let body = vec!["line-of-text"; 40].join("\n"); + let last_byte = body.len() as u64; + set_panel_text(&session, &body); + + // Put the panel's caret far below its viewport while it is PASSIVE. + { + let mut core = session.state.core.borrow_mut(); + let window = core.windows.get_mut(&panel).expect("panel window"); + window.cursor = last_byte; + window.view_top = 0; + } + let _ = session.present(); + assert_eq!( + session.state.core.borrow().windows[&panel].view_top, + 0, + "a passive panel's view_top is not moved by the projection" + ); + + session.state.core.borrow_mut().focus_window(FID, panel); + let _ = session.present(); + assert!( + session.state.core.borrow().windows[&panel].view_top > 0, + "…while a FOCUSED panel runs the shared auto-scroll clamp" + ); +} + +// --------------------------------------------------------------------------- +// The band rides the terminal document path too +// --------------------------------------------------------------------------- + +#[test] +fn the_band_is_projected_even_when_the_document_surface_is_a_terminal() { + // A frontend with no declared byte viewport takes neither the + // document nor the terminal pass; the band must still be produced, + // or the first panel would be unpaintable. + let mut session = Session::new(); + session.declare(1, ROWS, COLS); + open_panel(&session, "*panel*", 4); + assert!( + session.render.panel_declaration().is_none(), + "fixture precondition: nothing shipped yet" + ); + let frame = session.present(); + assert_eq!(frame.size.cols, COLS); + assert!( + session.render.panel_declaration().is_some(), + "the declaration is recorded for the inbound validation ladder" + ); +} + +// --------------------------------------------------------------------------- +// Sanity: the projected cells really are the panel window's content +// --------------------------------------------------------------------------- + +#[test] +fn the_projection_paints_the_side_windows_own_buffer() { + let mut session = Session::new(); + session.declare(1, ROWS, COLS); + open_panel(&session, "*panel*", 4); + set_panel_text(&session, "panel-content"); + let frame = session.present(); + let rows = rows_of(&frame); + assert_eq!(rows.len(), frame.size.rows as usize); + assert!( + rows[0].starts_with("panel-content"), + "the first row is the panel buffer's first line, got {:?}", + rows[0] + ); + assert_eq!( + frame.cells.len(), + (frame.size.rows * frame.size.cols) as usize, + "exactly size.area() cells" + ); + frame.validate().expect("a produced frame is a valid frame"); + let _ = HashMap::::new(); +} + +// --------------------------------------------------------------------------- +// Review round 1 — R1-1 and R2-4 +// --------------------------------------------------------------------------- + +/// R1-1: exhausting the wire-area budget must be a **durable** hide, not +/// a per-frame one. +/// +/// Q#BP2b is explicit that hiding is a durable state transition: a +/// render-time dodge still routes keys to an invisible window and leaves +/// the terminal controller claimed. `panel_grid_size` gained a budget +/// clamp that `reconcile_panel_layout_core` did not share, so the band +/// went `Absent` on the wire while `panel_hidden` stayed false — the +/// exact per-frame-effect shape the Stage 1 record warns about. +#[test] +fn r1_1_wire_area_exhaustion_is_a_durable_hide_not_a_blank_frame() { + let mut session = Session::new(); + session.declare(1, ROWS, COLS); + open_panel(&session, "*panel*", 4); + let panel = session.side_window().expect("side window"); + session.state.core.borrow_mut().focus_window(FID, panel); + assert!( + session.present().focused, + "fixture precondition: the panel owns focus while it is presentable" + ); + + // Wide enough that not even the structural two-row floor fits inside + // the shared area bound. + let max_cells = u32::try_from(pmacs_protocol::panel::MAX_PANEL_VISIBLE_CELLS).expect("bound"); + session.declare(2, ROWS, max_cells); + + assert_eq!( + session.frame(), + Some(PanelFramePayload::Absent), + "the band is cleared authoritatively" + ); + let core = session.state.core.borrow(); + assert!( + core.panel_hidden_for(FID), + "R1-1: …and the DURABLE hidden state must move with it, or keys \ + keep reaching an invisible window" + ); + assert_ne!( + core.views[&FID].active, panel, + "R1-1: focus must leave a panel that can no longer be presented" + ); +} + +/// R1-1's controller half: a panel terminal that stops being presentable +/// must have its child released, because the resize path merely returns +/// on zero content without releasing anything. +#[test] +fn r1_1_wire_area_exhaustion_releases_a_panel_terminals_controller() { + let mut session = Session::new(); + session.declare(1, ROWS, COLS); + exec( + &session.state, + "TERM_BUF = pmacs.terminal.open { command = \"/bin/sh\", \ + args = { \"-c\", \"sleep 30\" }, display = \"panel\" }", + ); + let panel = session.side_window().expect("terminal panel"); + let buffer: pmacs::lua_bindings::BufferIdLua = session + .state + .lua_host + .lua() + .load("return TERM_BUF") + .eval() + .unwrap(); + session.state.core.borrow_mut().focus_window(FID, panel); + let _ = session.present(); + let key = pmacs::terminal::TerminalViewKey::new(FID, panel, buffer.0); + assert!( + session + .state + .terminal_manager + .borrow_mut() + .claim_controller(key), + "fixture precondition: the panel view controls the child" + ); + + let max_cells = u32::try_from(pmacs_protocol::panel::MAX_PANEL_VISIBLE_CELLS).expect("bound"); + session.declare(2, ROWS, max_cells); + assert_eq!(session.frame(), Some(PanelFramePayload::Absent)); + + assert!( + session + .state + .terminal_manager + .borrow() + .controller(buffer.0) + .is_none(), + "R1-1: the child's controller is released with the durable hide" + ); + exec(&session.state, "pmacs.terminal.terminate(TERM_BUF)"); +} + +/// R2-4: `NoMessage` means publish **nothing**, so the band keeps the +/// text it last published. Treating it like `Invalidated` *removes* +/// provider text on a transient buffer-follow mismatch. +#[test] +fn r2_4_nomessage_retains_the_bands_published_segments() { + let mut session = Session::new(); + session.declare(1, ROWS, COLS); + open_panel(&session, "*panel*", 4); + let panel = session.side_window().expect("side window"); + let document_buffer = session.state.core.borrow().windows[&session.document].buffer_id; + session.render.set_viewport( + document_buffer, + pmacs::protocol::ByteRange { start: 0, end: 0 }, + 0, + ); + exec( + &session.state, + "pmacs.statusline.register { + name = 'probe', side = 'left', + fn = function(ctx) return 'W' .. tostring(ctx.window) end, + }", + ); + + let marker = format!("W{}", panel.raw()); + let mode_line = |session: &mut Session| { + rows_of(&session.present()) + .last() + .expect("mode line") + .clone() + }; + assert!( + mode_line(&mut session).contains(&marker), + "fixture precondition: a Ready evaluation paints the band's own \ + provider text" + ); + + // A buffer-follow mismatch: the primary document window moves off the + // buffer the frontend declared, so phase 1 is already stale and the + // evaluator returns NoMessage. + exec( + &session.state, + "pmacs.window.switch_buffer(pmacs.buffer.create(\"*elsewhere*\"))", + ); + // Force a repaint: without a content change the payload would be + // duplicate-suppressed and this would assert nothing. + set_panel_text(&session, "changed"); + assert!( + mode_line(&mut session).contains(&marker), + "R2-4: NoMessage publishes nothing, so the band keeps its last \ + published segments" + ); + + // The discriminating half: `Invalidated` DOES clear them, because a + // callback that mutated the registry invalidates all evaluated text. + // The evaluation has to reach the callback phase first, so re-declare + // the viewport onto the buffer the document window now shows — + // otherwise phase 1 stays stale and this would still be NoMessage. + let elsewhere = session.state.core.borrow().windows[&session.document].buffer_id; + session.render.set_viewport( + elsewhere, + pmacs::protocol::ByteRange { start: 0, end: 0 }, + 0, + ); + exec( + &session.state, + "SELF = pmacs.statusline.register { + name = 'self-unregistering', side = 'right', + fn = function() pmacs.statusline.unregister(SELF); return 'STALE' end, + }", + ); + set_panel_text(&session, "changed again"); + let after = mode_line(&mut session); + assert!( + !after.contains(&marker) && !after.contains("STALE"), + "an Invalidated evaluation discards ALL callback text, got {after:?}" + ); +} + +/// R2-4's retained baseline follows one window-and-buffer presentation, +/// not merely the side `WindowId`. Side affinity reuses the window when it +/// replaces the buffer, so `NoMessage` must not carry provider text from +/// the predecessor into the replacement. +#[test] +fn r2_4_nomessage_does_not_cross_a_same_window_buffer_replacement() { + let mut session = Session::new(); + session.declare(1, ROWS, COLS); + open_panel(&session, "*first-panel*", 4); + let panel = session.side_window().expect("side window"); + let document_buffer = session.state.core.borrow().windows[&session.document].buffer_id; + session.render.set_viewport( + document_buffer, + pmacs::protocol::ByteRange { start: 0, end: 0 }, + 0, + ); + exec( + &session.state, + "pmacs.statusline.register { + name = 'buffer-probe', side = 'left', + fn = function(ctx) return 'CTX:' .. ctx.buffer:name() end, + }", + ); + + let first = rows_of(&session.present()) + .last() + .expect("mode line") + .clone(); + assert!( + first.contains("CTX:*first-panel*"), + "fixture precondition: the first panel publishes its buffer-scoped \ + segment; got {first:?}" + ); + + // Replace the buffer in the existing side WindowId, then move the + // document off its declared viewport in the same transaction so phase + // 1 returns NoMessage before it can publish the replacement context. + exec( + &session.state, + "SECOND = pmacs.buffer.create('*second-panel*') + pmacs.window.display(SECOND, { side = 'bottom' }) + pmacs.window.switch_buffer(pmacs.buffer.create('*elsewhere*'))", + ); + assert_eq!( + session.side_window(), + Some(panel), + "fixture precondition: side-buffer replacement reuses the WindowId" + ); + + let replaced = rows_of(&session.present()) + .last() + .expect("mode line") + .clone(); + assert!( + replaced.contains("*second-panel*"), + "fixture precondition: the replacement panel was painted; got {replaced:?}" + ); + assert!( + !replaced.contains("CTX:*first-panel*"), + "NoMessage must not carry buffer-scoped segments across a new panel \ + presentation; got {replaced:?}" + ); +} + +/// An authoritative `Absent` clears the peer's retained band, including +/// its mode line. Reopening the same panel under `NoMessage` therefore +/// starts with no provider segments to retain. +#[test] +fn r2_4_nomessage_does_not_resurrect_segments_after_absent() { + let mut session = Session::new(); + session.declare(1, ROWS, COLS); + open_panel(&session, "*panel*", 4); + let document_buffer = session.state.core.borrow().windows[&session.document].buffer_id; + session.render.set_viewport( + document_buffer, + pmacs::protocol::ByteRange { start: 0, end: 0 }, + 0, + ); + exec( + &session.state, + "pmacs.statusline.register { + name = 'absence-probe', side = 'left', + fn = function() return 'BEFORE-ABSENT' end, + }", + ); + assert!( + rows_of(&session.present()) + .last() + .expect("mode line") + .contains("BEFORE-ABSENT"), + "fixture precondition: the segment was published" + ); + + exec( + &session.state, + "pmacs.window.switch_buffer(pmacs.buffer.create('*elsewhere*'))", + ); + session.declare(2, 3, COLS); + assert_eq!( + session.frame(), + Some(PanelFramePayload::Absent), + "fixture precondition: the peer's panel state was cleared" + ); + + session.declare(3, ROWS, COLS); + let reappeared = rows_of(&session.present()) + .last() + .expect("mode line") + .clone(); + assert!( + !reappeared.contains("BEFORE-ABSENT"), + "NoMessage cannot retain across Absent because the peer has no panel \ + statusline state left to retain; got {reappeared:?}" + ); +} + +// --------------------------------------------------------------------------- +// Review round 1 sweep — the same defect shape, found elsewhere +// --------------------------------------------------------------------------- + +/// Sweep result: a panel **wider than the terminal subsystem's per-axis +/// cap** hosting a terminal reproduced R1-1's shape all over again. +/// +/// Bet B5' makes a panel wider than 512 columns legal on the wire — a 4K +/// surface at a small font is ordinary, and the frontend declares that +/// width itself. But the terminal screen keeps its own PTY policy +/// (`MAX_TERMINAL_COLS`), so `snapshot_for_view` refused the panel's +/// content rect, the projection returned `None`, and the band went +/// **per-frame `Absent` while `panel_hidden` stayed false** — keys still +/// reaching an invisible window, controller still claimed. +/// +/// The band is legitimately that wide, so hiding it would be the wrong +/// answer: the terminal projects into the columns it can occupy and the +/// remainder is band background, exactly as a snapshot narrower than its +/// window already paints. +#[test] +fn sweep_a_panel_wider_than_the_terminal_cap_still_presents_its_terminal() { + let wide = u32::from(pmacs::terminal::MAX_TERMINAL_COLS) + 88; + let mut session = Session::new(); + session.declare(1, ROWS, wide); + exec( + &session.state, + "TERM_BUF = pmacs.terminal.open { command = \"/bin/sh\", \ + args = { \"-c\", \"sleep 30\" }, display = \"panel\" }", + ); + let panel = session.side_window().expect("terminal panel"); + session.state.core.borrow_mut().focus_window(FID, panel); + + let frame = match session.frame() { + Some(PanelFramePayload::Present(frame)) => frame, + other => panic!( + "a legally wide panel must still present its terminal; got {other:?} \ + — and the durable state says hidden={}", + session.state.core.borrow().panel_hidden_for(FID) + ), + }; + assert_eq!( + frame.size.cols, wide, + "the band keeps the width the frontend declared (Bet B5')" + ); + frame + .validate() + .expect("and it is a frame the shared validator accepts"); + + // The durable state and the wire agree — which is the property R1-1 + // was really about. + assert!( + !session.state.core.borrow().panel_hidden_for(FID), + "a presented band is not durably hidden" + ); + assert_eq!( + session.state.core.borrow().views[&FID].active, + panel, + "…and focus is still legitimately in it" + ); + exec(&session.state, "pmacs.terminal.terminate(TERM_BUF)"); +}