From c2eeb60dc75327a3cd405da1b93374aa51ba47c7 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 18:00:40 -0400 Subject: [PATCH 1/5] docs: bottom-panel Stage 2 framing (revision 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The re-framing `docs/bottom-panel-framing.md` rev 4 §2 requires before Stage 2 (the GPU panel band) is implemented. Re-scouted against canonical `main` @ `5aa9044`, protocol v20. It does not restate the parent's decisions; it records what the re-scout found. Every source anchor Stage 2 inherits had moved, but none of the parent's mechanical model was falsified. Two facts held and are load-bearing: protocol is still v20, so Q#BP9 resolves to **v21** with no reservation needed, and both byte pins (`InstanceMessage::InitialTargetResult`, `FrontendEvent::TerminalPointer`) are still their enums' final variants. Four findings: - **Q#BP2S1, new and open.** Stage 1 landed a daemon-side geometry epoch allocator (`declare_frame_geometry`), but Q#BP15a specifies a frontend-owned epoch echoed by every `Present`. The landed allocator also dedups on value and uses `saturating_add`, which is neither wrapping nor the fail-closed the framing asks for. Three resolutions are stated with a recommendation. - **The §1.3 census is essentially unrouted.** Stage 1 built the `primary_document_window` seam but it has one production caller; ~80 direct `.active` reads remain. This is Stage 2's bulk, not its tidy-up, and the stage plan sequences it first. - **The statusline active read is three sites, not one.** - **Four scout obligations are still open** and are named rather than papered over, including the GPU-side pixel formula inputs. Also carries the staged plan, draft acceptance criteria, the coherence impact per `COHERENCE.md` §20 (§14 is the section it serves), and four questions for the user. Co-Authored-By: Claude Opus 5 (1M context) --- docs/bottom-panel-stage2-framing.md | 326 ++++++++++++++++++++++++++++ 1 file changed, 326 insertions(+) create mode 100644 docs/bottom-panel-stage2-framing.md diff --git a/docs/bottom-panel-stage2-framing.md b/docs/bottom-panel-stage2-framing.md new file mode 100644 index 0000000..19f9203 --- /dev/null +++ b/docs/bottom-panel-stage2-framing.md @@ -0,0 +1,326 @@ +# Bottom panel Stage 2 — the GPU panel band (framing) + +**Revision 1 — pre-implementation. Ground truth: canonical `main` @ +`5aa9044`, protocol v20, 2026-07-25.** + +Stage 1 (#155, merge `e745068`) gave pmacs window placement, window +parameters, TUI side windows, the divider, and the adopter `display` +opt-in. It deliberately set `FrontendView::panel_capable = false` for +every semantic session, so a GPU frontend silently falls back to the +non-side target. **Stage 2 flips that bit and earns the right to.** + +This document is the re-framing `docs/bottom-panel-framing.md` (rev 4) +§2 requires before Stage 2 is implemented. It does **not** restate the +parent's decisions. It records what the re-scout against current `main` +found: which anchors moved, which parent claims survived, which did +not, and the four questions the parent framing cannot answer without a +decision from the user. + +Read the parent framing's Q#BP8, Q#BP9, Q#BP14b, Q#BP15, Q#BP15a, +Q#BP16, and Q#BP17 alongside this. Those decisions stand except where +§4 below revises them. + +## 0. Why the re-scout was required + +The parent framing's Stage 2 sections were written against `main` @ +`0dd16a5` and last re-scouted at `47581f4`. Since then eleven PRs have +merged: #149/#150 (folding Stage 2), #152–#155 (through bottom-panel +Stage 1), #158–#166 (inline math, minimap, Lean 4 Stages 1–2, +COHERENCE.md, find-file, the dired framing and Stage 1, the GPU +terminal input fix). Every source anchor Stage 2 depends on has moved. + +Two things did **not** change, and both are load-bearing: + +- **Protocol is still v20.** `PROTOCOL_VERSION` is `20` + (`pmacs-protocol/src/message.rs:1568`); no intervening PR bumped it. + Q#BP9's conditional resolves: **Stage 2 is v21**. +- **Both byte pins are still the final variants.** + `InstanceMessage::InitialTargetResult` is last in its enum + (`message.rs:577` within the enum at `:569`), and + `FrontendEvent::TerminalPointer` is last in its own. Q#BP9's + append-plus-pin instruction applies verbatim, with no re-derivation. + +## 1. Anchor re-scout + +Every line reference Stage 2 inherits, re-verified. "Claim" is the +parent framing's assertion about that site; "verdict" is what the code +at `5aa9044` actually says. + +| Parent anchor | Now at | Claim | Verdict | +| --- | --- | --- | --- | +| `src/editor.rs:2833` `paint_frame` returns cursor separately | `src/editor.rs:3171` | cells alone lose the caret | **Holds.** Signature still returns `Option` | +| `src/editor.rs:2883-2935` cursor-visible prep | `src/editor.rs:3249+` | extract with the per-window body | **Holds**, but see §3.1 — Stage 1 inserted work *above* it | +| `src/editor.rs:2937-3040` per-window paint body | after `:3260` | origin-agnostic `Viewport<'a>`, extractable | **Holds** | +| `src/editor_core.rs:566` `fold_map_for_window` | `src/editor_core.rs:734` | gates on the **active** frontend | **Holds** — `:738` is `if !self.fold_projection_active()` | +| `src/window.rs:339` stale invariant comment | `src/window.rs:562` | "a semantic session never enters `paint_frame`" | **Holds, still stale.** Updating it remains a Stage 2 obligation | +| `src/statusline.rs:634` indirect `view.active` read | `src/statusline.rs:629`, `:644`, `:675` | one read to close | **Revised: three sites**, not one | +| `src/daemon.rs:3122-3130` grid-only `Mouse` | `src/daemon.rs:3123` | `Mouse` is contractually the grid path | **Holds** | +| `pmacs-gpu/src/attach.rs:420-429`, `:573-577` | `pmacs-gpu/src/attach.rs:577` | permanent `24×80` placeholder | **Holds**, single site now | + +Nothing in the parent's mechanical model was falsified by the +re-scout. The decisions in §4 come from what Stage 1 *added*, not from +anything Stage 2 got wrong. + +## 2. What Stage 1 already built for Stage 2 + +More than the parent framing anticipated, which shrinks Stage 2 and +changes one of its wire contracts. + +- **`DeclaredFrameGeometry { geometry_epoch: u64, total: CellSize }` + exists** (`src/window.rs:522-528`), stored as + `FrontendView::frame_geometry: Option<_>` (`:589`) with `None` + meaning **unknown** — exactly Q#BP15a's "unknown is first-class". +- **The declaration path exists.** + `EditorState::sync_frame_geometry` (`src/editor.rs:877-882`) calls + `EditorCore::declare_frame_geometry` then `reconcile_panel_layout`. + Two daemon sites already drive it, both gated on + `panel_capable_for` (`src/daemon.rs:1882-1883` at attach, + `:1972-1973` on resize). +- **`paint_frame` itself declares geometry** (`src/editor.rs:3187`), + before the statusline fan-out and before the long mutable core + borrow, with a comment naming Q#BP2b/Q#BP15a. +- **`StatuslineEvaluationTarget`** (`src/statusline.rs:212-226`) is + already a two-variant enum — `Grid { frontend_id }` and `Semantic { + frontend_id, declared_buffer }`. Q#BP8's "generalize the fan-out" + is an added variant, not a refactor of a concrete type. +- **`primary_document_window`** exists (`src/editor_core.rs:2830`). + +## 3. What the re-scout found + +### 3.1 The geometry epoch is allocated daemon-side; the wire contract says frontend-side + +This is the one genuine conflict between landed Stage 1 and framed +Stage 2, and it needs a decision before implementation. + +`declare_frame_geometry` (`src/editor_core.rs:3155-3172`) **allocates +the epoch itself**: + +```rust +let next = view + .frame_geometry + .map_or(1, |geometry| geometry.geometry_epoch.saturating_add(1)); +``` + +Q#BP15a specifies the opposite: `FrontendEvent::FrontendCellGeometry { +frontend_id, geometry_epoch, total }` carries a **frontend-owned** +declaration id, and `PanelResizeRows` / `PanelPointer` / every +`Present` echo it. Under the landed code the daemon would have to +either ignore the wire epoch (breaking the echo contract the GPU +validates against) or overwrite its own allocator for panel-capable +semantic frontends only (two allocation regimes for one field). + +Two further details of the landed allocator matter: + +- **It dedups on value.** The function returns early when `total` is + unchanged, so the epoch advances only on an actual size change. For + a grid frontend that is correct — cells are the unit, and an + unchanged grid means an old `PanelFrame` is still valid under the new + metrics. For a frontend that *owns* its epoch, the daemon cannot + dedup by value without discarding a declaration the frontend already + considers current. +- **`saturating_add` is neither wrapping nor fail-closed.** Q#BP15a + requires exhaustion to "fail closed rather than wrap". Saturation + pins the epoch at `u64::MAX`, after which two different geometries + share one id — the exact staleness confusion the epoch exists to + prevent. Unreachable in practice; wrong as a contract, and free to + fix. + +**Q#BP2S1 (new, needs a decision).** Three candidate resolutions: + +1. **Frontend-owned, as framed.** `FrontendCellGeometry` carries the + epoch; the daemon stores it verbatim for semantic panel-capable + frontends and rejects a lower-or-equal epoch carrying different + data. Grid/LOCAL keep the local allocator, which never collides + because those frontends never send the event. Cost: one field, two + provenances, documented. +2. **Daemon-owned, GPU echoes.** `FrontendCellGeometry` carries only + `total`; the daemon allocates and the GPU learns its current epoch + from the next `PanelFrame`. Simpler invariant, but it reintroduces a + first-open ordering problem — the GPU must send `PanelResizeRows` + and `PanelPointer` carrying an epoch it has not been told yet, so + the first gesture after a resize is unvalidatable and must be + dropped. +3. **Frontend-owned everywhere.** Grid/LOCAL synthesize an epoch at + their existing declaration sites and the allocator moves out of + `EditorCore` entirely. Most uniform; largest Stage 1 churn, and it + touches code #155 just stabilized. + +**Recommendation: option 1.** It preserves the parent framing's +validation chain intact, and the "two provenances" cost is one doc +comment on a field that already carries three. + +### 3.2 The §1.3 census is essentially unrouted + +The parent framing's §1.3 lists 23 transitive active-context reads that +must route through `primary_document_window` before a panel can hold +focus without corrupting the document mirror. Stage 1 created the seam +but routed almost nothing through it: `primary_document_window` has +**three** references in `src/`, one of which is its own definition and +one a doc-comment link. The single production caller is +`src/daemon.rs:1639`. + +For scale, `src/*.rs` still contains ~80 non-test direct `.active` +reads (excluding `active_frontend`, setters, and predicates), on top of +the `active_window*` / `active_buffer*` helper family at +`src/editor_core.rs:663-967`. + +This is not a defect in Stage 1 — with `panel_capable = false` for +every semantic session, no semantic frontend can hold a side window, so +the unrouted reads are unreachable from the GPU. It does mean **the +census is the bulk of Stage 2's work**, not a tidy-up at the end, and +the stage plan in §5 sequences it first. + +### 3.3 The statusline read is three sites, not one + +Q#BP8 says closing the indirect `view.active` read at +`src/statusline.rs:634` falls out of the target generalization. There +are three: `:629` and `:675` compute `active: window_id == +view.active`, and `:644` does `.get(&view.active)`. They are the same +concern, but a fix that closes one and leaves two is a live risk, and +the acceptance criterion should name all three. + +### 3.4 Scout obligations still open + +Stated plainly rather than papered over. These were not re-verified in +this pass and must be before the doc leaves revision 1: + +- `pmacs-protocol/src/terminal.rs`'s validator internals, which Q#BP15 + asks to factor into a shared parameterized wire-cell-grid validator + (the `MAX_TERMINAL_ROWS/COLS = 512` split). +- `pmacs-gpu/src/attach.rs`'s bounded outbox policy and its existing + tail-coalescing classes, which Q#BP15a asks to extend with two new + classes and Q#BP16 with two more. +- The GPU-side band renderer and where it clips against the status + band — Q#BP15a's pixel formula is stated but its inputs + (`status_band_height_px`, `TEXT_TOP_px`, `code_line_height_px`, + `resolved_monospace_advance_px`) were not located in this pass. +- Whether folding Stage 3 lands first. Both stages touch the semantic + projection, and the ledger's standing rule is that whichever is + framed second re-scouts the other's landed state. + +## 4. Revisions to the parent framing + +Only these. Everything else in Q#BP8/9/14b/15/15a/16/17 stands. + +- **Q#BP9 resolves to v21.** No reservation was taken; none was needed. +- **Q#BP15a's epoch ownership is reopened as Q#BP2S1** (§3.1). +- **Q#BP8's statusline criterion names three sites** (§3.3). +- **Q#BP17's stale comment is at `src/window.rs:562`**, and its text is + now embedded in a longer `fold_projection` doc block that also + explains the Stage 2/Stage 3 split — the edit is a paragraph rewrite, + not a one-line correction. + +## 5. What ships, in order + +Sequenced so each step is independently gateable and the census — the +riskiest part — lands before anything depends on it. + +1. **Route the census.** Every §1.3 read through + `primary_document_window`, with `panel_capable` still `false`. No + wire change, no behavior change for any existing frontend; pure + seam adoption, falsifiable by revert. +2. **Extract the per-window painter.** Lift `paint_frame`'s per-window + body plus the active-window cursor-visible preparation into a + function taking the fold map as a **parameter** (Q#BP17), leaving + `sync_frame_geometry` and the statusline fan-out where Stage 1 put + them. Grid rendering must be byte-identical. +3. **Protocol v21.** Append `InstanceMessage::PanelFrame` and + `FrontendEvent::{FrontendCellGeometry, PanelResizeRows, + PanelPointer}`, each with a byte pin on the current final variant. + Factor the shared cell-grid validator. Gated both directions. +4. **Daemon-side panel projection.** `PanelFrame` production, + presentation epochs, `Absent` authority, the third statusline + target, the focus-chrome pass (Q#BP14b). +5. **GPU band.** Geometry declaration, band paint, divider chrome, + document clip, pointer transport, and the `panel_capable = true` + flip for semantic sessions — the flip is last, and it is what makes + the whole stage observable. + +Steps 1 and 2 are reviewable without any protocol change and could ship +as a separate PR if the user prefers a smaller first review. **That is +one of the questions in §8.** + +## 6. Coherence impact (per `COHERENCE.md` §20) + +- **Journey steps touched:** none directly. Stage 2 does not add a + journey step; it removes a frontend-dependent *hole* in one. Today a + user who runs `pmacs --gpu` and triggers compile, grep, references, + or a terminal gets the non-side fallback — the panel silently becomes + a stolen window. Every journey step that ends in an output surface + behaves differently on GPU than on TUI, and Stage 2 is what closes + that. +- **Interaction islands added: none, and this is a reduction.** §6 + grades islands "weak, and growing by one island per modal feature". + The panel is the opposite move: `display = "panel"` is one adopted + policy across listview, compile, and terminal, and Stage 2 extends + the existing policy to a second frontend rather than adding a + parallel GPU-only surface. The focus-chrome routing table (Q#BP14b) + deliberately reuses the existing `SearchPrompt` / `MenuPrompt` / + `CompletionPopup` messages instead of minting panel-specific ones. +- **Config registry adoption:** inherited, not extended. Stage 1's + `window.panel-height` and `window.min-height` already live in the + registry; Stage 2 adds no new user-facing option. If the GPU needs a + band-specific preference, it enters the registry — no new + configuration mechanism. +- **Background-work attribution:** unchanged. Stage 2 introduces no + worker, task, or process. It does, however, make §9's "✓ mechanics / + ✗ visibility" gap materially cheaper to close on the GPU: a terminal + PTY appearing in no user-visible activity view (§9) is partly a + *placement* problem, and after Stage 2 both frontends have a place to + put one. +- **Section this serves:** `COHERENCE.md` §14, which already records + the panel primitive as landed for Stage 1 and names "Stage 2 (GPU + band) pending its own framing" as the open item. This is that doc. + +## 7. Acceptance criteria (draft) + +Numbered for review; each must be falsifiable by revert, per the +standing lesson that a guard with no production caller passes every +direct-call test. + +1. Every §1.3 census read resolves through `primary_document_window`; + asserted at the outermost user-reachable seam, not by direct call. +2. All three `src/statusline.rs` active reads route through the new + frontend-layout target. +3. Grid `paint_frame` output is byte-identical across the extraction. +4. A semantic frontend with `fold_projection = false` painting a panel + over a folded buffer shows **every** source line (Q#BP17), and the + panel path never calls `fold_map_for_window`. +5. v21 round-trips; v20 peers negotiate without the panel events; each + extended enum's previous final variant is byte-pinned. +6. `Absent` is emitted on both close and hide, and clears input + authority before any later event validates. +7. A `PanelPointer` failing any of Q#BP16's six checks mutates no view, + controller, selection, menu, or PTY. +8. A geometry epoch change makes an older `PanelFrame` non-painted and + non-hit-testable until a matching `Present` arrives. +9. A panel wider than 512 columns is legal; a panel exceeding the + shared wire budget fails closed to `Absent`, not to a partial frame. +10. Panel focus does not disturb the document mirror + (`BufferSnapshot`, `CursorByte`, `Viewport`). +11. Native popups clear on document→panel focus change and panel + popups clear on panel→document, per Q#BP14b's ordering. + +## 8. Questions for the user + +1. **Q#BP2S1 epoch ownership** (§3.1) — recommendation is option 1, + frontend-owned with the daemon storing verbatim. Confirm or pick + another. +2. **One PR or two?** Steps 1–2 (census + extraction) are protocol-free + and independently valuable; steps 3–5 are the wire and the band. + Splitting gives two small reviews instead of one very large one, at + the cost of a second round-trip. +3. **Ordering against folding Stage 3.** Both touch the semantic + projection. Framing bottom-panel Stage 2 first means folding Stage 3 + re-scouts against a landed band. Confirm that order. +4. **Scout obligations in §3.4** — should revision 2 close all four + before implementation, or is the GPU-side pixel formula (the largest + of them) allowed to be settled during implementation? + +## 9. Gates + +The standing suite, plus `bottom_panel_stage1_acceptance` and a new +`bottom_panel_stage2_acceptance`, the three vterm suites (the panel +hosts terminals), folding Stage 2's 48 (shared projection), and +`PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu`. Protocol round-trip and +byte-pin tests ride step 3. From 69f9a1bdf9a8e4ce4247754c9c48a47f39f579e4 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 18:23:06 -0400 Subject: [PATCH 2/5] docs: bottom-panel Stage 2 framing (revision 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes review round 1 — 2 blocking, 3 high, 3 revision points — and rebases the ground truth onto `main` @ `d152120`. Both blockers were rev 1 asserting something the parent framing already decided otherwise: - **R1-1.** Rev 1 said all 23 census reads route through `primary_document_window`. Q#BP14 routes only the **Projection** class that way; focus/input (#13-#15, #23), focus chrome and surface-routed (#16-#19), and focus/session (#20) keep their own authorities. Rev 1's rule would have broken remote-op validation and application, `DispatchIdle`, presence, focused search/menu/completion routing, and terminal bell ownership. §3.2 restores the four classes as a table and the acceptance asserts each separately — the focus-class assertions are the load-bearing half, since a test that only proves "the document is used" passes with them wrongly rerouted. - **R1-2.** The three `src/statusline.rs` active reads have two dispositions, not one. Only `:644` selects the wrong window; `:629` and `:675` must keep tracking actual focus, because grid contexts need a truthful `active`, revalidation must notice a focus change, and parent acceptance 42 requires a document provider to be able to observe `active = false` while the panel is focused. The three high findings: - Q#BP2S1 resolves to frontend-owned epochs (option 1) — a font or scale transaction can need to invalidate an old `PanelFrame` while the derived `CellSize` is identical, which daemon value dedup cannot detect. Rev 2 adds the four-row transition table, splits grid allocation from semantic acceptance into two APIs rather than one ambiguous method, moves the grid allocator off `saturating_add` to checked-with-fail-closed, and defines the initial epoch and both exhaustion behaviors. Rev 1's "rejects a lower-or-equal epoch carrying different data" was itself wrong: a lower epoch carrying identical data is still stale. - The `panel_capable` flip is narrowed to an authenticated semantic session negotiated at **v21 or later**. Denying a v20 peer the new events is insufficient if the daemon still places its window in a side panel it cannot render — the gate is on placement. - Parent acceptance criteria 37-55 are declared authoritative and mapped to slices 2A/2B, with rev 1's eleven drafts demoted to refinements. The painter-extraction criterion now pins cells, the returned cursor, the focused window's `view_top` mutation, and passive-window state. All four scout obligations are closed (§5), and the pixel formula is treated as contract work, not implementation detail: - The shared/terminal-only validator boundary is named exactly. - Four new outbox tail-coalescing tags beside the existing four. - **`State::mono_advance` is unsafe to adopt**: absent a `FontFacts` probe it samples the document's first shaped glyph, which would make panel columns document-dependent. The declaration uses the existing stable normal-face `probe_mono_advance` instead, and declares zero usable geometry when it returns `None`. - `BASE_DIVIDER_HEIGHT` does not exist. Rev 2 decides its scaling and requires **one** document-bottom accessor routing every consumer (caret, hits, minimap, terminal geometry, clipping, edge scrolling) — a second unrouted seam is precisely the Stage 1 `Layout::compute` two-caller defect. The concrete base value is left open for round 2. Also: the coherence statement now names journey steps 7-10 instead of claiming none, and drops rev 1's overclaim that this advances background-work visibility — a panel gives output a placement but adds no join key to COHERENCE §9's four disjoint activity planes. The ledger's bottom-panel lane is updated from "no branch and no framing yet" to the framing's real state, and carries an explicit correction: that entry was itself the source of rev 1's census mis-statement. Factual corrections: `InitialTargetResult` is at `message.rs:1145`; `primary_document_window` has four references and two production paths (`daemon.rs:1639`, and `daemon.rs:2998` via `primary_document_buffer`, which is census #22); fifteen PRs merged since the parent's last re-scout, not eleven. Co-Authored-By: Claude Opus 5 (1M context) --- docs/active-work.md | 44 +- docs/bottom-panel-stage2-framing.md | 681 +++++++++++++++++----------- 2 files changed, 449 insertions(+), 276 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index e031b4a..cdeddc6 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -389,28 +389,42 @@ If it does not, stop and repair the remote/fetch configuration. **isolated-config workspace sweep 3,177 across 92 suites, zero failures**; `git diff --check` clean. Gates were run against the committed tree. -## Bottom-panel lane (Arc 7) — Stage 1 MERGED; Stage 2 (GPU band) is next +## Bottom-panel lane (Arc 7) — Stage 1 MERGED; Stage 2 IN FRAMING -Stage 1 is on `main`; nothing in this arc is in flight. Stage 2 has **no -branch and no framing yet** — the approved parent framing -`docs/bottom-panel-framing.md` (rev 4) is what it re-scouts against. +Stage 1 is on `main`. **Stage 2 is in framing**, no implementation in +flight. - Stage 1 merged as **#155** (`main` @ `e745068`, 2026-07-24, after two review rounds). No protocol change. Durable substrate facts live in `docs/agent-handoff.md` §1; the two round lessons are in §5. +- Landed-docs follow-up merged as **#156** (`main` @ `d152120`, + 2026-07-25). +- **Stage 2 framing: `docs/bottom-panel-stage2-framing.md` revision 2**, + on branch `githubsucks/bottom-panel-stage2-framing`, worktree + `../pmacs-bp-stage2`, based on `githubsucks/main` @ `d152120`. Round 1 + closed 2 blocking + 3 high findings; awaiting round 2. The approved + parent framing `docs/bottom-panel-framing.md` (rev 4) remains + authoritative, **including its acceptance criteria 37–55**. - Retained, carrying nothing unmerged: branch `bottom-panel` and worktree `../pmacs-bottom-panel`. -- **Stage 2 obligations, already named by the framing** — the starting - point for its own framing doc: `InstanceMessage::PanelFrame` plus - `FrontendEvent::{FrontendCellGeometry, PanelResizeRows, PanelPointer}` - at the next available protocol version, gated in both directions and - each extended enum byte-pinned on its own previous final variant; - extracting `paint_frame`'s per-window body *together with* the - active-window auto-scroll preparation; routing every consumer in the - framing's §1.3 census of 23 transitive active-context reads through - `primary_document_window`; the focus-chrome surface matrix (Q#BP14b); - and Q#BP17's fold-projection parameter plus the stale invariant comment - at `src/window.rs`. Stage 3 is the adopter default flip. +- **Stage 2 ships as two serial slices**, 2A landing before 2B branches: + **2A** = classified §1.3 census routing + `paint_frame` per-window + painter extraction (with the active-window auto-scroll preparation), no + protocol change; **2B** = protocol **v21** + (`InstanceMessage::PanelFrame` plus + `FrontendEvent::{FrontendCellGeometry, PanelResizeRows, PanelPointer}`, + gated both directions, each extended enum byte-pinned on its own + previous final variant), daemon panel projection, the GPU band, and the + negotiated `panel_capable` flip. Stage 3 is the adopter default flip. +- **Correction — this entry previously mis-stated the census contract.** + It is **not** "route every consumer through `primary_document_window`". + Q#BP14 classifies the 23 reads into four classes and routes only the + **Projection** class that way; focus/input (#13–#15, #23), focus chrome + and surface-routed (#16–#19), and focus/session (#20) keep their own + authorities. Rerouting them would break remote-op validation and + application, `DispatchIdle`, presence, focused search/menu/completion + routing, and terminal bell ownership. The Stage 2 framing carries the + full table. - **Folding Stage 3 and this arc's Stage 2 both touch the semantic projection.** Whichever is framed second re-scouts the other's landed state. diff --git a/docs/bottom-panel-stage2-framing.md b/docs/bottom-panel-stage2-framing.md index 19f9203..c85821c 100644 --- a/docs/bottom-panel-stage2-framing.md +++ b/docs/bottom-panel-stage2-framing.md @@ -1,326 +1,485 @@ # Bottom panel Stage 2 — the GPU panel band (framing) -**Revision 1 — pre-implementation. Ground truth: canonical `main` @ -`5aa9044`, protocol v20, 2026-07-25.** +**Revision 2 — pre-implementation. Ground truth: canonical `main` @ +`d152120`, protocol v20, 2026-07-25.** Stage 1 (#155, merge `e745068`) gave pmacs window placement, window parameters, TUI side windows, the divider, and the adopter `display` opt-in. It deliberately set `FrontendView::panel_capable = false` for every semantic session, so a GPU frontend silently falls back to the -non-side target. **Stage 2 flips that bit and earns the right to.** +non-side target. **Stage 2 flips that bit, under an exact negotiated +rule, and earns the right to.** This document is the re-framing `docs/bottom-panel-framing.md` (rev 4) §2 requires before Stage 2 is implemented. It does **not** restate the -parent's decisions. It records what the re-scout against current `main` -found: which anchors moved, which parent claims survived, which did -not, and the four questions the parent framing cannot answer without a -decision from the user. +parent's decisions or replace its acceptance criteria. It records the +re-scout against current `main`, closes the four scout obligations +review round 1 required, and fixes what round 1 found wrong. -Read the parent framing's Q#BP8, Q#BP9, Q#BP14b, Q#BP15, Q#BP15a, -Q#BP16, and Q#BP17 alongside this. Those decisions stand except where -§4 below revises them. +**Inherited reading, all of which remains authoritative:** parent +Q#BP8 (the band), Q#BP9 (protocol), **Q#BP14 (the primary-document +projection contract and its census classification)**, **Q#BP14a (panel +input gating is per-window)**, Q#BP14b (focus chrome and per-window +overlay routing), Q#BP15 (`PanelFrame` lifecycle), Q#BP15a (three +geometries), Q#BP16 (pointer transport), Q#BP17 (fold projection), and +**parent acceptance criteria 37–55**. -## 0. Why the re-scout was required +## 0. Revision history -The parent framing's Stage 2 sections were written against `main` @ -`0dd16a5` and last re-scouted at `47581f4`. Since then eleven PRs have -merged: #149/#150 (folding Stage 2), #152–#155 (through bottom-panel -Stage 1), #158–#166 (inline math, minimap, Lean 4 Stages 1–2, -COHERENCE.md, find-file, the dired framing and Stage 1, the GPU -terminal input fix). Every source anchor Stage 2 depends on has moved. +### 0.1 Round 1 (rev 1 → rev 2) — 2 blocking, 3 high, 3 revision points, all closed -Two things did **not** change, and both are load-bearing: - -- **Protocol is still v20.** `PROTOCOL_VERSION` is `20` - (`pmacs-protocol/src/message.rs:1568`); no intervening PR bumped it. - Q#BP9's conditional resolves: **Stage 2 is v21**. -- **Both byte pins are still the final variants.** - `InstanceMessage::InitialTargetResult` is last in its enum - (`message.rs:577` within the enum at `:569`), and - `FrontendEvent::TerminalPointer` is last in its own. Q#BP9's - append-plus-pin instruction applies verbatim, with no re-derivation. +- **R1-1 (blocker).** Rev 1 said all 23 census reads route through + `primary_document_window`. That contradicts Q#BP14, which routes only + the **Projection** class (#1–#12, #21–#22) that way and leaves focus, + input, chrome, and bell consumers on their own authorities. Rev 1's + rule would have broken remote-op validation, `DispatchIdle`, + presence, focused search/menu/completion routing, and bell ownership. + §3.2 now restores all four classes; §7's criterion pins them + separately. The inherited-reading list above gains Q#BP14 and Q#BP14a. +- **R1-2 (blocker).** Rev 1 treated the three `src/statusline.rs` + active reads as one disposition. Only `:644` selects the wrong + window; `:629` and `:675` must keep tracking **actual focus**. §3.3 + is rewritten and the criterion states the required behavior instead + of routing focus away. +- **R1-3 (high).** The `panel_capable` flip needed an exact attach + rule, not "for semantic sessions". §3.5 states it: **v21-or-later + negotiated authenticated semantic session only**. +- **R1-4 (high).** Option 1 accepted, but the epoch needed a state + machine, split APIs, and a fail-closed allocator. §3.1 now carries + the transition table and the API split. Rev 1's phrasing "rejects a + lower-or-equal epoch carrying different data" was itself wrong — a + lower epoch carrying *identical* data is still stale. +- **R1-5 (high).** Rev 1's eleven draft criteria silently omitted + parent 37–55. §7 now declares the parent list authoritative, maps it + to 2A/2B, and adds only refinements. The painter-extraction criterion + pins cursor, `view_top`, and passive-window state, not just cells. +- **R1-6.** All four scout obligations are closed in §5. +- **R1-7.** The coherence statement understated journey impact and + overclaimed on background work. §6 names journey steps 7–10 and + narrows the §9 claim. +- **R1-8.** Factual corrections in §1 and §3.2. ## 1. Anchor re-scout -Every line reference Stage 2 inherits, re-verified. "Claim" is the -parent framing's assertion about that site; "verdict" is what the code -at `5aa9044` actually says. +| Parent anchor | Now at | Verdict | +| --- | --- | --- | +| `paint_frame` returns cursor separately (`editor.rs:2833`) | `src/editor.rs:3171` | Holds | +| Cursor-visible prep (`editor.rs:2883-2935`) | `src/editor.rs:3249+` | Holds; Stage 1 inserted work above it (§2) | +| Per-window paint body (`editor.rs:2937-3040`) | after `src/editor.rs:3260` | Holds | +| `fold_map_for_window` gates on the **active** frontend (`editor_core.rs:566`) | `src/editor_core.rs:734`, gate at `:738` | Holds | +| Stale "semantic session never enters `paint_frame`" (`window.rs:339`) | `src/window.rs:562` | Holds, still stale; now embedded in a longer `fold_projection` doc block, so the edit is a paragraph rewrite | +| `Mouse` is contractually the grid path (`daemon.rs:3122-3130`) | `src/daemon.rs:3123` | Holds | +| Permanent `24×80` placeholder (`attach.rs:420-429`, `:573-577`) | `pmacs-gpu/src/attach.rs:577`, single site | Holds | +| Byte pin `InstanceMessage::InitialTargetResult` | `pmacs-protocol/src/message.rs:1145` | Holds — still the enum's final variant | +| Byte pin `FrontendEvent::TerminalPointer` | final variant of its enum | Holds | -| Parent anchor | Now at | Claim | Verdict | -| --- | --- | --- | --- | -| `src/editor.rs:2833` `paint_frame` returns cursor separately | `src/editor.rs:3171` | cells alone lose the caret | **Holds.** Signature still returns `Option` | -| `src/editor.rs:2883-2935` cursor-visible prep | `src/editor.rs:3249+` | extract with the per-window body | **Holds**, but see §3.1 — Stage 1 inserted work *above* it | -| `src/editor.rs:2937-3040` per-window paint body | after `:3260` | origin-agnostic `Viewport<'a>`, extractable | **Holds** | -| `src/editor_core.rs:566` `fold_map_for_window` | `src/editor_core.rs:734` | gates on the **active** frontend | **Holds** — `:738` is `if !self.fold_projection_active()` | -| `src/window.rs:339` stale invariant comment | `src/window.rs:562` | "a semantic session never enters `paint_frame`" | **Holds, still stale.** Updating it remains a Stage 2 obligation | -| `src/statusline.rs:634` indirect `view.active` read | `src/statusline.rs:629`, `:644`, `:675` | one read to close | **Revised: three sites**, not one | -| `src/daemon.rs:3122-3130` grid-only `Mouse` | `src/daemon.rs:3123` | `Mouse` is contractually the grid path | **Holds** | -| `pmacs-gpu/src/attach.rs:420-429`, `:573-577` | `pmacs-gpu/src/attach.rs:577` | permanent `24×80` placeholder | **Holds**, single site now | +**Protocol is still v20** (`pmacs-protocol/src/message.rs:1568`); no +intervening PR bumped it. Q#BP9's conditional resolves: **Stage 2 is +v21**, no reservation was taken and none was needed. -Nothing in the parent's mechanical model was falsified by the -re-scout. The decisions in §4 come from what Stage 1 *added*, not from -anything Stage 2 got wrong. +Fifteen PRs merged between the parent's last re-scout (`47581f4`) and +this one: #149, #150, #152–#155, #158–#166. Nothing in the parent's +mechanical model was falsified by any of them. ## 2. What Stage 1 already built for Stage 2 -More than the parent framing anticipated, which shrinks Stage 2 and -changes one of its wire contracts. +- `DeclaredFrameGeometry { geometry_epoch: u64, total: CellSize }` + (`src/window.rs:522-528`), held as + `FrontendView::frame_geometry: Option<_>` (`:589`) where `None` means + **unknown** — Q#BP15a's "unknown is first-class", already landed. +- `EditorState::sync_frame_geometry` (`src/editor.rs:877-882`) → + `declare_frame_geometry` + `reconcile_panel_layout`, driven from two + daemon sites gated on `panel_capable_for` (`src/daemon.rs:1882-1883` + attach, `:1972-1973` resize). +- `paint_frame` declares geometry itself (`src/editor.rs:3187`), before + the statusline fan-out and before the long mutable core borrow. +- `StatuslineEvaluationTarget` (`src/statusline.rs:212-226`) is already + a two-variant enum, so Q#BP8's fan-out generalization is an added + variant, not a refactor. +- `primary_document_window` (`src/editor_core.rs:2830`) and + `primary_document_buffer` (`:2845`). -- **`DeclaredFrameGeometry { geometry_epoch: u64, total: CellSize }` - exists** (`src/window.rs:522-528`), stored as - `FrontendView::frame_geometry: Option<_>` (`:589`) with `None` - meaning **unknown** — exactly Q#BP15a's "unknown is first-class". -- **The declaration path exists.** - `EditorState::sync_frame_geometry` (`src/editor.rs:877-882`) calls - `EditorCore::declare_frame_geometry` then `reconcile_panel_layout`. - Two daemon sites already drive it, both gated on - `panel_capable_for` (`src/daemon.rs:1882-1883` at attach, - `:1972-1973` on resize). -- **`paint_frame` itself declares geometry** (`src/editor.rs:3187`), - before the statusline fan-out and before the long mutable core - borrow, with a comment naming Q#BP2b/Q#BP15a. -- **`StatuslineEvaluationTarget`** (`src/statusline.rs:212-226`) is - already a two-variant enum — `Grid { frontend_id }` and `Semantic { - frontend_id, declared_buffer }`. Q#BP8's "generalize the fan-out" - is an added variant, not a refactor of a concrete type. -- **`primary_document_window`** exists (`src/editor_core.rs:2830`). +## 3. Findings and decisions -## 3. What the re-scout found +### 3.1 Q#BP2S1 — epoch ownership, resolved: frontend-owned, with an exact state machine -### 3.1 The geometry epoch is allocated daemon-side; the wire contract says frontend-side +**Decision: option 1.** The epoch is owned by the frontend for +negotiated semantic-panel sessions. The deciding argument is one rev 1 +missed: **a font or scale transaction can require invalidating an old +`PanelFrame` even when the derived `CellSize` is identical.** Daemon +value dedup cannot detect that case, because the cell totals it +compares are unchanged while the pixels behind them are not. -This is the one genuine conflict between landed Stage 1 and framed -Stage 2, and it needs a decision before implementation. +The landed allocator conflicts in three ways +(`src/editor_core.rs:3155-3172`): it allocates the id itself, it +early-returns when `total` is unchanged (value dedup), and it uses +`saturating_add`, which is neither wrapping nor fail-closed — it pins +at `u64::MAX`, after which two different geometries share one id. -`declare_frame_geometry` (`src/editor_core.rs:3155-3172`) **allocates -the epoch itself**: +**Acceptance rules for a semantic declaration:** -```rust -let next = view - .frame_geometry - .map_or(1, |geometry| geometry.geometry_epoch.saturating_add(1)); -``` +| Incoming declaration | Result | +| --- | --- | +| epoch **greater** than stored | Accept, store **verbatim**, even if `total` is unchanged | +| same epoch, same `total` | Idempotent no-op | +| same epoch, **different** `total` | Reject | +| **lower** epoch, any `total` | Reject | -Q#BP15a specifies the opposite: `FrontendEvent::FrontendCellGeometry { -frontend_id, geometry_epoch, total }` carries a **frontend-owned** -declaration id, and `PanelResizeRows` / `PanelPointer` / every -`Present` echo it. Under the landed code the daemon would have to -either ignore the wire epoch (breaking the echo contract the GPU -validates against) or overwrite its own allocator for panel-capable -semantic frontends only (two allocation regimes for one field). +The last row is deliberate and corrects rev 1: a lower epoch carrying +identical data is still stale and must not be accepted. -Two further details of the landed allocator matter: +**API split.** Two methods, not one method with an optional epoch: -- **It dedups on value.** The function returns early when `total` is - unchanged, so the epoch advances only on an actual size change. For - a grid frontend that is correct — cells are the unit, and an - unchanged grid means an old `PanelFrame` is still valid under the new - metrics. For a frontend that *owns* its epoch, the daemon cannot - dedup by value without discarding a declaration the frontend already - considers current. -- **`saturating_add` is neither wrapping nor fail-closed.** Q#BP15a - requires exhaustion to "fail closed rather than wrap". Saturation - pins the epoch at `u64::MAX`, after which two different geometries - share one id — the exact staleness confusion the epoch exists to - prevent. Unreachable in practice; wrong as a contract, and free to - fix. +- `declare_frame_geometry(fid, total)` — the **grid/LOCAL** allocator. + Keeps value dedup (correct there: cells are the unit, and an + unchanged grid means an old frame is still valid under unchanged + metrics). Changes from `saturating_add` to **checked** allocation + with an explicit fail-closed exhaustion arm. +- `accept_frame_geometry(fid, geometry_epoch, total) -> bool` — the + **semantic** path. No value dedup; applies the table above verbatim; + returns whether the declaration was accepted so the caller can drop + a stale event before any reconciliation. -**Q#BP2S1 (new, needs a decision).** Three candidate resolutions: +An ambiguous single method with an `Option` epoch is rejected +explicitly: it would let a future caller silently take the wrong regime. -1. **Frontend-owned, as framed.** `FrontendCellGeometry` carries the - epoch; the daemon stores it verbatim for semantic panel-capable - frontends and rejects a lower-or-equal epoch carrying different - data. Grid/LOCAL keep the local allocator, which never collides - because those frontends never send the event. Cost: one field, two - provenances, documented. -2. **Daemon-owned, GPU echoes.** `FrontendCellGeometry` carries only - `total`; the daemon allocates and the GPU learns its current epoch - from the next `PanelFrame`. Simpler invariant, but it reintroduces a - first-open ordering problem — the GPU must send `PanelResizeRows` - and `PanelPointer` carrying an epoch it has not been told yet, so - the first gesture after a resize is unvalidatable and must be - dropped. -3. **Frontend-owned everywhere.** Grid/LOCAL synthesize an epoch at - their existing declaration sites and the allocator moves out of - `EditorCore` entirely. Most uniform; largest Stage 1 churn, and it - touches code #155 just stabilized. +**Initial epoch and exhaustion.** The frontend's first declaration +after attach acceptance carries epoch `1`; `0` is reserved as "never +declared" and is rejected on the wire. Frontend-side allocation is +checked; on exhaustion the frontend stops declaring and **hides its +panel** rather than reusing or wrapping an id — it sends no further +geometry, so the daemon's last accepted declaration stands and no new +`Present` can claim a fresh identity. Daemon-side exhaustion on the +grid path fails closed the same way: no new declaration, panel stays +at its last valid geometry or hides under Q#BP2b. -**Recommendation: option 1.** It preserves the parent framing's -validation chain intact, and the "two provenances" cost is one doc -comment on a field that already carries three. +### 3.2 The census is classified, and it is mostly unrouted -### 3.2 The §1.3 census is essentially unrouted +**Correction to rev 1.** Q#BP14 routes only the **Projection** class +through `primary_document_window`. Rev 1's "all 23 reads" was wrong and +would have broken five subsystems. The four classes, restored: -The parent framing's §1.3 lists 23 transitive active-context reads that -must route through `primary_document_window` before a panel can hold -focus without corrupting the document mirror. Stage 1 created the seam -but routed almost nothing through it: `primary_document_window` has -**three** references in `src/`, one of which is its own definition and -one a doc-comment link. The single production caller is -`src/daemon.rs:1639`. +| Class | Census items | Authority | +| --- | --- | --- | +| **Projection** | #1–#7, #9, #10, #12, #21, #22 | `primary_document_window` / `primary_document_buffer` | +| **Projection + focus** | #8 (document `Pointer`), #11 (full-window `TerminalPointer`) | Align the primary document window **and then activate it** — the one place the two legitimately move together | +| **Focus / input** | #13 (remote-op validation), #14 (`dispatch_idle_for`), #15 (presence), #23 (remote-op application) | The frontend's **actually focused** window. Q#BP14a: gating is per-window, never per-buffer | +| **Focus chrome / surface-routed** | #16–#19 (search, menu, minibuffer, completion) | Q#BP14b's routing table — the currently owned surface, with authoritative clears for the other | +| **Focus / session** | #20 (terminal bell drain) | Per-session counter; the **focused** window chooses which session may drain | -For scale, `src/*.rs` still contains ~80 non-test direct `.active` -reads (excluding `active_frontend`, setters, and predicates), on top of -the `active_window*` / `active_buffer*` helper family at -`src/editor_core.rs:663-967`. +Rerouting any of the last three classes to the document is a defect, +not a simplification: it would break remote-op validation and +application, `DispatchIdle`, presence, focused search/menu/completion +routing, and bell ownership. -This is not a defect in Stage 1 — with `panel_capable = false` for -every semantic session, no semantic frontend can hold a side window, so -the unrouted reads are unreachable from the GPU. It does mean **the -census is the bulk of Stage 2's work**, not a tidy-up at the end, and -the stage plan in §5 sequences it first. +**How much is already routed.** `primary_document_window` has **four** +references in `src/` and **two production paths**: directly at +`src/daemon.rs:1639` (#148's initial-target bootstrap, Q#BP11b), and +through `primary_document_buffer` at `src/daemon.rs:2998`, which is +census **#22** and carries a comment naming it. So one census item is +routed and the Projection class is otherwise open. For scale, `src/*.rs` +still holds ~80 non-test direct `.active` reads on top of the +`active_window*` / `active_buffer*` helper family +(`src/editor_core.rs:663-967`). -### 3.3 The statusline read is three sites, not one +This is not a Stage 1 defect — with `panel_capable = false` no semantic +frontend can hold a side window, so the unrouted Projection reads are +unreachable from the GPU. It does mean **classified census routing is +the bulk of Stage 2**, which is why it is Stage 2A. -Q#BP8 says closing the indirect `view.active` read at -`src/statusline.rs:634` falls out of the target generalization. There -are three: `:629` and `:675` compute `active: window_id == -view.active`, and `:644` does `.get(&view.active)`. They are the same -concern, but a fix that closes one and leaves two is a live risk, and -the acceptance criterion should name all three. +### 3.3 The three statusline reads have two dispositions, not one -### 3.4 Scout obligations still open +All three sites are real, but only one is wrong: -Stated plainly rather than papered over. These were not re-verified in -this pass and must be before the doc leaves revision 1: +- `src/statusline.rs:644` — `.get(&view.active)` **selects the wrong + window** when a panel is focused. This is the Projection read (#12). +- `src/statusline.rs:629` and `:675` — `active: window_id == + view.active` **must continue tracking actual focus**. Three reasons: + grid contexts need a truthful `active`; post-callback revalidation + must notice a focus change; and parent acceptance 42 explicitly + requires that a document provider may observe `active = false` while + the panel is focused. -- `pmacs-protocol/src/terminal.rs`'s validator internals, which Q#BP15 - asks to factor into a shared parameterized wire-cell-grid validator - (the `MAX_TERMINAL_ROWS/COLS = 512` split). -- `pmacs-gpu/src/attach.rs`'s bounded outbox policy and its existing - tail-coalescing classes, which Q#BP15a asks to extend with two new - classes and Q#BP16 with two more. -- The GPU-side band renderer and where it clips against the status - band — Q#BP15a's pixel formula is stated but its inputs - (`status_band_height_px`, `TEXT_TOP_px`, `code_line_height_px`, - `resolved_monospace_advance_px`) were not located in this pass. -- Whether folding Stage 3 lands first. Both stages touch the semantic - projection, and the ledger's standing rule is that whichever is - framed second re-scouts the other's landed state. +**The new semantic-layout target** therefore captures the **primary +document window plus the visible side window**, marks each context +`active` iff its `window_id == view.active`, invokes each provider +**exactly once**, and **invalidates the entire evaluation** if a +callback mutates layout or focus. Unprojected document splits run no +callbacks (Q#BP8). Route the primary-document result to semantic +`StatuslineSegments` and the side result to the panel mode line. + +### 3.4 Fold projection + +Unchanged from Q#BP17, with the anchor corrected: the extracted painter +takes the map as a **parameter**; the panel path passes `None` when the +owning frontend's `fold_projection` is false and must never call +`fold_map_for_window`, which gates on the **active** frontend +(`src/editor_core.rs:734`, gate at `:738`) — right for command-time +reckoning, wrong for painting another frontend's panel. The stale +comment is at `src/window.rs:562`. + +### 3.5 The `panel_capable` flip needs a negotiated rule + +Not "true for semantic sessions". Exactly: + +> `panel_capable = true` **only** for an authenticated semantic session +> that negotiated **v21 or later**. + +A v6–v20 semantic frontend stays non-panel-capable and takes the +existing Stage 1 fallback: the non-side target with **every +side-specific parameter discarded**, leaving the document window +undedicated (Q#BP2c). "It receives no new events" is insufficient — if +the daemon nevertheless places that frontend's window in a side panel +it cannot render, the window becomes invisible. The gate is on +placement, not only on transport. Parent acceptance 51 pins the mixed +session. ## 4. Revisions to the parent framing -Only these. Everything else in Q#BP8/9/14b/15/15a/16/17 stands. +Only these; everything else stands. -- **Q#BP9 resolves to v21.** No reservation was taken; none was needed. -- **Q#BP15a's epoch ownership is reopened as Q#BP2S1** (§3.1). -- **Q#BP8's statusline criterion names three sites** (§3.3). -- **Q#BP17's stale comment is at `src/window.rs:562`**, and its text is - now embedded in a longer `fold_projection` doc block that also - explains the Stage 2/Stage 3 split — the edit is a paragraph rewrite, - not a one-line correction. +- **Q#BP9 resolves to v21.** +- **Q#BP15a's epoch ownership is specified** by §3.1's table and API + split, replacing the parent's one-line "frontend-owned" statement. +- **Q#BP8's statusline criterion splits** per §3.3: one read reroutes, + two keep tracking focus. +- **Q#BP17's stale comment is at `src/window.rs:562`**, and parent + acceptance 52's reference to `:339` should be read against that. -## 5. What ships, in order +## 5. The four scout obligations, closed -Sequenced so each step is independently gateable and the census — the -riskiest part — lands before anything depends on it. +### 5.1 The shared cell-grid validator boundary -1. **Route the census.** Every §1.3 read through - `primary_document_window`, with `panel_capable` still `false`. No - wire change, no behavior change for any existing frontend; pure - seam adoption, falsifiable by revert. -2. **Extract the per-window painter.** Lift `paint_frame`'s per-window - body plus the active-window cursor-visible preparation into a - function taking the fold map as a **parameter** (Q#BP17), leaving - `sync_frame_geometry` and the statusline fan-out where Stage 1 put - them. Grid rendering must be byte-identical. -3. **Protocol v21.** Append `InstanceMessage::PanelFrame` and - `FrontendEvent::{FrontendCellGeometry, PanelResizeRows, - PanelPointer}`, each with a byte pin on the current final variant. - Factor the shared cell-grid validator. Gated both directions. -4. **Daemon-side panel projection.** `PanelFrame` production, - presentation epochs, `Absent` authority, the third statusline - target, the focus-chrome pass (Q#BP14b). -5. **GPU band.** Geometry declaration, band paint, divider chrome, - document clip, pointer transport, and the `panel_capable = true` - flip for semantic sessions — the flip is last, and it is what makes - the whole stage observable. +`TerminalFrame::validate` (`pmacs-protocol/src/terminal.rs:226`) +currently interleaves both concerns. The exact split: -Steps 1 and 2 are reviewable without any protocol change and could ship -as a separate PR if the user prefers a smaller first review. **That is -one of the questions in §8.** +- **Factored into the shared parameterized wire-cell-grid validator:** + checked area (the `checked_mul` + `usize::try_from` guard), the + `MAX_TERMINAL_VISIBLE_CELLS = 262,144` aggregate cap, cell-count + equality against declared area, cursor-in-bounds, and + `validate_cells`'s glyph width / continuation topology and aggregate + glyph-byte checks. +- **Stays terminal-only:** the `MAX_TERMINAL_ROWS/COLS = 512` per-axis + caps in `checked_area`, `validate_metadata` for title/signal/crash + text, `validate_selection`, and the `at_bottom == (scroll_offset == + 0)` coupling. + +`PanelFrame` takes the shared half plus its own presence/epoch rules +and does **not** inherit the 512 per-axis cap (Bet B5'), so a 4K +small-font panel wider than 512 columns is legal while the shared area +budget still binds. Parent acceptance 39 pins exactly this. + +### 5.2 The GPU outbox needs four more tags + +`coalesce_kind` (`pmacs-gpu/src/attach.rs:331`) today returns four +tail-only tags: `Viewport` → 0, `Pointer{Drag}` → 1, +`TerminalPointer{Move}` → 2, `TerminalPointer{Drag}` → 3. Everything +else is `None` = lossless, counting against `OUTBOX_MAX = 8192`. + +Stage 2 adds **four distinct tags**: `FrontendCellGeometry` → 4, +`PanelResizeRows` → 5, `PanelPointer{Move}` → 6, `PanelPointer{Drag}` +→ 7. Geometry is latest-wins (epochs need only increase, not be +consecutive); resize drag is latest-wins over the complete event +including its epochs. `PanelPointer` `Down`/`Up`/wheel/context stay +lossless and ordered — repeated left `Down`s are what the daemon click +state reads as a multi-click, and `Down(Right)` is the context-menu +gesture. Tail-only replacement preserves ordering across an +intervening event of any other class. + +### 5.3 The pixel formula's inputs — and one trap + +The formula in Q#BP15a is contract-level, not an implementation +detail, because its inputs are not all safe to adopt: + +| Input | Source | Note | +| --- | --- | --- | +| `status_band_height_px` | `FontMetrics::status_band_height` (`pmacs-gpu/src/main.rs:137`) = `BASE_STATUS_BAND_HEIGHT * scale` | Safe | +| `TEXT_TOP_px` | `const TEXT_TOP: f32 = 16.0` (`main.rs:352`) | Safe; unscaled today | +| `code_line_height_px` | `FontMetrics::code_line_height` (`main.rs:131`) = `BASE_CODE_LINE_HEIGHT * scale` | Safe | +| `resolved_monospace_advance_px` | `State::mono_advance` (`main.rs:4899`) | **Unsafe to adopt blindly** | +| `divider_height_px` | `BASE_DIVIDER_HEIGHT` | **Does not exist yet** | + +**The `mono_advance` trap.** `State::mono_advance` returns +`measured_mono_advance` when a `FontFacts` probe has been applied, but +otherwise falls back to **the first shaped glyph of the document +buffer** (`main.rs:4903+`). Panel column count would therefore become +**document-dependent**: two GPU frontends showing different files could +derive different `total.cols` from identical metrics, and the same +frontend's panel width could change when the document's first glyph +changes. + +**Decision.** The panel geometry declaration uses a **stable normal-face +probe**, never the document sample. `probe_mono_advance(font_system, +family, metrics)` (`main.rs:323`) already exists and is exactly this: it +shapes `ADVANCE_PROBE` in a scratch buffer, independent of document +contents, dividing total run width by logical cells so ligature +substitution survives. The declaration resolves its advance from that +probe for the current family/metrics. If the probe returns `None` (the +family shapes no width), the frontend declares **zero usable geometry** +under a new epoch — the panel hides — rather than falling back to a +document sample. + +**`BASE_DIVIDER_HEIGHT` must be decided before implementation**, because +the document-bottom seam it defines is depended on by caret placement, +hit testing, the minimap, terminal geometry, clipping, and edge +scrolling. Two sub-decisions: + +- **Value and scaling.** It joins the `BASE_*` family and scales as + `BASE_DIVIDER_HEIGHT * scale`, matching `status_band_height` — a + divider that does not scale with the font would misalign at non-1.0 + scale. The concrete base value is an open item for review round 2. +- **One seam, not several.** Today the document bottom is computed + from `status_band_height` at several sites + (`main.rs:3175`, `:3185`, `:6601`, `:6607`, `:8491`, and the + status-band rect at `:5910`). Stage 2 must introduce **one** + document-bottom accessor that subtracts status band **plus** the + installed band and divider, and route every one of those sites + through it. A second, unrouted seam is the exact shape of Stage 1's + `Layout::compute` two-caller defect, where `src/overlay_paint.rs` + derived its own rect and painted peer cursors at unfixed rows. + +Note the asymmetry Q#BP15a already requires: `divider_height_px` is +subtracted **for sizing purposes even while the panel is absent**, to +break the first-open cycle, while the document renderer does not +actually lose those pixels until a `Present` panel is painted. + +### 5.4 Ordering against folding Stage 3 + +Settled by review round 1: **bottom-panel Stage 2 first, through the +landed GPU band.** Folding Stage 3 then re-scouts the extracted +painter, the panel projection, clipping, and `fold_projection` behavior +exactly once. ## 6. Coherence impact (per `COHERENCE.md` §20) -- **Journey steps touched:** none directly. Stage 2 does not add a - journey step; it removes a frontend-dependent *hole* in one. Today a - user who runs `pmacs --gpu` and triggers compile, grep, references, - or a terminal gets the non-side fallback — the panel silently becomes - a stolen window. Every journey step that ends in an output surface +- **Journey steps touched: four, on the GPU frontend — steps 7–10** + (find symbol / find file, terminal, build and test, error + inspection). Rev 1 said "none directly", which contradicted its own + next sentence. Today a GPU user who triggers references, project + search, a terminal, compile, or error inspection gets the Stage 1 + non-side fallback: the output surface steals a document window + instead of opening a panel. Every one of those steps therefore behaves differently on GPU than on TUI, and Stage 2 is what closes - that. + the divergence. - **Interaction islands added: none, and this is a reduction.** §6 grades islands "weak, and growing by one island per modal feature". - The panel is the opposite move: `display = "panel"` is one adopted - policy across listview, compile, and terminal, and Stage 2 extends - the existing policy to a second frontend rather than adding a - parallel GPU-only surface. The focus-chrome routing table (Q#BP14b) - deliberately reuses the existing `SearchPrompt` / `MenuPrompt` / - `CompletionPopup` messages instead of minting panel-specific ones. -- **Config registry adoption:** inherited, not extended. Stage 1's + Stage 2 extends one already-adopted policy (`display = "panel"`, + used by listview, compile, and terminal) to a second frontend rather + than minting a GPU-only surface. Q#BP14b deliberately reuses the + existing `SearchPrompt` / `MenuPrompt` / `CompletionPopup` messages + instead of panel-specific twins. +- **Config registry adoption: inherited, not extended.** Stage 1's `window.panel-height` and `window.min-height` already live in the - registry; Stage 2 adds no new user-facing option. If the GPU needs a - band-specific preference, it enters the registry — no new - configuration mechanism. -- **Background-work attribution:** unchanged. Stage 2 introduces no - worker, task, or process. It does, however, make §9's "✓ mechanics / - ✗ visibility" gap materially cheaper to close on the GPU: a terminal - PTY appearing in no user-visible activity view (§9) is partly a - *placement* problem, and after Stage 2 both frontends have a place to - put one. -- **Section this serves:** `COHERENCE.md` §14, which already records - the panel primitive as landed for Stage 1 and names "Stage 2 (GPU - band) pending its own framing" as the open item. This is that doc. + registry. Stage 2 adds no new user-facing option; if the band needs + one, it enters the registry. +- **Background-work attribution: unchanged, and this stage does not + advance it.** Rev 1 implied Stage 2 helps §9's activity-view gap. It + does not. A panel gives output a coherent *placement*; it does not + make terminal PTYs, LSP servers, or workers appear in the + activity/ownership view §9 describes, and it adds no join key across + the four disjoint activity planes. The §9 gap is untouched. +- **Section this serves:** `COHERENCE.md` §14, which records the panel + primitive as landed for Stage 1 and names "Stage 2 (GPU band) + pending its own framing" as the open item. -## 7. Acceptance criteria (draft) +## 7. Acceptance -Numbered for review; each must be falsifiable by revert, per the -standing lesson that a guard with no production caller passes every -direct-call test. +**Parent criteria 37–55 remain authoritative and are not replaced.** +This section maps them to the two slices and adds only refinements. -1. Every §1.3 census read resolves through `primary_document_window`; - asserted at the outermost user-reachable seam, not by direct call. -2. All three `src/statusline.rs` active reads route through the new - frontend-layout target. -3. Grid `paint_frame` output is byte-identical across the extraction. -4. A semantic frontend with `fold_projection = false` painting a panel - over a folded buffer shows **every** source line (Q#BP17), and the - panel path never calls `fold_map_for_window`. -5. v21 round-trips; v20 peers negotiate without the panel events; each - extended enum's previous final variant is byte-pinned. -6. `Absent` is emitted on both close and hide, and clears input - authority before any later event validates. -7. A `PanelPointer` failing any of Q#BP16's six checks mutates no view, - controller, selection, menu, or PTY. -8. A geometry epoch change makes an older `PanelFrame` non-painted and - non-hit-testable until a matching `Present` arrives. -9. A panel wider than 512 columns is legal; a panel exceeding the - shared wire budget fails closed to `Absent`, not to a partial frame. -10. Panel focus does not disturb the document mirror - (`BufferSnapshot`, `CursorByte`, `Viewport`). -11. Native popups clear on document→panel focus change and panel - popups clear on panel→document, per Q#BP14b's ordering. +### 7.1 Stage 2A — classified census routing + painter extraction -## 8. Questions for the user +No protocol change. Parent criteria that apply in full: **42, 43, 44, +51 (the `LOCAL`-panel inheritance half), 52**. -1. **Q#BP2S1 epoch ownership** (§3.1) — recommendation is option 1, - frontend-owned with the daemon storing verbatim. Confirm or pick - another. -2. **One PR or two?** Steps 1–2 (census + extraction) are protocol-free - and independently valuable; steps 3–5 are the wire and the band. - Splitting gives two small reviews instead of one very large one, at - the cost of a second round-trip. -3. **Ordering against folding Stage 3.** Both touch the semantic - projection. Framing bottom-panel Stage 2 first means folding Stage 3 - re-scouts against a landed band. Confirm that order. -4. **Scout obligations in §3.4** — should revision 2 close all four - before implementation, or is the GPU-side pixel formula (the largest - of them) allowed to be settled during implementation? +Refinements 2A adds: -## 9. Gates +- **A2A-1 (replaces rev 1's criterion 1).** Every **Projection** census + item (#1–#7, #9, #10, #12, #21, #22) resolves through + `primary_document_window` / `primary_document_buffer`; #8 and #11 + align **and then activate**; **#13, #14, #15, #23 continue to resolve + the actually focused window**; #16–#19 follow Q#BP14b's routing + table; #20 keeps its per-session counter with focus choosing the + eligible terminal. Each class is asserted separately, at the + outermost user-reachable seam, and falsified by revert. A test that + only proves "the document is used" would pass with the focus classes + wrongly rerouted, so the focus-class assertions are the load-bearing + half. +- **A2A-2 (replaces rev 1's criterion 2).** `src/statusline.rs:644` + resolves the primary document window, while `:629` and `:675` + continue to report **actual focus** — pinned by a document provider + truthfully observing `active = false` while the panel is focused + (parent 42). The semantic-layout target captures primary document + + visible side window, invokes each provider exactly once, and + invalidates the whole evaluation when a callback mutates layout or + focus. +- **A2A-3 (replaces rev 1's criterion 3).** The painter extraction + preserves, for grid frontends: the painted **cells**, the **returned + cursor**, the **focused window's `view_top` mutation** from the + auto-scroll clamp, and **passive windows' untouched `view_top` and + scroll state**. Byte-identical cells alone would not catch a clamp + that silently moved to the wrong window. -The standing suite, plus `bottom_panel_stage1_acceptance` and a new -`bottom_panel_stage2_acceptance`, the three vterm suites (the panel -hosts terminals), folding Stage 2's 48 (shared projection), and -`PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu`. Protocol round-trip and -byte-pin tests ride step 3. +### 7.2 Stage 2B — v21 protocol + daemon projection + GPU band + +Parent criteria that apply in full: **37, 38, 39, 40, 41, 45, 46, 47, +48, 49, 50, 51, 53, 54, 55**, plus re-assertion of 42/43/44 **through +the actual negotiated capability flip** rather than through a +test-only panel-capable semantic view. + +Refinements 2B adds: + +- **A2B-1.** The epoch state machine of §3.1 is pinned row by row, + including the lower-epoch-identical-data rejection and the + same-epoch-different-total rejection. Grid allocation is checked with + a fail-closed exhaustion arm; the semantic path performs no value + dedup. Epoch `0` is rejected on the wire. +- **A2B-2.** A font or scale change that leaves `CellSize` **identical** + still produces a new `geometry_epoch`, and the older `PanelFrame` + neither paints nor hit-tests until a matching `Present` arrives. This + is the case daemon value dedup cannot see and is why option 1 was + chosen. +- **A2B-3.** Panel columns are derived from the **stable normal-face + probe**, not `State::mono_advance`'s document-glyph fallback: two GPU + frontends with identical metrics and different documents derive + identical `total.cols`, and a probe returning `None` declares zero + usable geometry rather than falling back to a document sample. +- **A2B-4.** Every document-bottom consumer — caret, hit test, minimap, + terminal geometry, clipping, edge scrolling — routes through the one + document-bottom accessor. Falsified by introducing a band and + asserting each consumer moves; a second unrouted seam is the Stage 1 + `Layout::compute` defect class. +- **A2B-5.** `panel_capable` is true only for a v21+ negotiated + authenticated semantic session; a v20 semantic session is never + **placed** in a side window, not merely denied the events. + +## 8. Open items for review round 2 + +1. The concrete `BASE_DIVIDER_HEIGHT` value (§5.3). Scaling and the + single-seam rule are decided; the number is not. +2. Whether `TEXT_TOP` should scale. It is an unscaled constant today + and the formula consumes it as-is; that is pre-existing behavior + Stage 2 inherits rather than fixes, but it is worth a decision + before the conversion is pinned by acceptance. + +## 9. Slices, branches, and gates + +Per review round 1: **two serial implementation PRs**, each a named +slice under this framing so one-feature/one-branch/one-PR holds. **2A +lands before 2B branches** — not stacked. + +- **Stage 2A** — classified census routing + per-window painter + extraction. Branch `bottom-panel-stage2a`. No protocol change. +- **Stage 2B** — v21 protocol, daemon panel projection, GPU band, and + the negotiated `panel_capable` flip. Branch `bottom-panel-stage2b`, + cut from `main` after 2A merges. Repeats 2A's relevant census + assertions through the real capability flip. + +Gates for both: the standing suite, plus +`bottom_panel_stage1_acceptance`, the new +`bottom_panel_stage2a_acceptance` / `bottom_panel_stage2b_acceptance`, +the three vterm suites (the panel hosts terminals), folding Stage 2's +48 (shared projection), and `PMACS_REQUIRE_GPU=1 cargo test -p +pmacs-gpu`. Protocol round-trip and byte-pin tests ride 2B. Parent +criterion 54's `--headless-probe` run — one real daemon, real PTY, real +wgpu, through a panel-hosted terminal — is a 2B gate. From 857a86ff59a1aa8856a36b9306ece44952c68812 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 19:23:49 -0400 Subject: [PATCH 3/5] docs: bottom-panel Stage 2 framing (revision 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes review round 2 — 1 blocking, 2 high, 1 medium — decides both remaining open items, and re-integrates canonical `main` @ `ccf29e3` (#172 + #157; documentation plus one `src/buffer.rs` regression test, no protocol or Stage 2 source anchor moved). **R2-1 (blocker) — the seam is three boundaries, not one.** Rev 2 asked for a single document-bottom accessor. That is wrong: once a panel is installed the present single value must DIVERGE, because several of its consumers must not move at all. `text_area_bottom` (`pmacs-gpu/src/main.rs:8490`) is today `status_band_top`, `geometry_capacity_bottom`, and `document_text_bottom` at once. Rev 3 defines all three, classifies every one of its ~19 call sites as status-owned / document-owned / geometry, and records that four sites rev 2 named (`:3175`, `:3185`, `:6601`, `:6607`) consume a status-band HEIGHT and no bottom coordinate at all, while the status background `:5908` and status text `:7134`/`:7922` must stay at the physical window bottom. The acceptance is now a contrast assertion: installing a panel moves every document-owned consumer WHILE the status band stays pixel-identical. "Everything moved" alone is passed by a blanket rewrite of the helper, which is exactly the wrong implementation. **R2-2 (high) — epoch exactness.** `accept_frame_geometry` returns `Advanced | Duplicate | Rejected` instead of a boolean that cannot separate reconcile-needed from already-current from stale; if a boolean is ever kept internally it must be named `advanced`, since `Duplicate` is also accepted. Rev 2's exhaustion wording permitted retaining stale geometry, which is not fail-closed — a real resize after exhaustion would keep painting a panel sized to disowned geometry. The grid path now clears `frame_geometry` to unknown and reconciles hidden, and the frontend takes a terminal latch so a retained matching `Present` cannot resurrect the band; only a fresh session clears it. **R2-3 (high) — parent acceptance 52 splits.** 2A has no semantic panel projection, so it can only prove the extracted painter honors an explicit `None` map plus the `src/window.rs:562` comment fix. The real contract is production-reachable only in 2B and is reasserted there beside 42/43/44. **R2-4 (medium) — touched gates named**: `statusline_segments_acceptance`, `m11_5_semantic_acceptance`, `gpu_initial_target_acceptance`, `gpu_font_acceptance`, beside the vterm, folding, and GPU suites. Open items decided: `BASE_DIVIDER_HEIGHT = 4.0` at scale 1.0, scaled by `FontMetrics::scale`, whole strip painted `ui.divider` and used as the exact hover/drag hit rect; `TEXT_TOP` stays `16.0` unscaled, with Q#BP15a's "all quantities use the frontend's current scale" narrowed to font-derived metrics and the divider. Wholesale surface-inset/DPI scaling is recorded as separate work, not smuggled in. The ledger's bottom-panel lane keeps its census correction and gains the three-boundary one. Co-Authored-By: Claude Opus 5 (1M context) --- docs/active-work.md | 15 +- docs/bottom-panel-stage2-framing.md | 280 +++++++++++++++++++++------- 2 files changed, 229 insertions(+), 66 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index cdeddc6..b6b48f7 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -399,10 +399,11 @@ flight. `docs/agent-handoff.md` §1; the two round lessons are in §5. - Landed-docs follow-up merged as **#156** (`main` @ `d152120`, 2026-07-25). -- **Stage 2 framing: `docs/bottom-panel-stage2-framing.md` revision 2**, +- **Stage 2 framing: `docs/bottom-panel-stage2-framing.md` revision 3**, on branch `githubsucks/bottom-panel-stage2-framing`, worktree - `../pmacs-bp-stage2`, based on `githubsucks/main` @ `d152120`. Round 1 - closed 2 blocking + 3 high findings; awaiting round 2. The approved + `../pmacs-bp-stage2`, based on `githubsucks/main` @ `ccf29e3`. Round 1 + closed 2 blocking + 3 high; round 2 closed 1 blocking + 2 high + 1 + medium and decided both open items. No open items remain. The approved parent framing `docs/bottom-panel-framing.md` (rev 4) remains authoritative, **including its acceptance criteria 37–55**. - Retained, carrying nothing unmerged: branch `bottom-panel` and worktree @@ -425,6 +426,14 @@ flight. application, `DispatchIdle`, presence, focused search/menu/completion routing, and terminal bell ownership. The Stage 2 framing carries the full table. +- **The GPU document bottom is three boundaries, not one.** + `text_area_bottom` (`pmacs-gpu/src/main.rs:8490`) is today + `status_band_top`, `geometry_capacity_bottom`, and + `document_text_bottom` at once. Once a band is installed they diverge: + the status chrome must stay pixel-identical at the physical window + bottom while document consumers move. A blanket rewrite of that helper + moves both together and passes an "everything moved" assertion, so the + Stage 2 criterion asserts **both directions in one scenario**. - **Folding Stage 3 and this arc's Stage 2 both touch the semantic projection.** Whichever is framed second re-scouts the other's landed state. diff --git a/docs/bottom-panel-stage2-framing.md b/docs/bottom-panel-stage2-framing.md index c85821c..8cffec1 100644 --- a/docs/bottom-panel-stage2-framing.md +++ b/docs/bottom-panel-stage2-framing.md @@ -1,7 +1,7 @@ # Bottom panel Stage 2 — the GPU panel band (framing) -**Revision 2 — pre-implementation. Ground truth: canonical `main` @ -`d152120`, protocol v20, 2026-07-25.** +**Revision 3 — pre-implementation. Ground truth: canonical `main` @ +`ccf29e3`, protocol v20, 2026-07-25.** Stage 1 (#155, merge `e745068`) gave pmacs window placement, window parameters, TUI side windows, the divider, and the adopter `display` @@ -26,6 +26,33 @@ geometries), Q#BP16 (pointer transport), Q#BP17 (fold projection), and ## 0. Revision history +### 0.0 Round 2 (rev 2 → rev 3) — 1 blocking, 2 high, 1 medium, all closed + +- **R2-1 (blocker).** Rev 2's "one document-bottom seam" conflated two + boundaries that must **diverge** once a panel exists. Several sites it + named are not document-bottom consumers at all: the status-band + background (`main.rs:5908`) must stay at the physical window bottom, + the status text buffers (`:3175`, `:3185`, `:6601`, `:6607`) consume a + *height* and never a bottom coordinate, and status text placement + (`:7134`) sits inside an unchanged band. §5.3 now splits the single + value into **three** named boundaries, classifies every existing + `text_area_bottom` call site, and adds the contrast assertion that + catches a uniformly-wrong implementation moving both together. +- **R2-2 (high).** `accept_frame_geometry -> bool` cannot distinguish + *advanced* from *accepted duplicate* from *rejected*. It now returns an + explicit three-valued result. The exhaustion wording also permitted + retaining stale geometry, which is not fail-closed: §3.1 now clears the + authoritative declaration and reconciles to hidden, and adds the + frontend-side terminal latch. +- **R2-3 (high).** Parent acceptance 52 was assigned wholly to 2A, but + 2A has no semantic panel projection — it can only prove the extracted + painter accepts an explicit `None`. 52 is now also reasserted in 2B, + where the contract becomes production-reachable. +- **R2-4 (medium).** §9 names the four touched acceptance suites + explicitly rather than relying on "standing suite". +- Both §8 open items are decided (§5.3): `BASE_DIVIDER_HEIGHT = 4.0` at + scale 1.0, and `TEXT_TOP` stays unscaled. + ### 0.1 Round 1 (rev 1 → rev 2) — 2 blocking, 3 high, 3 revision points, all closed - **R1-1 (blocker).** Rev 1 said all 23 census reads route through @@ -135,23 +162,58 @@ identical data is still stale and must not be accepted. unchanged grid means an old frame is still valid under unchanged metrics). Changes from `saturating_add` to **checked** allocation with an explicit fail-closed exhaustion arm. -- `accept_frame_geometry(fid, geometry_epoch, total) -> bool` — the - **semantic** path. No value dedup; applies the table above verbatim; - returns whether the declaration was accepted so the caller can drop - a stale event before any reconciliation. +- `accept_frame_geometry(fid, geometry_epoch, total) -> GeometryUpdate` + — the **semantic** path. No value dedup; applies the table above + verbatim. An ambiguous single method with an `Option` epoch is rejected explicitly: it would let a future caller silently take the wrong regime. -**Initial epoch and exhaustion.** The frontend's first declaration -after attach acceptance carries epoch `1`; `0` is reserved as "never -declared" and is rejected on the wire. Frontend-side allocation is -checked; on exhaustion the frontend stops declaring and **hides its -panel** rather than reusing or wrapping an id — it sends no further -geometry, so the daemon's last accepted declaration stands and no new -`Present` can claim a fresh identity. Daemon-side exhaustion on the -grid path fails closed the same way: no new declaration, panel stays -at its last valid geometry or hides under Q#BP2b. +**The result is three-valued, not a boolean.** A boolean cannot +distinguish the three outcomes the caller must act on differently: + +```rust +enum GeometryUpdate { + /// Epoch advanced: stored verbatim. Run panel reconciliation. + Advanced, + /// Same epoch, same total: already current. Do no work. + Duplicate, + /// Same epoch with different total, or a lower epoch: stale or + /// conflicting. Drop the event before any reconciliation. + Rejected, +} +``` + +`Advanced` reconciles, `Duplicate` returns without touching panel +state, and `Rejected` drops the event. Collapsing `Duplicate` into +either neighbour is a defect in one direction or the other: folded into +`Advanced` it reconciles on every repeated declaration, folded into +`Rejected` it would log or surface a stale-event condition that never +happened. (If a boolean is kept for a narrower internal caller, it must +be named `advanced`, never `accepted` — `Duplicate` *is* accepted.) + +**Initial epoch.** The frontend's first declaration after attach +acceptance carries epoch `1`. `0` is reserved as "never declared" and +is rejected on the wire. + +**Exhaustion fails closed on both sides, and rev 2's wording did not.** +Saying the panel "stays at its last valid geometry" is not fail-closed: +if the real frame resizes after the allocator is exhausted, the daemon +would keep painting a panel sized to geometry that no longer describes +the frontend. + +- **Grid/LOCAL path.** On checked-allocation exhaustion, **clear** the + authoritative `frame_geometry` (back to `None` = unknown) and + reconcile. Unknown is already non-presentable under Q#BP2b, so the + panel hides. Stale geometry is never retained. +- **Frontend path.** On exhaustion the frontend sets a **terminal + latch** for the life of the session: it sends no further geometry, + and — critically — an old matching `Present` **cannot** make the band + reappear, because the latch suppresses paint and hit-testing + independently of frame validity. Only a fresh session (reconnect) + clears it. Without the latch, a retained `Present` whose epoch still + matches the last declaration would resurrect a band under geometry + the frontend has disowned. ### 3.2 The census is classified, and it is mostly unrouted @@ -319,29 +381,81 @@ family shapes no width), the frontend declares **zero usable geometry** under a new epoch — the panel hides — rather than falling back to a document sample. -**`BASE_DIVIDER_HEIGHT` must be decided before implementation**, because -the document-bottom seam it defines is depended on by caret placement, -hit testing, the minimap, terminal geometry, clipping, and edge -scrolling. Two sub-decisions: +**`BASE_DIVIDER_HEIGHT = 4.0`** at scale 1.0, scaled by +`FontMetrics::scale` like `status_band_height`. A 1–2 px rule is +adequate decoration but too fragile as the drag hit strip; 4 px still +reads as a rule while giving the pointer a usable target. **The entire +strip is painted with `ui.divider`, and that exact rectangle is the +hover/drag hit region** — paint geometry and hit geometry are the same +rect, so they cannot drift apart. -- **Value and scaling.** It joins the `BASE_*` family and scales as - `BASE_DIVIDER_HEIGHT * scale`, matching `status_band_height` — a - divider that does not scale with the font would misalign at non-1.0 - scale. The concrete base value is an open item for review round 2. -- **One seam, not several.** Today the document bottom is computed - from `status_band_height` at several sites - (`main.rs:3175`, `:3185`, `:6601`, `:6607`, `:8491`, and the - status-band rect at `:5910`). Stage 2 must introduce **one** - document-bottom accessor that subtracts status band **plus** the - installed band and divider, and route every one of those sites - through it. A second, unrouted seam is the exact shape of Stage 1's - `Layout::compute` two-caller defect, where `src/overlay_paint.rs` - derived its own rect and painted peer cursors at unfixed rows. +**`TEXT_TOP` stays `16.0`, unscaled.** It is a fixed surface inset +today, like `TEXT_LEFT` and the other paddings, while +`FontMetrics::scale` governs font-derived metrics and row chrome. +Scaling it only inside the declaration formula would disagree with the +actual renderer; scaling every renderer and hit-test occurrence is a +wholesale inset/DPI change and is **named here as separate work**, not +smuggled into Stage 2. The formula is pinned to the real unscaled inset. -Note the asymmetry Q#BP15a already requires: `divider_height_px` is -subtracted **for sizing purposes even while the panel is absent**, to -break the first-open cycle, while the document renderer does not -actually lose those pixels until a `Present` panel is painted. +Accordingly, **Q#BP15a's "all quantities use the frontend's current +scale" is narrowed**: font-derived metrics and the divider scale; fixed +surface insets keep their current units. + +#### The seam is three boundaries, not one + +Rev 2 asked for a single document-bottom accessor. That was wrong: +once a panel is installed, today's single value must **diverge into +three**, because some of its consumers must not move at all. + +``` +status_band_top = surface_height - status_band_height + +geometry_capacity_bottom = status_band_top - reserved_divider_height + // divider reserved even while absent + +document_text_bottom = status_band_top + - (installed_panel_height + divider_height + if Present, else 0) +``` + +`geometry_capacity_bottom` is what Q#BP15a's asymmetry already +requires: the divider is subtracted **for sizing purposes even while +the panel is absent**, which is what breaks the first-open cycle, while +the document renderer does not actually lose those pixels until a +`Present` panel is painted. + +**Today `text_area_bottom` (`pmacs-gpu/src/main.rs:8490`) is all three +at once**, and its doc comment calls it "the single source for every +bottom-of-text computation" (Q#S3). Its ~19 call sites split into three +classes: + +| Class | Boundary | Sites | +| --- | --- | --- | +| **Status-owned** — must stay pixel-identical at the window bottom | `status_band_top` | Status-band background rect `:5908`; band tops `:6003`, `:6027`, `:6140`; status text placement `:7134`, `:7922`; global minibuffer chrome | +| **Document-owned** — must move when a band is installed | `document_text_bottom` | Code/terminal clips `:7174`, `:7195`, `:7212`, `:7242`, `:7273`, `:7351`, `:7421`; caret visibility and code height `:4566`, `:6118`, `:6581`; document completion placement `:8077`; minimap `:8497`; visible-line estimate `:8501` | +| **Geometry declaration** | `geometry_capacity_bottom` | The Q#BP15a conversion only | + +**Sites that consume no bottom coordinate at all** and must not be +touched: `:3175`, `:3185`, `:6601`, `:6607` size the status text +buffers to `status_band_height` directly. Rev 2 listed them as seam +consumers; they are not. + +Each call site is classified individually. A blanket rewrite of +`text_area_bottom` to subtract the band would move the status chrome +with the document and is the defect this section exists to prevent. + +**The contrast assertion (A2B-4).** "Every document consumer moved" is +only half a test — a uniformly wrong implementation that moves +everything passes it. The criterion must assert **both directions in +one scenario**: installing a panel moves every document-owned consumer +**while the status band stays pixel-identical** at the physical window +bottom. That is the assertion a blanket rewrite fails. + +The one-accessor-per-boundary rule still holds within each class: a +second, unrouted derivation of any of the three is the exact shape of +Stage 1's `Layout::compute` two-caller defect, where +`src/overlay_paint.rs` derived its own rect and painted peer cursors at +unfixed rows. ### 5.4 Ordering against folding Stage 3 @@ -390,7 +504,17 @@ This section maps them to the two slices and adds only refinements. ### 7.1 Stage 2A — classified census routing + painter extraction No protocol change. Parent criteria that apply in full: **42, 43, 44, -51 (the `LOCAL`-panel inheritance half), 52**. +51 (the `LOCAL`-panel inheritance half)**, plus the extraction half of +**52**. + +**52 splits across the slices.** 2A has no semantic panel projection +and no `PanelFrame`, so all it can prove is that the extracted painter +honors an explicitly supplied `None` fold map and that the stale +`src/window.rs:562` comment is corrected. The actual contract — *a +semantic panel with `fold_projection = false` never collapses folds and +never calls `fold_map_for_window`* — is production-reachable only once +2B lands the projection and the capability flip. It is therefore +reasserted in 2B (§7.2). Refinements 2A adds: @@ -423,17 +547,26 @@ Refinements 2A adds: ### 7.2 Stage 2B — v21 protocol + daemon projection + GPU band Parent criteria that apply in full: **37, 38, 39, 40, 41, 45, 46, 47, -48, 49, 50, 51, 53, 54, 55**, plus re-assertion of 42/43/44 **through -the actual negotiated capability flip** rather than through a -test-only panel-capable semantic view. +48, 49, 50, 51, 53, 54, 55**, plus re-assertion of **42, 43, 44, and +52** **through the actual negotiated capability flip** rather than +through a test-only panel-capable semantic view. 52's 2B form is the +production one: a real semantic frontend with `fold_projection = false` +displaying a folded buffer in a panel shows every source line, and the +panel path never reaches `fold_map_for_window`. Refinements 2B adds: - **A2B-1.** The epoch state machine of §3.1 is pinned row by row, including the lower-epoch-identical-data rejection and the - same-epoch-different-total rejection. Grid allocation is checked with - a fail-closed exhaustion arm; the semantic path performs no value - dedup. Epoch `0` is rejected on the wire. + same-epoch-different-total rejection, and each row's + `Advanced`/`Duplicate`/`Rejected` result is asserted — a `Duplicate` + performs no reconciliation and a `Rejected` mutates nothing. Epoch + `0` is rejected on the wire. **Exhaustion is pinned on both sides**: + grid exhaustion clears `frame_geometry` to unknown and the panel + hides (a subsequent real resize must not paint a stale-geometry + panel), and a frontend that exhausts latches — a retained `Present` + whose epoch still matches cannot make the band reappear, and only a + fresh session clears the latch. - **A2B-2.** A font or scale change that leaves `CellSize` **identical** still produces a new `geometry_epoch`, and the older `PanelFrame` neither paints nor hit-tests until a matching `Present` arrives. This @@ -444,23 +577,33 @@ Refinements 2B adds: frontends with identical metrics and different documents derive identical `total.cols`, and a probe returning `None` declares zero usable geometry rather than falling back to a document sample. -- **A2B-4.** Every document-bottom consumer — caret, hit test, minimap, - terminal geometry, clipping, edge scrolling — routes through the one - document-bottom accessor. Falsified by introducing a band and - asserting each consumer moves; a second unrouted seam is the Stage 1 - `Layout::compute` defect class. +- **A2B-4 (contrast assertion).** Installing a panel moves **every** + document-owned consumer — code and terminal clips, caret visibility, + document completion placement, gutter/math clipping, minimap, + visible-line estimate, hit testing, edge scrolling — by exactly + `installed_panel_height + divider_height`, **while the status band + stays pixel-identical** at the physical window bottom (background + rect, band top, and status text placement all unchanged). Both halves + are asserted in one scenario: a uniformly wrong implementation that + moves the status band too would pass the "everything moved" half + alone. The geometry declaration separately reserves the divider while + the panel is `Absent`, and the document loses no pixels until a + `Present` is painted. - **A2B-5.** `panel_capable` is true only for a v21+ negotiated authenticated semantic session; a v20 semantic session is never **placed** in a side window, not merely denied the events. -## 8. Open items for review round 2 +## 8. Open items -1. The concrete `BASE_DIVIDER_HEIGHT` value (§5.3). Scaling and the - single-seam rule are decided; the number is not. -2. Whether `TEXT_TOP` should scale. It is an unscaled constant today - and the formula consumes it as-is; that is pre-existing behavior - Stage 2 inherits rather than fixes, but it is worth a decision - before the conversion is pinned by acceptance. +**None.** Both round-1 open items are decided in §5.3: +`BASE_DIVIDER_HEIGHT = 4.0` at scale 1.0 (scaled, whole strip painted +`ui.divider` and used as the hit rect), and `TEXT_TOP` stays unscaled +with wholesale inset/DPI scaling named as separate work. + +One deferral is recorded rather than resolved: **wholesale surface-inset +scaling** (`TEXT_TOP`, `TEXT_LEFT`, and the sibling paddings under +`FontMetrics::scale`) is pre-existing behavior Stage 2 pins rather than +fixes. It belongs to a spacing-system change of its own. ## 9. Slices, branches, and gates @@ -475,11 +618,22 @@ lands before 2B branches** — not stacked. cut from `main` after 2A merges. Repeats 2A's relevant census assertions through the real capability flip. -Gates for both: the standing suite, plus -`bottom_panel_stage1_acceptance`, the new -`bottom_panel_stage2a_acceptance` / `bottom_panel_stage2b_acceptance`, -the three vterm suites (the panel hosts terminals), folding Stage 2's -48 (shared projection), and `PMACS_REQUIRE_GPU=1 cargo test -p -pmacs-gpu`. Protocol round-trip and byte-pin tests ride 2B. Parent -criterion 54's `--headless-probe` run — one real daemon, real PTY, real -wgpu, through a panel-hosted terminal — is a 2B gate. +Gates for both: the standing suite from `CLAUDE.md`, plus the **touched +acceptance suites named explicitly** — the standing rule is to run the +suites a change touches, and "standing suite" does not name them: + +- `bottom_panel_stage1_acceptance` — the substrate both slices build on. +- `bottom_panel_stage2a_acceptance` / `bottom_panel_stage2b_acceptance` + — new, one per slice. +- `statusline_segments_acceptance` — the fan-out target change (§3.3). +- `m11_5_semantic_acceptance` — the semantic census (§3.2). +- `gpu_initial_target_acceptance` — parent criterion 55. +- `gpu_font_acceptance` — font/scale geometry refresh (§5.3), including + the normal-face probe and the unscaled-`TEXT_TOP` decision. +- The three vterm suites — the panel hosts terminals. +- Folding Stage 2's 48 — shared projection. +- `PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu`. + +Protocol round-trip and byte-pin tests ride 2B. Parent criterion 54's +`--headless-probe` run — one real daemon, real PTY, real wgpu, through a +panel-hosted terminal — is a 2B gate. From 49757e51a8513446eed38de667420b0046db0140 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 19:36:58 -0400 Subject: [PATCH 4/5] docs: bottom-panel Stage 2 framing (revision 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes review round 3 — 1 blocking, 1 high, 1 medium. **R3-1 (blocker) — the call-site table contradicted the source.** The three-boundary model was right; five rows of its classification were not, and each was a real defect: - `:6140` is `completion_dropdown_layout` — DOCUMENT completion placement, deriving the space below the anchor line. Classified status-owned, it would let completion overlap the panel. - `:7195` and `:7212` are the `status_buffer` / `status_left_buffer` `TextBounds.top` — status text bounds, classified document-owned. - `:7351` clips global minibuffer CANDIDATE glyphs to the dropdown's band anchor; classified document-owned, they would be clipped against a boundary the dropdown does not sit above. - `:8561` (`edge_scroll_direction`, document edge scrolling) was missing entirely, leaving it tied to the old bottom. - `:8077` is `code_caret_rect_in_clip` — caret clipping, not completion placement. Its class was right, its label wrong. Every production site is now individually verified against the source and tabulated with what it actually is. The census is stated as arithmetic a reader can check: 29 matches = 20 production + 1 definition + 8 test sites. Root cause recorded in the revision history: rev 3's table was built from a `grep | head -20` over 29 matches, which is precisely why `:8561` vanished. The minibuffer's status-owned status is now argued from Q#BP14b rather than assumed — it is global, bufferless chrome anchored to the status band, so all four of its sites stay with the band. **R3-2 (high) — clamps preserved.** The three equations permitted negative coordinates on a surface shorter than its chrome, where today's `text_area_bottom` clamps with `.max(0.0)`. All three now clamp at zero, which keeps the "exact formula" exact exactly where it matters most. **R3-3 (medium) — attachment rejection classified SHARED.** `validate_cells` also rejects `cell.attachment.is_some()` (`terminal.rs:305`), whose error text reads "which terminals never use" (`:190-191`) — phrased as a terminal-specific fact, which is why rev 3's "exact split" missed it. Panels implement no attachment rendering in Stage 2, so a `PanelFrame` carrying one describes a surface the GPU would silently not draw; shared rejection fails closed on the producer side instead. The message is reworded grid-neutral when it moves, and giving panels attachment rendering later moves the rejection back deliberately rather than by default. A2B-4 now names the counts on both sides (twelve document-owned move, eight status-owned do not) and carries the three symptom-bearing rows that a plausible misclassification produces. §9 records that the GPU three-boundary split belongs to 2B, not 2A — it is only observable once a band can be installed. Co-Authored-By: Claude Opus 5 (1M context) --- docs/active-work.md | 19 ++-- docs/bottom-panel-stage2-framing.md | 157 +++++++++++++++++++++++----- 2 files changed, 141 insertions(+), 35 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index b6b48f7..b60d62c 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -399,11 +399,13 @@ flight. `docs/agent-handoff.md` §1; the two round lessons are in §5. - Landed-docs follow-up merged as **#156** (`main` @ `d152120`, 2026-07-25). -- **Stage 2 framing: `docs/bottom-panel-stage2-framing.md` revision 3**, - on branch `githubsucks/bottom-panel-stage2-framing`, worktree - `../pmacs-bp-stage2`, based on `githubsucks/main` @ `ccf29e3`. Round 1 - closed 2 blocking + 3 high; round 2 closed 1 blocking + 2 high + 1 - medium and decided both open items. No open items remain. The approved +- **Stage 2 framing: `docs/bottom-panel-stage2-framing.md` revision 4**, + on branch `githubsucks/bottom-panel-stage2-framing` (three commits, + one per revision), worktree `../pmacs-bp-stage2`, based on + `githubsucks/main` @ `ccf29e3`. Round 1 closed 2 blocking + 3 high; + round 2 closed 1 blocking + 2 high + 1 medium and decided both open + items; round 3 closed 1 blocking + 1 high + 1 medium. No open items + remain. The approved parent framing `docs/bottom-panel-framing.md` (rev 4) remains authoritative, **including its acceptance criteria 37–55**. - Retained, carrying nothing unmerged: branch `bottom-panel` and worktree @@ -433,7 +435,12 @@ flight. the status chrome must stay pixel-identical at the physical window bottom while document consumers move. A blanket rewrite of that helper moves both together and passes an "everything moved" assertion, so the - Stage 2 criterion asserts **both directions in one scenario**. + Stage 2 criterion asserts **both directions in one scenario**. The + census is 20 production sites (8 status-owned, 12 document-owned) + 1 + definition + 8 test sites = 29 matches; the framing carries the + per-site table. The three easiest to misclassify are document + completion `:6140`, minibuffer candidates `:7351`, and edge scrolling + `:8561` — each with its own visible symptom. - **Folding Stage 3 and this arc's Stage 2 both touch the semantic projection.** Whichever is framed second re-scouts the other's landed state. diff --git a/docs/bottom-panel-stage2-framing.md b/docs/bottom-panel-stage2-framing.md index 8cffec1..38d94ed 100644 --- a/docs/bottom-panel-stage2-framing.md +++ b/docs/bottom-panel-stage2-framing.md @@ -1,6 +1,6 @@ # Bottom panel Stage 2 — the GPU panel band (framing) -**Revision 3 — pre-implementation. Ground truth: canonical `main` @ +**Revision 4 — pre-implementation. Ground truth: canonical `main` @ `ccf29e3`, protocol v20, 2026-07-25.** Stage 1 (#155, merge `e745068`) gave pmacs window placement, window @@ -26,7 +26,35 @@ geometries), Q#BP16 (pointer transport), Q#BP17 (fold projection), and ## 0. Revision history -### 0.0 Round 2 (rev 2 → rev 3) — 1 blocking, 2 high, 1 medium, all closed +### 0.0 Round 3 (rev 3 → rev 4) — 1 blocking, 1 high, 1 medium, all closed + +- **R3-1 (blocker).** Rev 3's three-boundary model was right but its + call-site table was wrong in five places, and each error was a real + defect: `:6140` is **document completion placement** (classified + status-owned, which would let completion overlap the panel); + `:7195`/`:7212` are the two **status text bounds** (classified + document-owned); `:7351` clips **global minibuffer candidate glyphs** + to the dropdown's band anchor (classified document-owned, which would + clip them against the document boundary); `:8561` (**document edge + scrolling**) was missing entirely, leaving it tied to the old bottom; + and `:8077` was described as completion placement when it is **caret + clipping** (its class was right, its label wrong). §5.3's table is + rebuilt from the full census and every row is verified against the + source. + **Root cause worth recording:** rev 3's table was built from a + `grep | head -20` over 29 matches. The truncation is exactly why + `:8561` vanished. The census is now stated as 20 production sites + + 1 definition + 8 test sites = 29, so a future reader can check the + arithmetic instead of trusting the list. +- **R3-2 (high).** The three equations permitted negative coordinates + on a surface shorter than its chrome, where today's + `text_area_bottom` clamps with `.max(0.0)`. All three are now + explicitly clamped, preserving the current helper's behavior. +- **R3-3 (medium).** §5.1's "exact split" omitted `validate_cells`'s + `cell.attachment.is_some()` rejection. It is now classified — and + **shared**, with the reasoning pinned. + +### 0.1 Round 2 (rev 2 → rev 3) — 1 blocking, 2 high, 1 medium, all closed - **R2-1 (blocker).** Rev 2's "one document-bottom seam" conflated two boundaries that must **diverge** once a panel exists. Several sites it @@ -53,7 +81,7 @@ geometries), Q#BP16 (pointer transport), Q#BP17 (fold projection), and - Both §8 open items are decided (§5.3): `BASE_DIVIDER_HEIGHT = 4.0` at scale 1.0, and `TEXT_TOP` stays unscaled. -### 0.1 Round 1 (rev 1 → rev 2) — 2 blocking, 3 high, 3 revision points, all closed +### 0.2 Round 1 (rev 1 → rev 2) — 2 blocking, 3 high, 3 revision points, all closed - **R1-1 (blocker).** Rev 1 said all 23 census reads route through `primary_document_window`. That contradicts Q#BP14, which routes only @@ -326,6 +354,20 @@ currently interleaves both concerns. The exact split: text, `validate_selection`, and the `at_bottom == (scroll_offset == 0)` coupling. +**Attachment rejection is shared, not terminal-only.** `validate_cells` +also rejects `cell.attachment.is_some()` +(`pmacs-protocol/src/terminal.rs:305`), and its error text reads "A +cell carries a frontend attachment, which terminals never use" +(`:190-191`) — phrased as a terminal-specific fact, which is why rev 3 +missed it. **Stage 2 classifies it shared**: panels implement no +attachment rendering, so a `PanelFrame` carrying one describes a +surface the GPU would silently not draw. Shared rejection fails closed +on the producer side rather than shipping an invisible cell. The error +message is reworded away from "which terminals never use" to a +grid-neutral phrasing when it moves. If a later stage gives panels +attachment rendering, this rejection moves back to terminal-only as a +deliberate, reviewed change — not by default. + `PanelFrame` takes the shared half plus its own presence/epoch rules and does **not** inherit the 512 per-axis cap (Bet B5'), so a 4K small-font panel wider than 512 columns is legal while the shared area @@ -407,15 +449,20 @@ Rev 2 asked for a single document-bottom accessor. That was wrong: once a panel is installed, today's single value must **diverge into three**, because some of its consumers must not move at all. -``` -status_band_top = surface_height - status_band_height +All three clamp at zero, preserving today's `text_area_bottom` +`.max(0.0)` behavior — without the clamps a surface shorter than its +own chrome yields negative coordinates, and the "exact formula" stops +being exact precisely where it matters most: -geometry_capacity_bottom = status_band_top - reserved_divider_height +``` +status_band_top = max(0, surface_height - status_band_height) + +geometry_capacity_bottom = max(0, status_band_top - divider_height) // divider reserved even while absent -document_text_bottom = status_band_top - - (installed_panel_height + divider_height - if Present, else 0) +document_text_bottom = max(0, status_band_top + - installed_panel_height + - installed_divider_height) ``` `geometry_capacity_bottom` is what Q#BP15a's asymmetry already @@ -426,20 +473,67 @@ the document renderer does not actually lose those pixels until a **Today `text_area_bottom` (`pmacs-gpu/src/main.rs:8490`) is all three at once**, and its doc comment calls it "the single source for every -bottom-of-text computation" (Q#S3). Its ~19 call sites split into three -classes: +bottom-of-text computation" (Q#S3). -| Class | Boundary | Sites | -| --- | --- | --- | -| **Status-owned** — must stay pixel-identical at the window bottom | `status_band_top` | Status-band background rect `:5908`; band tops `:6003`, `:6027`, `:6140`; status text placement `:7134`, `:7922`; global minibuffer chrome | -| **Document-owned** — must move when a band is installed | `document_text_bottom` | Code/terminal clips `:7174`, `:7195`, `:7212`, `:7242`, `:7273`, `:7351`, `:7421`; caret visibility and code height `:4566`, `:6118`, `:6581`; document completion placement `:8077`; minimap `:8497`; visible-line estimate `:8501` | -| **Geometry declaration** | `geometry_capacity_bottom` | The Q#BP15a conversion only | +The census is **29 matches: 20 production call sites, 1 definition +(`:8490`), and 8 test sites** (`:12887`, `:12937`, `:12997`, `:13109`, +`:13793`, `:14013`, `:15306`, `:15386`). Every production site, +classified individually against the source: + +**Status-owned — must stay pixel-identical at the physical window +bottom, using `status_band_top`** (8 sites): + +| Site | What it is | +| --- | --- | +| `:5908` | Status-band background rect `y` | +| `:6003` | `mb_visible_window` — rows that fit **above the band** | +| `:6027` | `mb_dropdown_window` origin — dropdown grows up from the band | +| `:7134` | `status_top` for the right status group | +| `:7195` | `status_buffer` `TextBounds.top` — status text bound | +| `:7212` | `status_left_buffer` `TextBounds.top` — status text bound | +| `:7351` | Minibuffer **candidate glyph** clip, anchored to the dropdown's band origin | +| `:7922` | `status_top`, second site | + +The minibuffer is **global, bufferless chrome anchored to the status +band** (Q#BP14b keeps `MinibufferPrompt` global), so all four of its +sites — `:6003`, `:6027`, `:7351`, and its `status_left_buffer` bound +`:7212` — stay status-owned. Clipping candidate glyphs at +`document_text_bottom` would clip the dropdown against a boundary it +does not sit above. + +**Document-owned — must move when a band is installed, using +`document_text_bottom`** (12 sites): + +| Site | What it is | +| --- | --- | +| `:4566` | `terminal_cell_viewport` — drawable height for the cell grid | +| `:6118` | `completion_anchor_px` — anchor visibility bottom | +| `:6140` | `completion_dropdown_layout` — **document completion placement**; `band_top - (line_top + line_h)` is the space below the anchor line | +| `:6581` | `code_height` | +| `:7174` | Code text clip bottom | +| `:7242` | Math text clip bottom | +| `:7273` | Gutter clip bottom | +| `:7421` | Terminal clip bottom | +| `:8077` | `code_caret_rect_in_clip` — **caret clipping** | +| `:8497` | Minimap drawable height | +| `:8501` | Visible-line estimate | +| `:8561` | `edge_scroll_direction` — **document edge scrolling** | + +**Geometry declaration** uses `geometry_capacity_bottom`, and is the +Q#BP15a conversion only. **Sites that consume no bottom coordinate at all** and must not be touched: `:3175`, `:3185`, `:6601`, `:6607` size the status text buffers to `status_band_height` directly. Rev 2 listed them as seam consumers; they are not. +Three of these classifications are the ones a plausible implementation +gets wrong, and each has a visible symptom: document completion +(`:6140`) anchored to `status_band_top` **overlaps the panel**; +minibuffer candidates (`:7351`) clipped at `document_text_bottom` are +**cut off**; and edge scrolling (`:8561`) left on the old bottom +**auto-scrolls from inside the panel**. + Each call site is classified individually. A blanket rewrite of `text_area_bottom` to subtract the band would move the status chrome with the document and is the defect this section exists to prevent. @@ -577,18 +671,21 @@ Refinements 2B adds: frontends with identical metrics and different documents derive identical `total.cols`, and a probe returning `None` declares zero usable geometry rather than falling back to a document sample. -- **A2B-4 (contrast assertion).** Installing a panel moves **every** - document-owned consumer — code and terminal clips, caret visibility, - document completion placement, gutter/math clipping, minimap, - visible-line estimate, hit testing, edge scrolling — by exactly - `installed_panel_height + divider_height`, **while the status band - stays pixel-identical** at the physical window bottom (background - rect, band top, and status text placement all unchanged). Both halves - are asserted in one scenario: a uniformly wrong implementation that - moves the status band too would pass the "everything moved" half - alone. The geometry declaration separately reserves the divider while - the panel is `Absent`, and the document loses no pixels until a - `Present` is painted. +- **A2B-4 (contrast assertion).** Installing a panel moves **all twelve + document-owned consumers** of §5.3 by exactly + `installed_panel_height + divider_height`, **while all eight + status-owned sites stay pixel-identical** at the physical window + bottom. Both halves are asserted in one scenario: a uniformly wrong + implementation that moves the status band too passes the "everything + moved" half alone. Three rows carry their own named symptom because + they are the ones a plausible implementation misclassifies — + **document completion (`:6140`) must not overlap the band**, + **minibuffer candidates (`:7351`) must not be clipped by it**, and + **edge scrolling (`:8561`) must not trigger from inside it**. The + geometry declaration separately reserves the divider while the panel + is `Absent`, and the document loses no pixels until a `Present` is + painted. All three boundaries clamp at zero on a surface shorter than + its chrome. - **A2B-5.** `panel_capable` is true only for a v21+ negotiated authenticated semantic session; a v20 semantic session is never **placed** in a side window, not merely denied the events. @@ -612,7 +709,9 @@ slice under this framing so one-feature/one-branch/one-PR holds. **2A lands before 2B branches** — not stacked. - **Stage 2A** — classified census routing + per-window painter - extraction. Branch `bottom-panel-stage2a`. No protocol change. + extraction. Branch `bottom-panel-stage2a`. No protocol change. The + three-boundary GPU split is **2B**, not 2A: it is only observable + once a band can be installed. - **Stage 2B** — v21 protocol, daemon panel projection, GPU band, and the negotiated `panel_capable` flip. Branch `bottom-panel-stage2b`, cut from `main` after 2A merges. Repeats 2A's relevant census From 4fbd47f02553c363dfd96a031946111068881271 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 19:49:19 -0400 Subject: [PATCH 5/5] =?UTF-8?q?docs:=20refresh=20the=20handoff's=20Stage?= =?UTF-8?q?=202=20status=20(COHERENCE=20=C2=A725)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `docs/agent-handoff.md` §1 still said Stage 2 "needs its own re-framing". It is framed, so that line would be false on `main` the moment this branch merges. It now records the approved shape — protocol v21, two serial slices (2A census routing + painter extraction, then 2B wire/projection/band/ capability flip), parent acceptance 37-55 still authoritative — and carries the census classification rule itself, since that is the fact the ledger previously got wrong and the one a future reader is most likely to re-derive incorrectly. Co-Authored-By: Claude Opus 5 (1M context) --- docs/agent-handoff.md | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index d873063..b2d0aeb 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -171,10 +171,20 @@ 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) needs its own re-framing** before - implementation and takes the next available protocol version; the - framing's §1.3 census of 23 transitive active-context reads is its map. - Stage 3 is the adopter default flip. + - **Stage 2 (the GPU panel band) is FRAMED** — + `docs/bottom-panel-stage2-framing.md`, four review rounds, no open + items. It takes protocol **v21** and ships as two serial slices: + **2A** classified census routing + per-window painter extraction (no + wire change), then **2B** the wire, the daemon projection, the band, + and the negotiated `panel_capable` flip. Parent acceptance 37–55 + remains authoritative. Stage 3 is the adopter default flip. + - **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 + and surface-routed (#16–#19), and focus/session (#20) keep their own + authorities. Rerouting them breaks remote-op validation and + application, `DispatchIdle`, presence, focused + search/menu/completion routing, and terminal bell ownership. - **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