diff --git a/docs/bottom-panel-framing.md b/docs/bottom-panel-framing.md new file mode 100644 index 0000000..35abd22 --- /dev/null +++ b/docs/bottom-panel-framing.md @@ -0,0 +1,1818 @@ +# Bottom panel — framing (window placement + side windows) + +**Revision 4 — pre-implementation, DRAFT after review round 3 plus landed-state +audit. Ground truth: canonical `main` @ `ddaa80d` (documentation landing #152; +runtime @ `0dd16a5`, GPU initial target / #148 after folding Stage 2 / #149), +protocol v20, 2026-07-24. Amended by the pre-implementation dependency +verification in §0.6: the folding dependency is cleared, and one geometry +caller-census error is corrected.** + +Give pmacs a **bottom panel**: a buffer displayed in a fixed-height window +pinned to the bottom of the frame, resizable by dragging its divider, which +feature code targets **by policy** instead of by stealing the selected window. +This is what makes vterm feel like Emacs's vterm rather than `term` in a stolen +buffer, and it is the presentation substrate flycheck, compile, LSP panels, DAP +(`docs/dap-debugging-framing.md`, **parked awaiting this arc**), remotes, and +MCP surfaces all want. + +The feature is a bottom panel. The **missing concept** underneath it is Emacs's +`display-buffer` + window parameters: pmacs has a real window tree but no way to +say *where* a buffer should appear, and no window that is anything other than +proportionally sized. + +## 0. Revision history + +### 0.1 Round 1 (rev 1 → rev 2) — 8 blocking, 6 revision points, all closed + +Verdict: **panel-as-window (Q#BP1) confirmed; everything downstream of it in the +GPU and display-policy contracts rejected.** R1-1 → Q#BP14; R1-2 → Q#BP15; +R1-3 → Q#BP16; R1-4 → Q#BP2a; R1-5 → Q#BP11a; R1-6 → Q#BP13; R1-7 → Q#BP5a; +R1-8 → Q#BP7; rp-1 → Q#BP6; rp-2 → Q#BP10a; rp-3 → Q#BP4; rp-4 → Q#BP5b; +rp-5 → Q#BP2; rp-6 → acceptance. + +### 0.2 Round 2 (rev 2 → rev 3) — 7 blocking, 5 revision points + +Verdict: substantially stronger, still not approvable. **Bets B1, B6, and B7 +falsified as written.** Every anchor below was re-verified against `6ed4fe9` +before this revision; all seven findings reproduce in the code. + +| # | Finding | Closed in | +| --- | --- | --- | +| R2-1 | Q#BP14 doesn't fully separate projection from focus; census incomplete | Q#BP14 (rewritten) | +| R2-2 | Auto round-trip marking is buffer-global, not panel-local | Q#BP14a (new) | +| R2-3 | `PanelResize { size }` conflates three geometries; first-open cycle | Q#BP15a (new) | +| R2-4 | Q#BP4 erases `select = true` | Q#BP4 (rewritten) | +| R2-5 | Real visit paths lack a target-aware load; no compile/terminal entry points | Q#BP11b (new) | +| R2-6 | The jump ring becomes wrong once a panel is a separate window | Q#BP11c (new) | +| R2-7 | `PanelPointer` has no stale-frame identity | Q#BP16 | +| rp-1 | Minimum-height must be recursive; "never violate" too strong | Q#BP2 | +| rp-2 | Hidden-panel focus must be a durable transition | Q#BP2b (new) | +| rp-3 | B6 / acceptance 2 mathematically impossible as written | Bet B6 | +| rp-4 | Q#BP5b ancestor rule and `resize(win, …)` resolution | Q#BP5b | +| rp-5 | `WindowParams` gaps: remembered id, `no_other_window`, `dedicated` vs raw switch, fallback hygiene | Q#BP2c (new) | + +**Bet corrections:** + +- **B1 falsified as written** — Q#BP14a needs one panel-aware condition in + `dispatch_idle_for`. Narrowed to terminal controller / escape routing only. +- **B6 falsified as written** — opening an N-row panel *necessarily* changes + document rectangles. Restated over the document subtree's **structure**. +- **B7 falsified** — the active-window census is larger than + `primary_document_window`; four more producers (R2-1) plus the input-side + validator. Replaced by B7' over an explicit classified census. + +### 0.3 Round 3 + integration review (rev 3 → rev 4) — 22 blocking, +7 revision points, all closed + +Verdict: the three corrected bets were honestly restated, but **B7' was +falsified immediately** by four indirect active-buffer consumers. The remaining +findings were contract holes in geometry ownership, presentation identity, +placement precedence, real adopter lifecycle, jump-history ownership, and +interactive minima. The integration review extended that audit through +terminal/statusline helpers, render preparation, wire bounds, and the Stage 3 +default lifecycle rather than stopping at the originally reported eight. + +| # | Finding | Closed in | +| --- | --- | --- | +| R3-B1 | §1.3 missed four `active_buffer_id()` semantic producers; focus chrome could disappear or remain stale | §1.3, Q#BP14, Q#BP14b | +| R3-B2 | Hidden-panel reconciliation had no authoritative geometry or mutation seam | Q#BP2b, Q#BP4, Q#BP13 | +| R3-B3 | `buffer_id` cannot identify a close/hide/reopen presentation of the same buffer | Q#BP15, Q#BP16 | +| R3-B4 | Global reuse-first defeated requested side/exact-window placement; dedication was not universal | Q#BP3, Q#BP11b | +| R3-B5 | `listview` had no Stage 1 opt-in or panel-aware quit path; compile lifecycle was incomplete | Q#BP11b, Q#BP12 | +| R3-B6 | A global jump ring plus frontend-tagged entries lets one frontend consume another's history; origin buffer was not revalidated | Q#BP11c | +| R3-B7 | `FrontendCellGeometry` lacked an exact pixel→cell contract, an unknown initial state, and a session-kind gate | Q#BP15a, Q#BP9 | +| R3-B8 | `window.min-height` had no interactive recursion and sub-floor requested heights hid a satisfiable panel | Q#BP2, Q#BP5, Q#BP5b | +| R3-B9 | The viewport terminal-context guard and semantic terminal helpers still resolved the focused panel instead of the full-window document surface | §1.3, Q#BP14 | +| R3-B10 | `StatuslineEvaluationTarget::Semantic` captures `view.active` transitively; focusing a panel could clear the document statusline and the panel painter had no callback result | §1.3, Q#BP8, Q#BP14 | +| R3-B11 | `QuitAction` had no neutral non-side state and restored only a buffer/action, leaking replacement height or dedication into the prior presentation | Q#BP2, Q#BP2c, Q#BP11b | +| R3-B12 | Ordinary reuse admitted a side window, and side→ordinary fallback could carry panel-only parameters into a document window | Q#BP3 | +| R3-B13 | The non-side invariant fallback attempted to fabricate a document leaf instead of failing closed | Q#BP11a | +| R3-B14 | A horizontal boundary may sit below a subtree, so one divider can span several exposed leaf mode-line segments | Q#BP5, Q#BP5a, Q#BP5b | +| R3-B15 | Falling back from an invalid side-origin jump could duplicate a hidden panel buffer into the document window | Q#BP11c | +| R3-B16 | A frame for old font/scale geometry could arrive after a new declaration; presentation epoch alone cannot detect that race | Q#BP15, Q#BP15a, Q#BP16 | +| R3-B17 | `display_file` resolved a default target before dedup/eligibility, so a dedicated origin could force load-before-failure | Q#BP11b | +| R3-B18 | A creation-only `origin_document` becomes stale after the user enters the panel from another document split | Q#BP2c, Q#BP11a | +| R3-B19 | A semantic panel terminal cannot use full-window `TerminalResize`, and attach `term_sizes` is the wrong 24×80 source | Q#BP7, Q#BP15a | +| R3-B20 | Active-window cursor auto-scroll lives before the per-window paint loop; extracting only the loop leaves a focused panel caret off-screen | Q#BP8, Bet B2' | +| R3-B21 | Terminal's 512-column PTY bound is not a generic panel-grid bound; wide GPU frames can exceed it | Q#BP15, Q#BP15a, Bet B5' | +| R3-B22 | Stage 3 flipped defaults without an explicit current-window opt-out or its own acceptance contract | Q#BP11b, Q#BP12, acceptance | +| R3-rp1 | Do not publish an intentionally inert `no_other_window` API | Q#BP2c, §6 | +| R3-rp2 | Keep raw `switch_buffer` as the dedication escape hatch, but make display policy honor dedication everywhere | Q#BP2c, Q#BP3 | +| R3-rp3 | `origin_document` is implementation-owned, not caller-settable | Q#BP2c, Q#BP11 | +| R3-rp4 | Terminal bell state is per session, but choosing which session to drain is focus-facing | §1.3, Q#BP14b | +| R3-rp5 | The exact base advanced to `b168dcad`; only handoff/ledger documentation changed after `6ed4fe9` | title, §1, §7 | +| R3-rp6 | An unspecified `window.toggle-panel` and focus traversal into a hidden panel are not shippable contracts | Q#BP6, Q#BP11, §6 | +| R3-rp7 | Open PR #148 overlaps the attach/protocol seams this arc must edit | §7 | + +**Bet corrections:** B7' was too syntactic. Searching only literal +`active_window_for` / `active_window` calls missed helpers such as +`active_buffer_id()` that resolve through the same focused window. It is +replaced by a transitive contract. The integration pass then falsified B7'' as +well: `semantic_terminal_key` reaches `view.active` from `src/editor.rs`, and +`StatuslineEvaluationTarget::Semantic` captures it directly in +`src/statusline.rs`. B7''' is now over **all transitive active-context reads +reached by the daemon/semantic projection**, not merely reads spelled in those +two files, plus the explicit surface-routing matrix in Q#BP14b. B2 is narrowed +to B2': the concrete painters are origin-agnostic, but the per-window active +auto-scroll preparation at `src/editor.rs:2883-2935` must be extracted with +them. B5 is narrowed to B5': cell/topology/aggregate wire validation is shared, +but terminal-specific per-axis PTY limits do not apply to a generic panel grid. + +### 0.4 What the post-folding re-scout established (2026-07-24) + +Folding Stage 2 merged as **#149**. Carried forward from rev 2, still true: + +1. **The capability seam exists.** `FrontendView.fold_projection` + (`src/window.rs:348`) is a non-`Default` bool passed explicitly into + `build_fresh_frontend_view` (`src/daemon.rs:2935`) from the attach + transaction (`src/daemon.rs:1769`), where both + `negotiated_capabilities.semantic_render` and `negotiated_protocol_version` + are in hand (`src/presence.rs:74-84`). Q#BP13 copies it. +2. **`DispatchIdle` is the existing optimistic-apply gate** — per frontend, + keyed on `active_window_for(fid)` (`src/editor.rs:753-769` → + `src/daemon.rs:1223-1248` → `pmacs-gpu/src/main.rs:4135`). Rev 2 concluded this + needed no code; round 2 corrected that (Q#BP14a). +3. **The panel band breaks a folding invariant** — `paint_frame`'s per-window + map is built ungated (`src/editor.rs:2991`) on the premise that a semantic + session never enters it. Q#BP17. +4. **No protocol version is reserved.** #148 has now landed as protocol v20; + its final `InstanceMessage` variant is `InitialTargetResult`, while + `TerminalPointer` remains the final `FrontendEvent` variant. Q#BP9. + +`Layout::compute` / `split_node` / `remove_leaf` / `collapse_single_child_splits` +took no folding edits, so Q#BP2/Q#BP2a stand. `Viewport<'a>` carries +`folds: Option<&'a VisibleLineMap>` (`src/view.rs:131-155`) and stays `Copy`. + +### 0.5 Landed #148 + final integration audit — 9 blocking findings, +1 revision point, all closed + +The runtime moved to `0dd16a5` during this review, then canonical `main` +advanced to `ddaa80d` through #152's handoff/ledger documentation only. The new +initial-target transaction directly overlaps attach, target loading, semantic +snapshot publication, and the protocol append point, so the document was +re-scouted against the landed runtime rather than retaining an open-PR +sequencing note. +**B7''' is falsified**: #148 added #21, while the final transitive scan found +the older attach inheritance (#22) and input-side source-window use (#23). +B7'''' is the corrected census bet after integrating them. + +| # | Finding | Closed in | +| --- | --- | --- | +| R4-B1 | A hidden side leaf had durable focus state but no defined effective placement; the requested fixed extent could still steal rows or become flexible | Q#BP2, Q#BP2b, acceptance 7 | +| R4-B2 | #148's semantic replica-publication filter asks whether a peer displays a buffer through its focused window; panel focus can cause both a missed document snapshot and a panel-driven mirror swap | §1.3, Q#BP14, acceptance 43 | +| R4-B3 | Fresh no-target attaches inherit `LOCAL`'s focused buffer; attaching while the TUI focuses a panel would make that panel the new frontend's document | §1.3, Q#BP13, Q#BP14, acceptance 51 | +| R4-B4 | #148's private target loader reselects `view.active` after hooks; once hooks can create/select a panel, bootstrap could overwrite it instead of reasserting the requested document | Q#BP11b, acceptance 55 | +| R4-B5 | Omitted `height`/`dedicated` semantics were undefined across same-presentation redisplay, replacement, and creation | Q#BP3, acceptance 13 | +| R4-B6 | Recursive `QuitAction::Restore` history grew without bound under repeated panel replacement | Q#BP2c, acceptance 20 | +| R4-B7 | Wire/client documentation still defines semantic `CursorByte`/mirror state as the focused “active buffer”; after panel focus that name means the primary document surface, not input focus | Q#BP14, acceptance 42 | +| R4-B8 | A panel-owned `SearchPrompt` names the panel buffer, but the GPU currently displays prompts only when their buffer matches the document mirror; frame/chrome ordering and validation were undefined | Q#BP14b, acceptance 45 | +| R4-B9 | New geometry/drag/move events omitted the GPU outbox's bounded tail-coalescing contract, so a stalled daemon could turn normal resize/pointer traffic into lossless-queue overflow | Q#BP15a, Q#BP16, acceptance 47–48 | +| R4-rp1 | The focus census also omitted remote-CRDT source-window cursor/provenance application even though its classification remains Focus | §1.3, Q#BP14a | + +### 0.6 Pre-implementation dependency verification (2026-07-24) — folding +cleared, 1 correction + +Run against canonical `main` @ `ddaa80d` before branching Stage 1. + +**The folding dependency is cleared.** #149 (`6ed4fe9`) and its landed-doc +refresh #150 (`b168dca`) are both ancestors of `ddaa80d`; no PR is open; the +retained `folding` and `folding-tui` branches carry zero commits beyond +`githubsucks/main`; and Stage 3 has neither a branch nor a framing +(`docs/active-work.md`). `cargo test --test folding_stage2_acceptance` is +48/48 green on this base. Every anchor this document borrows from the arc +reproduces: `fold_projection` (`src/window.rs:348`, non-`Default`), its attach +install (`src/daemon.rs:1769`), `build_fresh_frontend_view` +(`src/daemon.rs:2935`) and its `LOCAL`-active inheritance (`:2949-2958`), the +ungated per-window map in `paint_frame` (`src/editor.rs:2991`), the +active-frontend gate behind `fold_map_for_window` +(`src/editor_core.rs:566` → `fold_projection_active` `:549-551`), and the +`Copy` `Viewport<'a>` with `folds` (`src/view.rs:130`, `:155`). Q#BP17's stale +comment is at `src/window.rs:339-340`. Folding's entire `src/window.rs` diff was +one 22-line hunk at `:324`, and nothing has touched that file since `6ed4fe9`, +so `compute` / `compute_node` / `split_node` / `remove_leaf` / +`collapse_single_child_splits` remain pre-folding code and Q#BP2/Q#BP2a stand +unchanged. The only surviving coupling is forward and non-blocking: Stage 1 has +no folding surface at all, and if folding Stage 3 flips `fold_projection` true +for semantic sessions before this arc's Stage 2 lands, Q#BP17's "pass `None`" +becomes "pass that window's map". + +| # | Finding | Closed in | +| --- | --- | --- | +| R5-B1 | `Layout::compute` has **two** production callers, not one; the second (`src/overlay_paint.rs:112`) derives its own text-area `Rect` and never consults `window_placements`, so the Q#BP2 signature change would leave peer-cursor overlays on unfixed geometry | §1.1, Q#BP2, acceptance 1 | + +## 1. Ground truth (re-scouted 2026-07-24 against canonical `main` @ +`ddaa80d`; runtime @ `0dd16a5`) + +### 1.1 What already exists + +- **A real window tree, per frontend.** `LayoutNode::{Leaf, Split{orientation, + weights, children}}` (`src/window.rs:283`); `FrontendView { layout, active, + fold_projection }` (`:320`); all windows in one flat `core.windows`. +- **Geometry is purely proportional.** `compute_node` (`src/window.rs:435`) + divides by weight, last child takes the remainder; **zero extents are an + explicitly permitted outcome on a tiny frame** (`src/window.rs:362-365`). + `compute` (`src/window.rs:367`) has **two** production callers (R5-B1): + `window_placements` (`src/editor.rs:2359`, calling at `:2372`), and the + peer-presence overlay pass (`src/overlay_paint.rs:112`), which builds its own + text-area `Rect` from `core.active_layout()` and never routes through + `window_placements`. The remaining `compute` calls (`src/editor.rs:6363`, + `:6783`, `:6788`) are inside the `cfg(test)` module opening at `:4098`. + `compute` is the only producer of window rectangles; every other layout + consumer (`statusline.rs`, `desktop.rs`, `editor_core.rs`, `lua_bindings`) + reaches the tree through `iter_ids` / focus traversal and needs no fixed-extent + argument. +- **`WindowPlacement { outer, content }`**, `content = outer` minus the mode + line (`src/editor.rs:2352`); frame area is `rows - 1`. +- **The mode-line row is reserved in the mouse path** — `window_at_cell` + (`src/editor.rs:2391`), early return at `local_row >= inner_rows` + ("Mode-line click: reserved.", `:1865`); `MouseClickState` (`:204`). +- **`paint_frame` is a per-window loop** (`src/editor.rs:2937`) through an + origin-agnostic `Viewport<'a>` (`:3008`); one fold map per rendered window + (`:2991`). +- **The terminal controller is keyed on the window** (`src/terminal/view.rs:19`, + `:86`, `:105`); `active_terminal_key` reads `view.active` + (`src/editor.rs:989`). +- **Terminal scroll is anchor-based**, `selection_froze_top` + (`src/terminal/view.rs:360`, `:422`), `view_geometry` (`:625`), + `record_view_size` (`:273`), controller-only PTY resize + (`src/editor.rs:1234-1249`). +- **Attach-time capability plumbing is proven** (`src/presence.rs:74-84`, + `peer_declared_terminal_support` `src/daemon.rs:888`, folding's install at + `src/daemon.rs:1769`). +- **The GPU has a bottom band and renders a foreign cell grid** + (`pmacs-gpu/src/main.rs:395-402`, `:1574-1609`, `:6670-6683`; + `TerminalFrame` + `pmacs-protocol/src/terminal.rs:103`, planner `pmacs-gpu/src/terminal.rs`). + +### 1.2 What does not exist + +- **No placement policy** — `pmacs.window` (`src/lua_bindings/mod.rs:12167`) + acts only on the active window; no `display`, `pop_to_buffer`, `quit_window`. +- **No window parameters** on `Window` (`src/window.rs:158`). +- **No divider drag, no keyboard resize**, in either frontend. +- **No `CursorIcon` / `set_cursor` in `pmacs-gpu/`.** +- **No `WindowId` in `pmacs-protocol/`.** +- **No `MIN_WINDOW_*` constant, no `window.min-height`.** +- **No general target-aware load.** #148 added the private attach-only + `open_initial_target` transaction (`src/daemon.rs:1625-1677`) and the + side-effect-free `EditorCore::get_or_load_buffer` seam + (`src/editor_core.rs:660-684`), but the former still switches + `view.active` before and after hooks. The public + `pmacs.buffer.find_or_open` likewise switches the **active** window in both + branches before firing `buffer.after-switch` / `after-load` + (`src/lua_bindings/mod.rs:3089`, `:3108`, `:3113`). Neither accepts an exact + destination window. +- **No way to open a terminal off-active.** `pmacs.terminal.open` hardwires + `switch_active_buffer_for(frontend_id, …)` into that frontend's active window + (`src/lua_bindings/mod.rs:8500`), and rolls the session back if it fails. + Compile creates its buffer then `switch_buffer`s (`compile.lua:263`, `:808`). + +### 1.3 The active-context census (R2-1, R2-2, R3-B1) — every consumer, +transitively classified + +Round 2 found the literal active-window consumers. Round 3 found the remaining +trap: `active_buffer_id()` is itself an active-window read, so a census of only +the spelling `active_window*` is not exhaustive. The contract is therefore over +**every transitive read of the focused window/buffer** in the daemon and +semantic producer, including helper calls. + +The bounded production scope is: reads that choose an attached frontend's +semantic document messages/alignment, focus chrome, snapshot routing, attach +inheritance, presence/bell surface, or optimistic-input acceptance/application +in `src/daemon.rs`, `src/semantic_render.rs`, and `src/statusline.rs`, plus +their named `src/editor.rs` helpers. Ordinary grid per-window painting and +normal key/mouse command semantics are excluded—they already operate on the +real window that owns them—as are test-only reads. + +| # | Consumer | Site | Class | +| --- | --- | --- | --- | +| 1 | Semantic buffer-follow + `BufferSnapshot` re-send | `src/daemon.rs:1133-1149` | **Projection** | +| 2 | Lazy CRDT upgrade + replica broadcast | `src/daemon.rs:1096`, `:2319-2343` | **Projection** | +| 3 | `CursorByte` | `src/daemon.rs:1418-1428` | **Projection** | +| 4 | `LineNumbers` mode | `src/semantic_render.rs:1350` | **Projection** | +| 5 | Selection decorations | `src/semantic_render.rs:1706` | **Projection** | +| 6 | Terminal-frame suppression of the document pass | `src/semantic_render.rs:610` | **Projection** | +| 7 | `Viewport` alignment | `src/daemon.rs:2020` → `align_semantic_window_to_buffer` `:2900` | **Projection** (must not move focus) | +| 8 | Document `Pointer` | `src/daemon.rs:2085` → same helper | **Projection + focus** | +| 9 | `Viewport` terminal-context gate | `src/daemon.rs:2002-2009` | **Projection** — a terminal panel must not suppress a document viewport | +| 10 | Full-window semantic terminal declaration/snapshot/sync | `src/daemon.rs:2026-2046` → `semantic_terminal_key`, `src/editor.rs:1157` | **Projection** — these describe the primary document surface, never the panel band | +| 11 | Full-window `TerminalPointer` | `src/daemon.rs:2048-2067` → `dispatch_semantic_terminal_pointer`, `src/editor.rs:1276` | **Projection + focus** — a non-hover gesture on the document terminal takes document focus | +| 12 | Semantic statusline target capture | `src/semantic_render.rs:628` → `capture_target_contexts`, `src/statusline.rs:634` | **Projection + panel projection** — document segments use the primary document; panel segments paint in its mode line | +| 13 | Remote CRDT-op validation | `src/daemon.rs:2531-2537` | **Focus** | +| 14 | `dispatch_idle_for` | `src/editor.rs:753-769` | **Focus** | +| 15 | Presence snapshot (peer cursor broadcast) | `build_presence_snapshot`, `src/daemon.rs:2988-3000` | **Focus** — it answers "where is this user working", which *is* the panel when the panel is focused | +| 16 | `SearchPrompt` active-buffer gate | `src/semantic_render.rs:1106` | **Focus chrome** — the prompt follows the modal session; match washes paint on its owning window | +| 17 | `MenuPrompt` active-buffer gate | `src/semantic_render.rs:1164` | **Surface-routed** — a document menu is semantic chrome; a panel menu is painted in `PanelFrame`; the other surface receives a clear | +| 18 | `MinibufferPrompt` active-buffer gate | `src/semantic_render.rs:1220` | **Focus chrome, global** — bufferless and emitted independently of the document viewport | +| 19 | `CompletionPopup` active-buffer/window gate | `src/semantic_render.rs:1027`, `:1030` | **Surface-routed** — a document popup is semantic chrome; a panel popup is a window overlay in `PanelFrame`; the other surface receives a clear | +| 20 | Terminal bell drain | `take_pending_terminal_bell`, `src/daemon.rs:1565-1600` | **Focus/session** — the counter is per session, but the active window chooses which session may drain | +| 21 | Semantic recipient filter for lazy/initial-target `BufferSnapshot` publication | `publish_buffer_snapshot_to_replicas`, `src/daemon.rs:2441-2475` | **Projection** — “displays this buffer” means the peer's primary document surface | +| 22 | Buffer inherited by a fresh no-target frontend view | `build_fresh_frontend_view`, `src/daemon.rs:2949-2958` | **Projection** — inherit `LOCAL`'s primary document, never its focused panel | +| 23 | Remote CRDT-op source-window cursor/provenance application | `handle_remote_crdt_op`, `src/daemon.rs:2735-2797` | **Focus/input** — the validated op applies to the source's actually focused window | + +**Why #2 is the sharpest.** Focusing a *fresh generated* panel buffer triggers +the lazy CRDT upgrade, which **broadcasts a `BufferSnapshot` to every replica** +(`src/daemon.rs:1096-1107`) and records it in `last_active_buffer_sent`. That +swaps the GPU's mirror to the panel buffer — directly contradicting rev 2's +acceptance 34. It is not reachable from rev 2's three-coupling model at all. + +**Why #9–#12 are separate from ordinary document projection.** The viewport +gate, terminal declaration/key, terminal pointer, and statusline target all +reach focused-window state outside the main `render_frame` buffer producers. +If left unchanged, a focused terminal panel rejects the still-visible document +viewport, a full-window document terminal cannot repaint or receive a click, +and `DeclaredBufferMismatch` clears the document's statusline. The document +terminal events and document statusline must resolve the primary document +window, while the panel gets its own `PanelPointer` and painted mode line. + +**Why #13 and #23 forbid an opt-out.** Remote-op validation requires the op's +`buffer_id` to equal the **source's active window buffer** +(`src/daemon.rs:2531-2537`), and the accepted-op path applies cursor/provenance +to that same source window (`:2735-2797`). If a panel is focused while the GPU +still optimistically edits its document mirror, every resulting op is rejected +— silently diverging the mirror. Optimistic apply and daemon input must agree +on *one* window. + +**Why #16–#19 cannot inherit the document viewport.** `render_frame` invokes +all four producers with `vp.buffer_id` (`src/semantic_render.rs:800-807`), but +their current guards compare it with `core.active_buffer_id()`. With a panel +focused, a panel-opened `M-x` emits no `MinibufferPrompt`; search/menu chrome +can disappear; and a document completion popup can remain stuck because the +producer returns before emitting its authoritative close. Q#BP14b splits +per-window overlays from global/native semantic chrome and makes both open and +clear paths explicit. + +**Why #21 and #22 are projection even though they sit outside +`render_frame`.** #148 publishes a target/upgraded snapshot to an existing +semantic peer only when that peer “displays” the buffer. Testing the focused +panel would miss a buffer visible in the document or replace the GPU mirror +because only the panel showed it. A fresh no-target attach has the same +surface question when it clones `LOCAL`: panel focus must not turn panel +content into the new frontend's full document. Both therefore use +`primary_document_window`, not focus. + +## 2. What ships (staged) + +- **Stage 1 — window placement + TUI side windows. No wire change** (inherits + the protocol version on its eventual base). +- **Stage 2 — the GPU panel band. Next available protocol version.** Own + re-framing before implementation. +- **Stage 3 — default placement flip**, after Stage 2 (Q#BP12). + +## 3. Decisions + +### Q#BP1 — A panel is a WINDOW, not a new kind of slot *(confirmed round 1)* + +Side windows are ordinary leaves in `Layout`, carrying parameters. +`TerminalController` is keyed `(frontend_id, window_id)` and +`active_terminal_key` reads `view.active` (`src/editor.rs:989`), so child-input +routing, the fixed `C-c` escape, atomic controller replacement, and +release-on-blur need no new machinery — **this is the whole of B1 now** (round 2 +correctly removed the input-gating half; see Q#BP14a). A non-window slot would +need a second copy of the controller model plus per-window overlays, gutter, +statusline, mouse routing, selection, and desktop handling. + +### Q#BP2 — Window parameters; fixed extents; the recursive minimum (rp-1, rp-5) + +```rust +pub struct WindowParams { + pub side: Option, // immutable after placement (Q#BP2a) + pub fixed_rows: Option, // outer rows, incl. the mode line + pub dedicated: bool, + quit_action: Option, // implementation-owned; None off-side + origin_document: Option, // implementation-owned; read-only to Lua +} + +pub enum QuitAction { + Delete, + Restore { + buffer_id: BufferId, + fixed_rows: u32, + dedicated: bool, + cursor: Position, + view_top: usize, + goal_col: Option, + selection: Option, + then: Box, + }, +} +``` + +`Layout::compute(area)` → `Layout::compute(area, fixed: &HashMap)`. **Both** production callers supply the map (R5-B1): `window_placements` +(`src/editor.rs:2372`) and the peer-presence overlay pass +(`src/overlay_paint.rs:112`). The second is easy to miss because it derives its +own text-area `Rect` from `core.active_layout()` instead of reusing +`window_placements`; leaving it on unfixed geometry would paint every peer +cursor at the row it would occupy with no panel open. Since both callers need +the same `HashMap`, the fixed map is derived by one shared +helper over the frontend's side windows rather than assembled at each call +site. Two-pass inside a split: subtract fixed children, then divide the +remainder by weight among flexible children (preserving +last-flexible-takes-the-remainder). + +**The minimum is recursive (rp-1).** Rev 2's "leave the document subtree two +rows" is wrong: two rows at the root does not give each nested leaf two rows. +Define, over row extent: + +``` +subtree_min_rows(Leaf) = MIN_WINDOW_OUTER_ROWS // 2 +subtree_min_rows(Split{Horizontal, kids}) = Σ subtree_min_rows(kid) +subtree_min_rows(Split{Vertical, kids}) = max subtree_min_rows(kid) +``` + +(Horizontal splits stack rows, so minima add; vertical splits share rows, so the +tallest child governs.) + +**And the promise is bounded (rp-1).** The layout **already permits zero +extents** on an intrinsically tiny frame (`src/window.rs:360`) and renderers +already skip empty rects — rev 2's "the layout can never violate the floor" was +too strong. The honest contract: **the panel allocator never makes an otherwise +satisfiable document tree unsatisfiable.** Formally, the panel takes +`min(fixed_rows, area.rows.saturating_sub(subtree_min_rows(document_root)))`, +and if that is below `MIN_WINDOW_OUTER_ROWS` the panel is **hidden** (Q#BP2b). +What the frame does to a document tree that could not fit anyway is unchanged +behavior. + +`MIN_WINDOW_OUTER_ROWS = 2` (one text row + one mode line, since `content = +outer - 1`) is a structural floor. Every programmatic source of `fixed_rows` +(`height`, `window.panel-height`, and `set_params`) clamps a nonzero request to +that floor; a request of `0` is rejected rather than being an invisible +"open". A frame shrinking under the floor hides the panel, but a caller asking +for one row on a large frame gets a two-row panel. Side creation resolves an +omitted height through `window.panel-height`, so a live side leaf always has +`fixed_rows = Some(requested_rows)`; `None` remains the ordinary-window value. + +**Hidden has an exact effective geometry.** The requested `fixed_rows` remains +stored, but `window_placements`/`Layout::compute` receives the reconciled +effective state: while `panel_hidden`, the side leaf receives an empty rect and +the prior document root receives the full frame area (minus the one global +status row), as if the wrapper's side child consumed zero rows. The side leaf, +wrapper, weights, `WindowId`, and requested extent remain intact. It must not +fall through as a flexible child, and the requested fixed extent must not +continue stealing rows while hidden. + +`window.min-height` is a **user preference clamped into +`[MIN_WINDOW_OUTER_ROWS, …]`** that applies only to *interactive* resize (drag, +keyboard, and GPU `PanelResizeRows`). Define a second recursion with the same +sum/max shape: + +``` +interactive_min_rows(Leaf) = window.min-height +interactive_min_rows(Split{Horizontal, kids}) = Σ interactive_min_rows(kid) +interactive_min_rows(Split{Vertical, kids}) = max interactive_min_rows(kid) +``` + +Each leaf resolves the setting against that window's current `buffer_id` +(buffer-local override → global → default), and one gesture snapshots the +result before changing geometry. Side creation similarly resolves +`window.panel-height` against the buffer being displayed. + +Interactive boundary motion preserves the preferred minimum on **both** sides +when the current frame can satisfy it; if the frame is already smaller, the +motion may not make either side worse. The ordinary layout pass and +frame-resize reconciliation consult only `subtree_min_rows`, so changing a +preference never invalidates an existing layout. + +### Q#BP2a — Side-window topology (R1-4) + +- **At most one bottom-side leaf per `FrontendView`.** +- Installed as the **final child of a root-level horizontal split wrapping the + entire prior root**: `root := Split { Horizontal, weights: [1, 1], + children: [, Leaf(panel)] }`. `fixed_rows` makes the panel's + weight inert; the prior root takes the flexible remainder. +- **Closing collapses the wrapper** via `collapse_single_child_splits` + (`src/window.rs:537`) once `remove_leaf` drops the panel — no new tree code. +- **`fixed_rows` is interpreted only on that root-level side child**; elsewhere + it is inert (Q#BP2's `fixed` map is built from side windows only). +- **`side` is immutable after placement.** `set_params` rejects adding, + changing, or clearing it. Transactional rehoming is deferred by name. + +### Q#BP2b — Hiding is a durable state transition, not a per-frame effect (rp-2) + +Rev 2 said a hidden panel "hands focus to a document window for that frame". +That is a render-time dodge: keys would still route to an invisible window, and +the terminal resize path merely returns on zero content **without releasing the +controller** (`src/editor.rs:1118`). Geometry currently lives outside +`EditorCore` — the local loop reads `frontend.size()`, while the daemon owns +`term_sizes` — so "wherever layout is recomputed" is not a mutation seam. + +Each `FrontendView` therefore gains +`frame_geometry: Option` and `panel_hidden: bool`, where +`DeclaredFrameGeometry = { geometry_epoch: u64, total: CellSize }`. `None` +means **unknown**, not 24×80. Grid/LOCAL views cache their real attach/resize +size with an internal epoch; a semantic view stays `None` until its first +authenticated `FrontendCellGeometry` in Stage 2 (Q#BP15a). + +`EditorState::reconcile_panel_layout(frontend_id)` is the single idempotent +mutable transaction. It runs after attach/resize, display/split/close, +`fixed_rows`/setting changes, and any Lua hook or callback transaction that can +mutate the layout (including statusline evaluation); it also runs defensively +before final-focus resolution, input dispatch, terminal sync, and paint. Thus +two events drained in one burst cannot route the second event to a panel the +first event made invisible, and a render callback cannot leave stale panel +geometry for the painter. The transaction: + +1. If there is no live side window, set `panel_hidden = false`; Stage 2 records + and emits `Absent` as needed, clears presentation input authority, and + returns. `panel_hidden` never describes a panel that no longer exists. +2. Compute the panel's allocation per Q#BP2. Unknown geometry or + `frame_geometry.total.cols == 0` is not presentable and follows the hidden + arm; no zero-width `Present` frame is legal. Install Q#BP2's effective + empty-side/full-document placement for the hidden state rather than feeding + the stored request to ordinary fixed/flexible allocation. +3. If it is below the floor and the panel is currently visible → mark + `panel_hidden = true`; if `view.active` is the panel, **set `view.active` to + the non-side target** (Q#BP11a); **release the terminal controller** for + that view key (`release_controller`, the existing call at + `src/editor.rs:1113`). +4. If it becomes satisfiable again → `panel_hidden = false`, restore its + allocation from the still-stored requested `fixed_rows`. **Focus is not + restored** — the user moved on; `C-x o` returns. +5. Stage 2: a hide or unhide emits `PanelFrame::Absent` / a fresh `Present` + authoritatively (Q#BP15). + +`panel_hidden` is cached derived layout state on the `FrontendView`, not a +`WindowParams` field. It is recomputed from authoritative geometry and must +never be persisted or set by Lua. + +### Q#BP2c — Parameter semantics the API must pin (rp-5) + +- **`origin_document: Option`** is the remembered document window + Q#BP11a needs; rev 2 described it but omitted it from the struct. Recorded at + panel creation, then refreshed on every focus transition from a non-side + window into the panel (keyboard, pointer, or selecting display). + Panel→panel redisplay and passive display do not overwrite it. + It is **revalidated on every use** (live, in this frontend's layout, + non-side) and cleared when it fails. It is implementation-owned: + `params(win)` may report it for diagnostics, but `set_params` rejects it. +- **`no_other_window` does not ship in v1.** A public parameter that is stored + and deliberately ignored is a false contract. The whole parameter and its + traversal semantics are deferred. If added later, traversal filters it only + as a **destination**; a currently focused no-other window can always leave, + so the caller cannot strand focus. +- **`dedicated` binds `display_buffer` only.** Raw `pmacs.window.switch_buffer` + and `switch_active_buffer_for` **ignore it**: they are the deliberate + low-level escape hatch, and every existing caller predates this arc. + `display_buffer` is the policy layer and checks dedication on **every** + candidate, not only the side slot (Q#BP3); making the primitive enforce + policy would change existing behavior silently. +- **Quit restoration preserves replacement history.** `QuitAction` is + present only on a side window. Creating the side installs `Some(Delete)`; + ordinary windows and every fallback carry `None`. Replacing a side + presentation captures its buffer, requested height, dedication, cursor, + viewport/goal/selection state, and prior action in `Restore`. Restoring + rebuilds `TextView`, clamps the saved positions against the buffer's current + contents, reinstalls `then`, and fires the normal switch hook so overlays + reattach. Derived `last_visible_rows` and trait-object overlays are never + snapshotted. Thus C→B→A→delete restores the actual presentation rather than + forgetting A or leaking C's height/dedication into it. A killed restore + buffer still fails closed to `Delete` (Q#BP10a), dropping the unusable chain. + `MAX_PANEL_QUIT_DEPTH = 64` bounds the recursive history: before wrapping an + existing action, count iteratively; if the new depth would exceed the cap, + truncate the oldest retained `Restore` by replacing its `then` with + `Delete`. The newest 64 presentations therefore remain LIFO-restorable and + the following quit closes the slot. Construction, traversal, and truncation + never recurse past the same bound. +- **The two bookkeeping fields are read-only.** `params(win)` may expose + `origin_document` and a diagnostic description of `quit_action`; + `set_params` rejects both. Lua cannot forge a window id, buffer restore + chain, or stale cursor state. `window.quit` on a window with + `quit_action = None` returns a pointed error without closing or switching + anything; non-side adopter fallbacks call their existing restore path + instead. +- **Capability fallback discards every side-specific parameter.** When + `!panel_capable` (Q#BP13), `display_buffer` drops `side`, `fixed_rows`, + `dedicated`, `quit_action`, and `origin_document`, and displays into the + non-side target as an ordinary buffer switch. A fallback must never dedicate, + pin, or otherwise poison the primary document window. + +### Q#BP3 — `display_buffer`: the placement policy + +Placement affinity precedes generic reuse; otherwise a persistent compilation +buffer already visible in a document window makes `{side = "bottom"}` silently +ignore its requested placement. `action.window` and `action.side` are mutually +exclusive; supplying both is an error. `height` requires a side request or an +exact target that is already the side window. Stage 1 accepts only +`Side::Bottom`; every other side value is a pointed unsupported error, not an +ordinary fallback. + +1. **Exact target (`action.window`).** Validate that it is live and belongs to + this frontend. Use that exact window or error; generic reuse may not + substitute another. A target dedicated to a different buffer errors. +2. **Side target (`action.side`).** + 1. Reuse a window on the requested side already showing `buffer_id`. + 2. Otherwise use that side slot if absent or not dedicated to another + buffer, creating it per Q#BP2a when absent. + 3. If the one side slot is dedicated to another buffer, never create a + second one: fall back to the ordinary non-side policy below **after + discarding `side`, `height`, `dedicated`, and quit bookkeeping**. Only an + explicitly supplied `select` survives. A failed placement request may + not pin or dedicate a document window. + A non-side window already showing the buffer **does not preempt** a usable + requested side slot; displaying the same buffer in two windows is legal and + avoids the deferred rehoming problem. +3. **Ordinary target (no usable exact/side target).** + 1. Reuse a visible **non-side** window on this frontend already showing + `buffer_id`. An ordinary display never selects the panel by coincidence. + 2. Otherwise use the first candidate from Q#BP11a that is not dedicated to a + different buffer. Continue in `iter_ids()` order when the preferred + document target is dedicated. + 3. If no eligible non-side window exists, return a pointed error; do not + overwrite a dedicated window or create an unrequested split. + +After target resolution, an omitted `action.select` defaults to **false** for +an actual side target and **true** for an ordinary target. An explicit +`select` survives fallback unchanged. Placement and reuse are strictly per +frontend. + +`height` and `dedicated` are option-valued at the policy boundary; omission is +not silently equivalent to an explicit zero/false: + +- creating the side slot uses `window.panel-height` when `height` is omitted + and `dedicated = false` when dedication is omitted; +- redisplaying the **same continuously presented buffer** preserves its + current requested height, dedication, and quit action unless an explicit + value changes the first two; +- replacing the buffer in an existing usable side slot preserves its current + requested height when `height` is omitted, but the new presentation defaults + to `dedicated = false`; an explicit dedication applies only after the old + presentation passed eligibility and cannot be used to clear-and-bypass an + existing dedication in the same call; +- an ordinary/exact non-side replacement defaults to undedicated, while a + same-buffer redisplay preserves existing dedication unless explicitly + changed. + +These rules let a user-resized panel keep its height as compile/listview +replace one another, prevent a harmless same-buffer redisplay from unpinning a +window, and still make every adopter's newly installed presentation +undedicated by default. + +### Q#BP4 — The display transaction and the final-focus matrix (R2-4, rp-3) + +Rev 2's Phase 2 *always* restored `saved_active`, which erases `select = true` +outright, and restored only when the saved window was non-side, so +`select = false` from a live focused panel blurred it. Both are wrong. + +**Phase 1 (core, no Lua).** `EditorCore::display_buffer` chooses the target +(Q#BP3/Q#BP11a), installs the buffer, records `saved_active: WindowId`, returns +`DisplayOutcome { target, saved_active, select, fire: HookKind }` where +`HookKind ∈ { AfterSwitch, AfterLoad, None }`. + +**Phase 2 (the Lua-owning layer).** Activate `target`, fire the hook, run +`reconcile_panel_layout(frontend_id)` (hooks may resize, close, or replace the +target), then **revalidate both ids** against `core.windows`, the frontend's +`layout.iter_ids()`, and `panel_hidden`, and apply: + +| `select` | `target` after hooks | `saved_active` after hooks | Final focus | +| --- | --- | --- | --- | +| `true` | live + visible | — | **`target`** | +| `true` | dead or `panel_hidden` | live + visible | `saved_active` | +| `true` | dead or `panel_hidden` | dead or `panel_hidden` | non-side target rule | +| `false` | — | live + visible (**side or not**) | **`saved_active`** | +| `false` | live + visible | dead or `panel_hidden` | `target` | +| `false` | dead or `panel_hidden` | dead or `panel_hidden` | non-side target rule | + +Two corrections encoded here: `select = true` **keeps the target selected**, and +`select = false` restores a saved window **even when it is the panel** — a +passive display invoked from a focused panel must not blur it. "Visible" means +not `panel_hidden` per Q#BP2b. + +The hook-failure arms are tested in **both** `select` modes (acceptance). + +### Q#BP5 — The divider is a general split-boundary drag (sub-problem 1) + +`window_at_cell` maps a mode-line row to the window above it and `dispatch_mouse` +already reserves that row (`src/editor.rs:1865`). We spend that reservation. + +- A leaf's outer bottom row is a **drag handle** when it is an exposed segment + of a horizontal ancestor boundary. If that ancestor's upper child is a + vertical/nested subtree, every leaf segment touching the same bottom edge + paints and resolves to the **same boundary**; dragging any segment has the + same result. +- `Down` arms `WindowDragState { frontend_id, boundary, start_row, + start_extents }` beside `MouseClickState`; `Drag` recomputes — `fixed_rows` + when one side is fixed, weights when both are flexible — clamped by Q#BP2's + **interactive** recursive minimum when satisfiable, and never worsening an + already-unsatisfied side; `Up` disarms. Selection is untouched. +- A flexible pair writes weights (ratio survives a terminal resize); a side + window writes `fixed_rows` (absolute height survives). That difference is the + point. +- **No pointer-shape change in the TUI** (`OSC 22` is xterm-only) — deferred by + name. The affordance is `ui.divider` (Q#BP5a) plus keyboard parity (Q#BP5b). + +### Q#BP5a — Where the divider actually is (R1-7) + +- **TUI.** The divider is the upper subtree's exposed existing mode-line + segment(s). No row is added or consumed; every adjacent leaf segment along + that boundary renders with the reserved theme face **`ui.divider`** + (`src/highlight.rs:234-248`) plus a handle glyph. The root panel divider is + therefore full width even when the document subtree ends in several columns. + **`fixed_rows` excludes it.** +- **GPU.** The projected panel grid holds the panel window's rows and **its own + bottom mode line** — not the document's mode line, which is not part of the + panel window. So the GPU **paints its own divider chrome**: a `ui.divider` + rule of `BASE_DIVIDER_HEIGHT`, **frontend-local, outside the projected grid + and outside `fixed_rows`**, exactly as the status band is chrome outside the + document. The drag hit strip is that rule. + +**The daemon is authoritative for rows**: the GPU converts pixels to rows and +sends rows (Q#BP15a), never the reverse. + +### Q#BP5b — Keyboard resize, boundary resolution, and `resize(win, …)` (rp-4) + +`window.enlarge` (`C-x ^`) / `window.shrink` (`C-x C-^`) act on the **active** +window. `pmacs.window.resize(win, delta_rows)` resolves from the **supplied +`win`** — the Lua entry point is explicit, the commands are implicitly active. +Both resolve the boundary identically: + +1. Active/supplied window is a **side window** → its own fixed boundary. +2. Otherwise → walk up from the leaf to the **nearest horizontal-split ancestor + at which the path child has a following sibling**, and move that boundary. + (Rev 2 said "nearest horizontal ancestor", which is wrong when the subtree is + that ancestor's *final* child — there is no boundary below it there.) +3. No such ancestor → report "no adjustable horizontal boundary", no-op. + +Rule 2 moves the same boundary a drag on that window's bottom mode line moves — +that identity is an acceptance case, tested in a nested layout where the naïve +"nearest horizontal ancestor" reading picks the wrong one. All three +interactive entry points share the Q#BP2 preference clamp; programmatic +`display(..., {height = ...})` uses only the structural floor. + +### Q#BP6 — Focus, child input, and the window guards + +Input needs no new code (Q#BP1). The **guards** do: + +- `close_active` (`src/editor_core.rs:2349`) must refuse **only when the target + is the last non-side window**. Closing the side window itself is always legal, + including as the only other window. +- `close_others` from a document window also deletes the panel; from a **side + window it errors**. `split_active` from a side window errors. +- `focus_next/prev` include a side window only while it is visible. A hidden + panel is never a focus destination; after it reappears, traversal reaches it + normally (Q#BP2b/Q#BP2c). + +### Q#BP7 — Panel height vs scrollback (sub-problem 3) + +**Invariant: a height change is a viewport change, never a scroll change.** +`top` is preserved verbatim. Not "preserve the bottom row" — that fights +tail-follow. + +1. **Growth reaching the live tail re-arms follow** (`top` → `None`) — **only + when no selection is active** (R1-8). `selection_froze_top` + (`src/terminal/view.rs:360`, `:422`) and `view_geometry` (`:625`) already + encode the freeze; the re-arm goes in the **shared viewport-size path** + (`record_view_size` / `snapshot_for_view`) so grid and semantic declarations + agree. +2. **Shrink to zero never happens**: Q#BP2's clamp plus Q#BP2b's hide, and + `record_view_size` already fails closed (`src/terminal/view.rs:273-296`). +3. **Only the controller resizes the PTY** (`src/editor.rs:1234-1249`); two + frontends may hold different panel heights over one child. Pinned, not + "fixed". +4. **A semantic panel terminal sizes from the panel content rect.** At the + existing pre-child-drain terminal-sync point, the daemon resolves the + visible side window against `frame_geometry`, derives + `(fixed_rows - mode_line) × total.cols`, records that exact + `TerminalViewKey` size, and resizes the PTY only if it is the controller. + It never consumes the GPU attach `term_sizes` placeholder or the + full-document `TerminalResize` declaration. + +### Q#BP8 — The GPU panel band (Stage 2) + +The daemon projects the frontend's bottom side window into a standalone cell +grid; the GPU paints it as a band above the status band and shrinks its text +area by band + divider height. The panel projection is a sibling pass: it runs +independently of whether the primary document has declared a byte viewport or +is in full-window terminal mode, so neither existing early return can suppress +the band. + +- **Extraction, not new rendering**: `paint_frame`'s per-window body already + paints one window into a `CellGrid` through an origin-agnostic `Viewport<'a>` + (`src/editor.rs:2937-3040`); painting into a panel-sized grid at `(0,0)` is + that body lifted out. The extraction also takes the active-window + cursor-visible preparation currently just before the loop + (`src/editor.rs:2883-2935`): it runs for the panel only when that window owns + focus, uses the same supplied fold map, and leaves passive `view_top` + untouched. No concrete text/gutter/overlay/modeline painter forks (Bet B2'). + `pmacs-gpu/src/terminal.rs` is already a pure cell-space planner for this + payload shape. +- **The extraction boundary is per-window, not per-frame.** Text, gutter, + selection, mode line, and window-attached overlays (including a panel's + `SearchView` / `MenuView` / `CompletionView`) enter `PanelFrame`. The + frame-global status row, search prompt, and minibuffer do not; semantic focus + chrome carries those surfaces per Q#BP14b. +- **Statusline callbacks still run once.** Generalize the existing + `StatuslineEvaluationTarget::Grid` fan-out into a frontend-layout target: + the grid target keeps today's layout-leaf fan-out but omits a derived-hidden + side, while a semantic panel target captures exactly the primary document + window plus its visible side window (unprojected document splits do not run + callbacks). Evaluate before paint, then transactionally revalidate as today. + Route the primary document result to semantic `StatuslineSegments` and the + side result to the panel mode line. A callback mutation runs Q#BP2b + reconciliation before either result is consumed, and an invalidated + evaluation paints no stale text. This closes the indirect `view.active` read + at `src/statusline.rs:634` without evaluating a provider twice. +- **One transport for every panel kind** — a terminal panel is painted + daemon-side by the same `paint_terminal_snapshot` the TUI uses. +- **Accepted consequence**: panels are **monospace cell grids** in the GPU. + Documents keep the rich renderer. +- **Document declarations follow the installed band.** Applying + `Present`/`Absent` recomputes the GPU document clip and emits the ordinary + document `Viewport` or full-window `TerminalResize` if its effective size + changed. `FrontendCellGeometry` does not change in response—the whole-frame + declaration deliberately excludes panel presence—so this cannot feed back + into panel sizing. +- **Discipline inherited from `TerminalFrame`**: whole-grid replacement, + `validate` both sides, atomic rejection retaining the previous valid frame, + duplicate suppression on the complete ordered payload, byte-bounded payload. + +### Q#BP14 — One authoritative primary-document context (R2-1, R3-B1) + +Rev 2 proposed `primary_document_window(fid)` and named three couplings. The +transitive §1.3 census now finds twenty-three. The projection contract: + +**Definition.** `EditorCore::primary_document_window(fid) -> Option` — +the frontend's active window when it is non-side, else its non-side target +(Q#BP11a). **Every consumer classified Projection in §1.3 (#1–#12 and +#21–#22) routes through it.** Focus and surface-routed consumers follow +Q#BP14b. + +The census rule is transitive: a new call to `active_window_for`, +`active_window`, `active_buffer_id`, or any helper that reaches one of them in +the daemon/semantic projection — including helpers implemented in +`src/editor.rs` or `src/statusline.rs` — must add or reaffirm its +classification. This is a review checklist item, not a lint; acceptance pins +each class. + +**The alignment helper splits in two.** `align_semantic_window_to_buffer` +(`src/daemon.rs:2900`) unconditionally rewrites `view.active`'s buffer, which is +exactly why rejecting panel-named events does not fix the *document* event — +with the panel focused, an ordinary document `Viewport` overwrites the panel's +buffer with the document buffer. + +- **`align_primary_document_window(fid, buffer_id)`** — rewrites the **primary + document window's** buffer/`TextView`/cursor. **Never touches `view.active`.** + Used by `Viewport` (#7). +- **Document `Pointer` (#8)** calls the same aligner **and then activates the + primary document window** before dispatching the gesture — a click in the + document area means "work here", so it moves focus out of the panel. This is + the one place projection and focus legitimately move together. +- **The `Viewport` terminal-context guard (#9)** tests the primary document + window plus the declared buffer, never the focused panel. A terminal panel + therefore cannot reject the still-visible document's viewport. +- **Existing full-window terminal transport (#10–#11)** remains the document + surface. `TerminalResize`, terminal snapshot/sync, and terminal-frame + suppression resolve a terminal key from the primary document window. + `TerminalPointer` validates against that declaration; any accepted + non-`Move` gesture activates the primary document window before replaying the + existing terminal gesture, while hover neither focuses nor claims control. + Panel terminals use `PanelFrame`/`PanelPointer`, never these declarations. +- **Statusline evaluation (#12)** uses Q#BP8's one frontend-layout fan-out: + primary-document segments remain on the semantic document status band while + the side context paints only in the panel mode line. +- **Semantic snapshot publication (#21)** tests whether a recipient displays + the published buffer through that recipient's primary document window. This + predicate is shared by lazy-upgrade and #148 initial-target publication: + panel-only visibility never swaps the document mirror, while panel focus + never hides a matching document surface from the publication. +- **Fresh no-target view construction (#22)** clones the buffer in + `primary_document_window(FrontendId::LOCAL)`, not `local_view.active`. A TUI + panel may own focus at attach without becoming the new frontend's + full-window document. The new view still starts as one ordinary leaf focused + on that inherited document buffer. +- **`PanelPointer` (Q#BP16)** activates the **panel**. + +So: `Viewport` never steals focus; document clicks take it; panel clicks give +it back. And because #1–#12 plus #21–#22 use the +primary-document/surface split, focusing the panel re-sends no snapshot, +suppresses no document, swaps no mirror, clears no document terminal or +statusline declaration, and cannot leak into a newly attached document view. + +**“Active buffer” in the semantic replica is now a document-surface term, not +an input-focus term.** Stage 2 audits and updates the contracts/comments/tests +for `InstanceMessage::CursorByte`, `BufferMirror::active_buffer`, +`SemanticRenderState`, `StatusFacts`, `LineNumbers`, `StatuslineSegments`, and +`TerminalFrame`: for a panel-capable semantic session these identify the +primary document declaration/mirror while a panel may separately own focus. +No wire field is renamed and legacy/grid behavior is unchanged; grid clients +already discard the semantic families. `DispatchIdle`, authenticated input, +presence, and Q#BP14b remain the authorities for actual focus. This vocabulary +split is load-bearing—leaving “active means focused” in the replica contract +invites a later producer to reintroduce the mirror swap. + +**The lazy CRDT upgrade (#2) is the sharpest case** and gets its own rule: the +upgrade + broadcast (`src/daemon.rs:1096`) keys on the **primary document +window**, so focusing a fresh generated panel buffer never broadcasts a +`BufferSnapshot` for it. A panel buffer that genuinely needs CRDT backing gets +it when it is displayed as a document, not as a side effect of focus. + +### Q#BP14a — Panel input gating is per-window, not per-buffer (R2-2) + +Rev 2 proposed auto-marking every side window's buffer round-trip, with an +opt-out. Both are wrong. `round_trip_buffers` is a **global set keyed by +`BufferId`** across every frontend and window (`src/editor_core.rs:349`), so +marking buffer A because *one* frontend panels it disables optimistic input for +another frontend editing A as its document; replacement and close would need +reference counting plus preservation of any pre-existing mark. And an opt-out is +unsafe: with the panel focused, the GPU would optimistically edit its document +mirror while daemon input targets the panel, and every resulting op fails +remote-op validation (#13, `src/daemon.rs:2531-2537`) — a silent mirror +divergence. The accepted-op cursor/provenance path (#23) intentionally retains +the focused source window; it is not redirected to the primary document. + +**The rule: `dispatch_idle_for` returns `false` whenever the acting frontend's +active window is a side window**, independently of the buffer-global set +(`src/editor.rs:753-769`). No auto-marking, no reference counting, no opt-out. +Existing `listview` / compile / terminal marks stay exactly as they are and keep +governing their full-window behavior. + +This is **one panel-aware producer condition** — which is why B1 is narrowed to +terminal controller/escape routing rather than claiming all input gating is free. + +### Q#BP14b — Focus chrome and per-window overlay routing (R3-B1, R3-rp4) + +The semantic producer gains a **focus-chrome pass** that runs once per semantic +frontend independently of whether a document viewport exists and independently +of the document/terminal projection pass. It reads modal state through the +acting frontend's focused context, never through `vp.buffer_id`. + +| Surface | Document focused | Panel focused | +| --- | --- | --- | +| Search | `SearchPrompt` on the semantic status band; document `SearchView` supplies washes | `SearchPrompt` still uses the semantic status band; panel `SearchView` washes are in `PanelFrame` | +| Minibuffer | `MinibufferPrompt` | `MinibufferPrompt` — it is global and bufferless | +| Menu | `MenuPrompt` native popup; no document cell-grid menu | `MenuView` is painted in `PanelFrame`; semantic `MenuPrompt` emits/retains authoritative empty | +| Completion | `CompletionPopup` native popup | `CompletionView` is painted in `PanelFrame`; semantic `CompletionPopup` emits/retains authoritative close | + +The menu/completion baselines track the **currently owned surface**, not merely +a per-buffer payload. A document→panel focus change therefore emits the clear +for a formerly open native popup even if the focused panel carries a different +buffer; a panel→document change cannot leave a pre-painted panel popup in +native GPU state. `BufferSnapshot` baseline resets audit both the open and clear +mirrors, following the #120 rule. + +No new focus-owner wire field is needed: a current +`PanelFrame::Present { buffer_id, focused: true, ... }` is the authenticated +panel-surface declaration. A surface transition is ordered: +**authoritative closes for the old owner → new `PanelFrame` +focus/presence → opens/updates for the new owner**. Thus a panel-owned search +clear is accepted while the old focused declaration still exists; only then +may `Absent` or `focused = false` remove that authority. Conversely, a newly +panel-owned prompt follows the `focused = true` frame it relies on. The GPU +accepts `SearchPrompt { buffer_id, ... }` when `buffer_id` matches either its +primary document mirror or its current focused `Present` panel; the latter +still renders prompt text in the semantic status band while match washes come +only from the panel grid. A prompt naming neither surface is stale and is +dropped without changing the current prompt. +Document-native completion validates against the document as today; panel +completion/menu opens only inside `PanelFrame`, while the semantic native +variants carry authoritative close. `MinibufferPrompt` remains bufferless. + +Focus consumers #13–#15 and #23 keep the focused window. #16–#19 use the +routing table above. Bell drain #20 keeps its per-session counter but uses the +focused window to choose the eligible terminal; passive/historical bells +remain baseline-suppressed exactly as today. + +### Q#BP15 — `PanelFrame` lifecycle (R1-2) + +- **Explicit presence.** `InstanceMessage::PanelFrame(PanelFramePayload)` where + the payload is `Present(PanelFrame)` | `Absent`. **`Absent` is authoritative + and must be sent** on close *and* on hide (Q#BP2b) — silence would leave the + last valid frame on screen forever under the retain-on-invalid rule. `Absent` + is duplicate-suppressed like any payload. +- **Cursor and focus travel with the frame.** `paint_frame` returns the cursor + separately (`src/editor.rs:2833`), so cells alone lose the caret. + `PanelFrame` carries `cursor: Option` and `focused: bool` — the GPU + paints the band caret only when the panel owns focus. `focused` is + presentation/focus-chrome routing only (Q#BP14b); the *keys* decision is + `DispatchIdle` (Q#BP14a). +- **Presentation identity.** `PanelFrame` carries `buffer_id` **and** + `panel_epoch: u64`, plus the frontend-owned `geometry_epoch` it is answering. + The panel epoch is opaque and monotonic per frontend. It stays stable across + ordinary frames of one continuously present window/buffer, and changes on + buffer replacement, new side-window creation, and every + `Absent`→`Present` transition. Thus closing/hiding and reopening the same + persistent buffer cannot reuse the identity of an old frame (Q#BP16). + Allocation is checked; exhaustion fails closed to `Absent` rather than + wrapping into a stale identity. + `geometry_epoch` is different: it changes whenever the frontend declares new + effective cell geometry, even if the panel presentation is otherwise the + same (Q#BP15a). +- **Absent clears input authority.** Emitting or applying `Absent` clears the + last declared panel size and **panel** epoch on both sides before any later + event can validate. Whole-frame geometry remains valid until superseded by a + newer authenticated declaration. +- **Cell-grid validation is shared, terminal dimensions are not.** Factor the + cell count, cursor, glyph width/continuation topology, aggregate glyph-byte, + visible-cell, and transport-safety checks out of + `pmacs-protocol/src/terminal.rs` into one parameterized wire-cell-grid + validator. `TerminalFrame` still adds its PTY-specific + `MAX_TERMINAL_ROWS/COLS = 512`; `PanelFrame` does **not** inherit that + per-axis cap. A common 4K/small-font panel wider than 512 columns remains + legal as long as its checked area and aggregate glyph bytes fit the shared + wire budget (Bet B5'). + +### Q#BP15a — Three geometries, two messages, one exact conversion +(R2-3, R3-B2, R3-B7) + +Rev 2's `PanelResize { size: CellSize }` conflated the frontend's total frame, +the requested panel rows, and the resulting grid — and created a **first-open +cycle**: the declaration was gated on a side window existing, but the daemon +needs columns before it can paint the first frame. The GPU's attach `CellSize` +cannot fill the gap: it is permanently the placeholder `24×80` +(`pmacs-gpu/src/attach.rs:420-429`, `:573-577`) and no resize updates it. + +Two messages with different lifetimes: + +- **`FrontendEvent::FrontendCellGeometry { frontend_id, geometry_epoch, + total: CellSize }`** — the frontend's authoritative cell-equivalent layout + capacity. It is valid **without a side window**, sent immediately after + attach acceptance and refreshed on **window resize, font change, and scale + change**. `geometry_epoch` is a checked, monotonically increasing + frontend-owned declaration id; exhaustion fails closed rather than wrapping, + and a lower/repeated epoch with different data is stale/invalid. The event is + accepted only from the authenticated, negotiated + panel-capable semantic session; the word "without" refers to side-window + presence, not protocol/session gates. +- **`FrontendEvent::PanelResizeRows { frontend_id, geometry_epoch, + panel_epoch, rows }`** — the requested fixed panel rows from a divider drag. + Its only size component is rows; the epochs are identities, not geometry. It + is accepted only for the currently visible `Present` panel matching both the + latest geometry declaration and presentation epoch, then clamped by Q#BP2's + interactive preference. + +Both events join `pmacs-gpu/src/attach.rs`'s bounded outbox policy as distinct +same-kind **tail-coalescible** classes. Geometry is latest-wins (epochs need +only increase, not be consecutive); resize drag is latest-wins over the +complete event, including its epochs, so a new presentation may supersede a +queued stale drag. Tail-only replacement preserves ordering across a click, +key, `PanelPointer`, or geometry transition, and daemon-side epoch validation +still rejects anything stale. Neither human-rate stream consumes the 8192 +lossless-event budget while the writer is stalled. + +The GPU declares **whole-cell capacity**, not pixels or a guessed grid. For +current GPU geometry: + +``` +available_height_px = + max(0, surface_height_px + - status_band_height_px + - TEXT_TOP_px + - divider_height_px) + +layout_rows = floor(available_height_px / code_line_height_px) +total.rows = layout_rows + 1 // virtual daemon status row +total.cols = floor(surface_width_px / resolved_monospace_advance_px) +``` + +All quantities use the frontend's current scale. `divider_height_px` is the +scaled frontend-local divider reserved **for sizing purposes even while the +panel is absent**; this keeps the declaration independent of panel presence +and breaks the first-open cycle. The document renderer does not actually lose +those divider pixels until a `Present` panel is painted. `total.cols` describes +the full-width panel grid beginning at x=0; document `TEXT_LEFT`/gutter padding +is unrelated. Only full cells count. While the band is present, any fractional +right-edge remainder is painted as panel background but maps to no cell and +emits no `PanelPointer`; above the band, the document keeps its normal full +pixel width. + +The conversion accepts only finite, positive line-height/advance metrics and +uses checked/saturating conversion to `u32`. A zero surface, non-finite metric, +or non-positive advance/line height declares zero usable geometry under a new +epoch and therefore hides the panel; it never divides, wraps, or emits a giant +grid. Aggregate area validation still applies after conversion. + +The added row is virtual because the shared grid placement helper subtracts one +global status row before laying out windows. The GPU's real status band remains +pixel chrome; it is not painted into `PanelFrame`. + +**The daemon derives the third geometry.** Panel grid cols = `total.cols`. +Rows are `fixed_rows` clamped per Q#BP2 against `total` and, for a semantic +panel, by `shared_visible_cell_budget / total.cols`; if that wire-area cap is +below the structural two-row floor, the panel follows Q#BP2b's hidden arm. +The requested `fixed_rows` remains stored, so a later narrower geometry can +restore it. The daemon paints and ships the resulting grid in `PanelFrame`; the +GPU never asserts its size. The rendered band is exactly +`grid.rows * code_line_height_px`; divider and status-band pixels remain +frontend chrome, so document shrink is exact and contains no row-rounding +feedback loop. For a terminal panel, the grid's content rows exclude its one +mode line and feed Q#BP7's pre-drain terminal view/controller sync before the +snapshot is painted. + +**Unknown is first-class.** A semantic `FrontendView` starts with +`frame_geometry = None`; the daemon must not consult the attach request's 24×80 +placeholder for panel layout. A panel requested before the first real +declaration remains non-presentable under Q#BP2b. The GPU sends geometry before +enabling user input; receipt stores it, reconciles visibility, and permits the +first `Present`. Grid/LOCAL frontends continue to populate the same cached field +from their existing real attach/resize sizes and never send this new event. + +**Geometry changes fail closed.** As soon as the GPU sends a new +`geometry_epoch`, it retains but does not paint or hit-test an older +`PanelFrame`; only a matching `Present` can make the band visible and +interactive again. An `Absent` is always safe to apply because it only removes +paint/input authority. Every `Present` echoes the daemon's latest accepted +geometry epoch. This is the font/scale/resize analogue of terminal-frame size +validation and prevents an old grid from being interpreted under new metrics. + +### Q#BP16 — GPU panel pointer transport and presentation identity +(R1-3, R2-7, R3-B3) + +Existing events cannot carry panel gestures: semantic `Pointer` carries a +**document byte**, `TerminalPointer` is keyed to a terminal buffer, and `Mouse` +is contractually the **grid** path (`src/daemon.rs:3122-3130` drops terminal +declarations from grid sessions for exactly this reason). + +`FrontendEvent::PanelPointer { frontend_id, geometry_epoch, panel_epoch, +buffer_id, coord: CellCoord, kind: MouseKind, mods: Modifiers }`. + +`buffer_id` catches A→B replacement, but it cannot catch close/hide/reopen of +the **same** persistent buffer. `panel_epoch` closes that hole without putting +`WindowId` on the wire. `PanelPointer` is validated in this order: + +1. The authenticated source negotiated the panel event and matches/owns the + claimed `frontend_id`. +2. Its `FrontendView` has a live side window that is **not + `panel_hidden`**, and its latest daemon→frontend declaration is `Present`. +3. The payload's `geometry_epoch` equals both the latest accepted frontend + geometry and the echoed epoch in that `Present`. +4. The payload's `panel_epoch` equals that declaration's presentation epoch. +5. The side window's current `buffer_id` equals the payload's. +6. `coord` is inside that declaration's panel size. + +`Absent` clears steps 4–6's presentation state. Any failure drops the event +before any view, controller, selection, menu, or PTY mutation. A +`PanelResizeRows` follows the same +source/visible/Present/geometry-epoch/panel-epoch validation before changing +`fixed_rows`. + +`PanelPointer` events whose `kind` is `Move` or `Drag` receive their own +same-kind tail-coalescing tags beside document/terminal motion and drag. +Every `Down`/`Up` and wheel step remains lossless and ordered: repeated left +`Down`s are what the existing daemon click state interprets as a multi-click, +and `Down(Right)` is the context-menu gesture, so neither may collapse. The +event's geometry/presentation identities remain part of daemon validation; +coalescing never crosses an intervening event or combines different kinds. + +Once accepted, the daemon re-derives the panel window and replays existing +semantics: a terminal panel takes the Stage 2 vterm pointer path (child SGR +reporting when eligible, else per-view scroll/selection/menu); otherwise the +ordinary document gesture path in cell space. Click-to-focus is a `Down` on the +band; it activates the panel and, per Q#BP14, does **not** disturb the document +mirror. One terminal-specific consequence is explicit: every accepted +non-`Move` terminal gesture activates the panel before the shared terminal +adapter runs, because that adapter deliberately claims the controller for +wheel/press/drag/release as well as clicks. Bare hover neither focuses nor +claims. Non-terminal wheel motion keeps today's scroll-without-focus behavior. + +### Q#BP17 — Fold projection for the panel grid + +Folding asserts *"a semantic session never enters `paint_frame`"* and builds the +per-window map **ungated** on that basis (`src/editor.rs:2991`). The panel band +breaks the premise. + +**Rule: the panel projection honors the owning frontend's `fold_projection`.** +The extracted per-window painter takes the map as a **parameter** rather than +building it; the panel path passes `None` when the owning frontend's +`fold_projection` is false. The panel path must **not** call +`EditorCore::fold_map_for_window`, which gates on the **active** frontend +(`src/editor_core.rs:566`) — right for command-time reckoning, wrong for +painting another frontend's panel. **Updating the now-stale invariant comment at +`src/window.rs:339` is part of Stage 2.** + +### Q#BP9 — Protocol: Stage 1 none; Stage 2 takes the next available version + +- Stage 1 changes no wire shape. The reviewed base is v20 after #148 and Stage + 1 inherits it without adding or reserving another version. +- Stage 2 appends `InstanceMessage::PanelFrame` after whatever that enum's final + variant is at the time, and appends + `FrontendEvent::{FrontendCellGeometry, PanelResizeRows, PanelPointer}` after + that enum's final variant. **Each extended enum gets a byte pin on its own + previous final variant's discriminant.** On `0dd16a5`, those pins are + `InstanceMessage::InitialTargetResult` and + `FrontendEvent::TerminalPointer`. Gated in both directions. +- **No future version is reserved.** Stage 2 takes the next available version + at implementation time—v21 if no intervening protocol PR lands—per + `docs/dap-debugging-framing.md` Q#DAP8. +- **Every gate keys on the daemon's own state.** All three events require an + authenticated semantic session whose claimed `frontend_id` equals the + transport source and that negotiated the panel version/capability. + `PanelResizeRows` / `PanelPointer` additionally require the current visible + `Present` declaration and matching geometry/presentation epochs; + `FrontendCellGeometry` deliberately does **not** require a side window + (Q#BP15a). A grid session or pre-panel semantic peer sending any new event is + rejected before payload state is trusted. + +### Q#BP10 — Persistence: side windows are not saved + +`src/desktop.rs`'s save walk skips side leaves; restore never creates one. +The v1 `SavedLeaf` shape remains unchanged: every restored ordinary window gets +default `WindowParams` (`side/fixed_rows/quit_action/origin_document` empty, +`dedicated = false`). Thus this arc does not bump `DESKTOP_VERSION` merely to +persist transient display policy. +Deferred: persisting panel geometry as a setting (blocked on settings +persistence). + +### Q#BP10a — Killing a panel buffer (rp-2 of round 1) + +`kill_buffer` redirects **every** window showing the victim to `*scratch*` +(`src/editor_core.rs:3046`). For a side window that is wrong twice. + +- Killing the buffer in a **side window closes the side window** (Q#BP2a + collapse) rather than redirecting it. +- If that would leave no non-side window — impossible under Q#BP6, asserted + anyway — the wrapper collapse restores the prior root, which by construction + holds a leaf. +- `QuitAction::Restore { buffer_id, .. }` **revalidates** at quit time; a + killed target degrades to `Delete`. This lifts `listview`'s existing fallback + (`builtin/runtime/listview.lua:164-166`) into the core. + +### Q#BP11 — Lua surface + +```lua +pmacs.window.display(buf, { side = "bottom", height = 12, + dedicated = true, select = false }) +pmacs.window.display_file(path, { window = win, select = true }) -- Q#BP11b +pmacs.window.quit() +pmacs.window.panel() +pmacs.window.params(win) / set_params(win, {...}) -- side/origin/quit action are read-only +pmacs.window.resize(win, delta_rows) -- boundary per Q#BP5b +pmacs.window.display_target() -- the non-side target +``` + +Commands: `window.quit`, `window.enlarge`, `window.shrink`. Settings: +`window.panel-height` (default 12 outer rows), `window.min-height` (Q#BP2). +Every Lua operation taking a `WindowId` validates that it is live and belongs +to the acting frontend's layout; a cross-frontend id is a pointed error before +read or mutation. + +### Q#BP11a — The non-side target rule (R1-5) + +1. Selected window is **not** side → it is the target (byte-identical to today). +2. Else the **remembered document window** (`origin_document`, Q#BP2c) if it + revalidates. +3. Else the **first non-side window in `iter_ids()` order**. +4. Else (no non-side window — forbidden as a resting state by Q#BP6) → + `debug_assert!` the broken invariant and return a pointed error without + mutation. There is no document leaf from which a valid fallback can be + fabricated. + +### Q#BP11b — A target-aware load transaction (R2-5) + +`display_target()` returns a *window*, but Lua has **no operation that loads or +switches into an arbitrary window**. `pmacs.buffer.find_or_open` switches the +**active** window in both branches before firing hooks +(`src/lua_bindings/mod.rs:3089`, `:3108`, `:3113`), and LSP +(`builtin/runtime/lsp.lua:1597`) and compile (`builtin/runtime/compile.lua:869`) +call it directly. #148's private `open_initial_target` +(`src/daemon.rs:1625-1677`) proves the useful off-ambient load seam +(`EditorCore::get_or_load_buffer`), but it too installs and reasserts through +`switch_active_buffer_for`, so it is not an arbitrary-window API. A visit to a +**previously unopened file** would still replace a focused panel before +`display_buffer` could help. Rev 2's Q#BP4 also covered only `after-switch`, +while a fresh load must fire `after-load` with the **document target** active. + +**`pmacs.window.display_file(path, { window, select })`** — one transaction: + +1. Construct the same path key `find_or_open` uses and perform its + side-effect-free registry dedup; do **not** read the file yet. +2. Resolve the destination before I/O. An explicit `window` is an **exact + target** under Q#BP3, not a hint; it must be live, owned by this frontend, + and not dedicated to a different already-open buffer (or, on a miss, to any + buffer). With no explicit window, an existing buffer uses Q#BP3's ordinary + non-side reuse/candidate policy; a miss chooses the first non-dedicated + Q#BP11a candidate. No eligible target is an error **before loading**. +3. On a registry miss, load/create the buffer; on a hit, preserve its unsaved + contents exactly as `find_or_open` does. +4. Enter Q#BP4's transaction with `fire = AfterLoad` on a fresh load, + `AfterSwitch` on a reuse (including a same-buffer no-op), and `None` for a + newly created `NotFound` path, matching #148/local-startup behavior — so any + hook observes the **document target** as active, which saveplace / recentf / + syntax / LSP all require. +5. Apply Q#BP4's final-focus matrix. + +The implementation factors one Rust/editor-core **resolve/load-without-switch** +primitive and one exact-window install primitive for both `display_file` and +#148's `open_initial_target`; the daemon bootstrap does not call back through +the public Lua binding. This prevents two path-normalization, dedup, and hook +transactions from drifting. + +Initial-target bootstrap retains its stronger Q#GT5/Q#GT8 postcondition. It +captures the fresh view's original document window before I/O and runs the +shared exact-window transaction with `select = true`. After its one hook, it +revalidates the target `BufferId`: removal is still bootstrap failure. If the +original document window remains live, reassert the target there and activate +it; if a hook closed that window, resolve an eligible non-side window in the +same new frontend, install the target there without firing a second hook, and +activate it. A hook-created/selected side window is never overwritten merely +because it became `view.active`. Snapshot publication and +`InitialTargetResult::Opened` retain #148's existing order and name the +reasserted document buffer. + +Adopters route through this: `listview` visit +(`builtin/runtime/listview.lua:126`), LSP `visit_location`, compile +`visit_error`. Raw `find_or_open` stays for programmatic use. + +**Stage 1 also needs real opt-in entry points**, because compile, terminal, and +listview currently create/switch their buffers through active-window-only +paths. Calling a generic display afterward was the rev-3 vacuous path; Q#BP3's +placement-aware rule and these entry points make the requested side placement +the first real display: + +All three parse the same strict placement value: +`display = "current" | "panel"`. Unknown values error before creating a buffer, +session, process, or wrapper. In Stages 1–2, omission means `"current"`; in +Stage 3, omission means `"panel"`. Explicit `"current"` always preserves the +adopter's pre-arc selected-window behavior and is the user-facing opt-out from +the default flip. + +- `pmacs.terminal.open{ display = "panel" }` — `pmacs.terminal.open` hardwires + `switch_active_buffer_for(frontend_id, …)` into the active window + (`src/lua_bindings/mod.rs:8500`) and rolls the session back on failure. The + binding takes an optional exact target window (mutually exclusive with + `display = "panel"`), defaulting to today's behavior; the panel opt-in uses + `select = true`. Placement failure removes any side wrapper created by the + transaction before the existing session/buffer rollback completes. +- `compile.run{ display = "panel" }` — compile creates its buffer + (`compile.lua:263`) then `switch_buffer`s (`:808`); the first display becomes + a side-affine `display` call even when an older document window already shows + `*compilation*`, explicitly with `select = false`. Recompile reuses the + current panel. `compile.quit` routes through `pmacs.window.quit` when the + compilation buffer is in a side window, so it deletes/restores the + presentation instead of leaving a source buffer stranded in the side slot. + In capability fallback it keeps today's previous-buffer restore in the + selected document window. +- `pmacs.listview.open{ ..., display = "panel" }` — `listview.open` currently + hardwires `switch_buffer` (`listview.lua:126`). The opt-in calls + `display(..., {side = "bottom", select = true})`, because `seat_cursor` and + refresh are active-window-only. `listview.quit` keeps the same `q` command and + user-visible behavior, delegating to `pmacs.window.quit` only when the + listview is in a side window; capability fallback retains the current + previous-buffer switch. + +For all three adopters the default panel is **undedicated**, so the one side +slot can be replaced. Creating a new side slot records +`Some(QuitAction::Delete)`. Replacing it snapshots the prior buffer, height, +dedication, cursor/view/selection state, and quit action into +`Some(QuitAction::Restore { … })`; merely redisplaying the same buffer +preserves its action. `origin_document` belongs to the slot lifetime: a +replacement retains the existing valid origin rather than remembering the +currently focused panel. + +`window.quit` executes through Q#BP4's activate–switch-hook–reconcile +transaction. Restoring C→B→A reinstalls each saved presentation and its +`then`; executing `Delete` collapses the wrapper and focuses the revalidated +origin/non-side target. Capability fallback creates no window-level quit +action and leaves no side parameters behind; each adopter uses its existing +ordinary document-window restore path. + +Acceptance pre-seeds the persistent listview/compilation buffer in a document +window before asking for panel placement. That is the bite against accidentally +restoring global reuse-first. + +### Q#BP11c — Jump-ring origins (R2-6) + +The jump ring stores only `(BufferId, Position)` (`src/editor_core.rs:279`), and +`jump_back` switches the **currently active** window to that buffer +(`src/editor_core.rs:811`). After `RET` from an outline or compilation panel, +`M-,` would put the **panel buffer into the document window** while the panel +stays open — a duplicate-buffer/window corruption, and a regression of today's +"M-, returns to the panel row" behavior. + +**History becomes per frontend**, matching `command_history`: +`HashMap>`, where `JumpEntry` is +`{ window_id, buffer_id, position, side_origin }`. `push_jump` and `jump_back` +address only the acting frontend's vector; detach purges it. One frontend can +therefore neither pop nor destroy another frontend's navigation trail. +`JUMP_RING_CAP` applies independently to each vector with today's oldest-entry +eviction. + +`jump_back` restores into the **origin window** only when all of these +revalidate: the window is live, belongs to the acting frontend's layout, is not +hidden when side, **and still shows the recorded `BufferId`**. A live panel +that has since been replaced does not resurrect its old buffer. When validation +fails for a **non-side** origin, the entry degrades to today's active-window +switch behavior within the same acting frontend. When it fails for a recorded +**side** origin (closed, hidden, replaced, or moved out of the layout), the +entry is skipped: switching its buffer into the document window would recreate +the duplicate-panel corruption this design is meant to remove. Entries whose +buffer is gone are likewise skipped. + +Acceptance runs the real paths: **panel → `RET` source → `M-,`** for both +outline and compilation, asserting focus returns to the **existing** panel with +its row restored and the document window unchanged. A second acceptance +interleaves two frontends' jump histories and replaces one origin window's +buffer before `M-,`. + +### Q#BP12 — Default placement flips in Stage 3 + +Stage 1 ships the mechanism **opt-in**; existing acceptance suites keep their +meaning. Between Stage 1 and Stage 2 a semantic frontend could hold a side +window it cannot render, so the flip waits. + +**Stage 3 is not "one line per consumer"**: each adopter also moves its visit +path onto `display_file`/`display_target` and takes its own `select` decision: + +| Adopter | Panel placement | Dedicated | Quit action | Visit | `select` on visit | +| --- | --- | --- | --- | --- | --- | +| `listview` (references/outline) | panel, `select = true` | `false` | delete if created; restore replaced panel | `display_file` | `true` | +| compile output | panel, `select = false` | `false` | delete if created; restore replaced panel | `display_file` | `true` | +| terminal | panel, `select = true` | `false` | delete if created; restore replaced panel | n/a | n/a | +| DAP stack/variables | panel, `select = true` | `false` | delete if created; restore replaced panel | `display_file` | `true` | + +An interactive `listview` **must** take `select = true`: `seat_cursor` +(`builtin/runtime/listview.lua:64`) and `listview.refresh` are active-window-only +and would silently seat the wrong window otherwise. + +The Stage 3 default is resolved as a panel request and therefore still passes +through Q#BP13 capability fallback. It is not a hidden global setting. +Explicit `display = "current"` bypasses side placement deliberately and keeps +the old adopter-specific quit/previous-buffer path; like today's entry points, +it uses the raw switch escape and does not consult display-policy dedication. + +### Q#BP13 — Panel capability: a per-`FrontendView` bit set at attach (R1-6) + +```rust +pub struct FrontendView { + pub layout: Layout, + pub active: WindowId, + pub fold_projection: bool, // Arc 6 Stage 2 + pub panel_capable: bool, // this arc; no Default + pub frame_geometry: Option, // epoch + total; None != 24x80 + pub panel_hidden: bool, // cached derived state, never persisted +} +``` + +Set in the attach transaction that already computes `fold_projection` +(`src/daemon.rs:1769`) from `SessionState` (`src/presence.rs:74-84`): + +| Session | `panel_capable` | +| --- | --- | +| `FrontendId::LOCAL` / grid | `true` | +| semantic, `negotiated_protocol_version < PANEL_MIN_VERSION` | `false` | +| semantic, `>= PANEL_MIN_VERSION` | `true` | + +`peer_declared_terminal_support` (`src/daemon.rs:888`) is the helper shape. +`peer_declared_panel_support` is explicitly +`semantic_render && negotiated_protocol_version >= PANEL_MIN_VERSION`; no +client-asserted standalone boolean is trusted. Stage 1 sets `true` for +grid/LOCAL, `false` for every semantic session; Stage 2 flips the version arm +on. `display_buffer` with a `side` falls back to the non-side target **and +discards every side-specific parameter** (Q#BP2c). + +Grid/LOCAL construction supplies real geometry before first input/render. +Semantic construction supplies `None`; Stage 2's authenticated declaration +fills it. Desktop restore spells all fields explicitly, preserving folding's +non-`Default` discipline. Stage 2 additionally holds the current presentation +epoch/declaration beside the semantic render baseline; it is runtime-only and +never desktop state. The same constructor inherits its initial buffer through +Q#BP14's `primary_document_window(LOCAL)`, so adding the capability fields +cannot preserve the old panel-focused attach leak. + +## 4. Bets (explicit, falsifiable) + +- **B1 (narrowed after R2-2) — panel-as-window means the terminal controller, + the `C-c` escape, and release-on-blur need zero new code.** Falsified if any + `TerminalViewKey` / `TerminalController` / escape-dispatch code needs a panel + case. *Input gating is explicitly excluded: Q#BP14a is one new condition.* +- **B2' (narrowed after R3-B20) — the active-window preparation plus + `paint_frame`'s per-window body extract to a standalone panel grid without + modifying a concrete painter.** Falsified if a text/gutter/overlay/modeline + painter reads absolute frame coordinates or `term_size` rather than its + `Viewport<'a>`/placement, or if the shared preparation cannot keep a focused + panel cursor visible. +- **B3 — the terminal's anchor model absorbs height changes with no new state.** + Falsified if Q#BP7 needs a new `TerminalViewState` field. +- **B4 — no document painter breaks when a window's rect becomes fixed rather + than proportional.** Falsified if any painter assumes the flexible-remainder + rule. +- **B5' (narrowed after R3-B21) — `PanelFrame` reuses one factored wire-cell + validator and aggregate area/glyph/transport budgets, but not terminal PTY + per-axis limits.** Falsified if panel cells need a second glyph/topology + implementation or if a legal >512-column, area-bounded panel cannot + round-trip. +- **B6 (restated after rp-3) — opening a panel leaves the prior document + subtree's STRUCTURE byte-identical**: same nodes, same weights, same order, + same `WindowId`s. Its **rectangles necessarily change**, being recomputed + inside the smaller flexible remainder. Falsified if opening a panel reorders, + reweights, or re-ids any document node. +- **B7'''' (replacing the falsified B7'/B7''/B7''') — the transitive §1.3 + census and Q#BP14b surface matrix are complete.** Falsified if any direct or helper- + mediated read of active window/buffer state reached by the daemon/semantic + projection is missing, if a focus surface inherits the document viewport + again, or if an open/clear baseline survives on the wrong surface under + acceptance. + +## 5. Acceptance + +**Stage 1 — core + TUI (no wire change from its eventual base).** + +1. `Layout::compute` honors a fixed extent: a bottom child of N rows gets + exactly N; siblings divide the remainder by weight. **Both production + callers are pinned through their real paths** (R5-B1): a document window's + rows come from `window_placements`, and a peer cursor in that same window is + painted by the overlay pass (`src/overlay_paint.rs:112`) at an identical row + whether or not a panel is open — the assertion that fails if the second + caller keeps computing unfixed geometry. +2. Opening a panel leaves the prior document subtree's **structure** identical + (nodes, weights, order, ids); its rects are recomputed (B6). +3. `subtree_min_rows` is recursive: a **nested** document tree (horizontal + inside vertical inside horizontal) keeps every leaf at the floor, and the + panel is clamped — not the document — when they compete. +4. Programmatic `height`, `window.panel-height`, and side `fixed_rows` requests + of one row clamp to `MIN_WINDOW_OUTER_ROWS`; zero rejects. An intrinsically + too-small or zero-column frame uses saturating arithmetic and hides rather + than underflows or emitting a zero-width panel. +5. A terminal resize preserves a side window's **absolute** height and a + flexible pair's **ratio**, in one layout. +6. Geometry is cached before first input. A command/hook opens and selects a + panel in a too-small frame, then a second key in the **same drained burst**: + reconciliation marks the panel hidden, moves focus to a document, and + releases the observed terminal controller before that key dispatches. +7. Growing the frame enough to make the request satisfiable restores the panel + at its exact requested `fixed_rows`; focus is **not** auto-restored, and + `focus_next/prev` skip it while hidden but reach it after reappearance. + While hidden, its rect is empty and the unchanged document subtree receives + every reclaimed row; the stored request, wrapper, ids, weights, and order + remain intact. +8. Keys typed while the panel is hidden reach the document window, never the + invisible panel. +9. `window.min-height` below the structural floor clamps; a value materially + above it constrains drag/keyboard resize recursively across a nested tree, + while frame-resize layout ignores the preference. +10. Closing the panel collapses the wrapper and restores the prior root exactly. +11. `set_params` rejects adding/changing/clearing `side` and rejects + `origin_document`; `params` may report the origin; a stray `fixed_rows` on + a non-side window is inert. Every `WindowId`-taking Lua operation rejects a + live id owned by another frontend. +12. Raw `switch_buffer` **ignores** `dedicated`; `display_buffer` honors it on + side, reused, exact, and non-side candidates, falling through or erroring + without overwriting one. An ordinary display never reuses a matching side + window. +13. Side placement is affinity-aware: a buffer already visible in a document + window does not preempt a requested usable side slot. An explicit + `window` is exact. A dedicated side fallback never creates a second side + window and discards height/dedication/quit state before touching the + document target; `window` + `side` and a freestanding `height` reject. + Same-buffer redisplay preserves omitted height/dedication/action; + replacement preserves an omitted user-resized height but defaults the new + presentation undedicated; creation uses the setting/default. Explicit + `dedicated = false` cannot bypass an existing dedication in the same call. +14. Capability fallback discards all side-only parameters and leaves the + document target undedicated/unpinned. +15. **Final-focus matrix (Q#BP4), all six rows**, including `select = true` + leaving the target selected and `select = false` restoring a **side** + `saved_active`. +16. The three hook-failure arms (hook closes target / closes saved / switches + buffers) are covered in **both** `select` modes, with reconciliation between + the hook and final-focus decision. +17. A panel displayed into a passive window has its overlays re-attached. +18. **`display_file` to a previously unopened file from a focused panel** opens + it in the exact document target, leaves the panel intact, and fires + `buffer.after-load` with the **document target** active — asserted through + the real LSP and compile visit paths. A dedicated exact target fails + without loading/switching it; an omitted target skips a dedicated + remembered origin and chooses the next eligible non-side window before I/O. + A `NotFound` path creates a path-backed buffer and fires no load/switch + hook, matching initial-target/local-startup behavior. +19. `pmacs.terminal.open{display="panel"}`, + `compile.run{display="panel"}`, and + `pmacs.listview.open{display="panel"}` place through their real entry + points. The fixture first shows persistent `*compilation*` / `*outline*` in + a document window, proving side-affine placement is not vacuous. Unknown + `display` values fail before buffer/process/session/wrapper creation. +20. `listview`/compile `q` route through `window.quit`: the first panel deletes + its wrapper; C→B→A restores each saved height, dedication, + cursor/view/goal/selection, hook-attached overlays, and prior quit action; + a killed restore target collapses safely. Capability fallback restores the + prior document through the adopter's old path and leaves no quit action. + Terminal placement failure removes a newly created wrapper before its + existing session/buffer rollback completes. Replacing more than + `MAX_PANEL_QUIT_DEPTH` times retains exactly the newest 64 presentations, + then terminates in `Delete`; depth never grows beyond the cap. +21. **`panel → RET source → M-,`** for outline and compilation returns focus to + the same still-showing-origin panel row; the document window remains + unchanged and no duplicate presentation is created. +22. Jump histories are per frontend: interleaved pushes/pops cannot consume a + peer's entries. A live origin window now showing a different buffer + is skipped when it was a side origin rather than resurrecting or + duplicating the old panel; an invalid non-side origin retains today's + acting-frontend fallback. +23. `window.quit` revalidates `QuitAction::Restore`; a killed restore target + degrades to delete. +24. Killing a panel buffer **closes the side window** rather than redirecting to + `*scratch*`. +25. `close_active` refuses only when the target is the last **non-side** window; + closing the side window itself is legal even as the only other window. +26. `close_others` from a document window deletes the panel; from a side window + it errors. `split_active` from a side window errors. +27. `C-x o` reaches the panel and returns; the terminal controller is claimed on + entry and released on exit. With two document windows, entering the panel + from B refreshes `origin_document`, so `display_target`, a panel visit, and + a Delete-form `window.quit` target B rather than the window from panel + creation. +28. With the panel focused, unescaped bound keys reach the child; `C-c` escapes + for exactly one key; `C-c C-c` sends one literal interrupt. **B1 pin.** +29. A focused side window makes `dispatch_idle_for` return `false` **without** + marking its buffer round-trip, and another frontend editing that same + buffer as a document keeps optimistic apply. A forged/stale optimistic op + for the document is rejected before source-window cursor/provenance + mutation; a valid round-trip edit still updates the focused panel window. +30. Divider drag changes side `fixed_rows` and document-pair weights under the + interactive recursive preference; a click on the reserved row creates no + selection, and `ui.divider` resolves through the `ui.*` face walk. A + boundary whose upper child is a vertical split paints all adjacent exposed + mode-line segments, and dragging either segment resolves the same boundary. +31. `window.enlarge`/`shrink` equal the equivalent drag in a **nested** layout + where the active subtree is its nearest horizontal ancestor's final child; + `resize(win, …)` resolves from `win`; no horizontal ancestor reports/no-ops. +32. A terminal panel scrolled back keeps its `top` across a height change; + growth reaching the tail re-arms follow; later output scrolls in. +33. Growth reaching the tail with a historical selection leaves the selection + and anchor frozen, via the shared viewport-size path. +34. Only the controller's height change resizes the PTY. A semantic panel + terminal uses the daemon-derived panel content rect at the pre-drain sync + point, never the 24×80 attach placeholder or full-window terminal + declaration. +35. The desktop round-trips a layout containing a file-backed side window + **without** the side leaf or its root wrapper; restored document leaves + have default parameters and the desktop format version does not change. +36. Full gate suite per `AGENTS.md`; because Stage 1 factors #148's target-load + seam, this includes `gpu_initial_target_acceptance` in default and CRDT + configurations in addition to the new/touched panel suites. + +**Stage 2 — GPU band (own re-framing; next available protocol version).** + +37. `PanelFrame` round-trips, including `panel_epoch` and `geometry_epoch`; + independent **byte pins on the previous final + `InstanceMessage::InitialTargetResult` and + `FrontendEvent::TerminalPointer` variants** catch a shift in either + extended enum. +38. Full lifecycle: **open → replace buffer → hidden by a tiny frame → + reappear → close**, with authoritative `Absent` at hide/close and a new + epoch on replacement/reappearance. +39. An invalid `PanelFrame` is rejected atomically; the previous valid frame is + retained. A duplicate valid frame (including duplicate `Absent`) does no + work. Shared cell/topology/glyph/area validation accepts an area-bounded + panel wider than 512 columns, while terminal frames retain their 512-column + PTY cap; maximum legal panel encoding stays below the transport limit. +40. **First open at a non-80×24 frame before any valid panel baseline** remains + absent until real `FrontendCellGeometry` arrives, then produces the correct + grid without consulting the 24×80 attach placeholder. +41. Pixel→cell conversion is pinned at fractional widths/heights: status band, + `TEXT_TOP`, potential divider, virtual status row, full-width monospace + columns, and floor rounding agree. Geometry refreshes on window resize, + font change, and scale change; the daemon alone derives the grid. After a + new `geometry_epoch` is sent, an older retained frame neither paints nor + accepts input until a matching `Present` arrives; `Absent` remains an + always-safe removal, and stale/conflicting epochs reject. A requested panel + whose rows×cols would exceed the shared wire-area budget is row-clamped + without losing its stored request, or hidden when even two rows cannot fit. + Zero/non-finite/non-positive metric inputs fail closed to zero usable + geometry without overflow or an oversized allocation. +42. **Focus into and out of a terminal panel while the document stays visible + and unchanged**: no `BufferSnapshot` re-send, no document suppression, no + mirror swap, no `CursorByte` for the panel buffer, no line-number, + selection-decoration, document-terminal declaration, or document + statusline replacement/clear with the panel buffer. Document statusline + callbacks may truthfully observe `active = false`; presence reports the + focused panel context. The GPU replica's `active_buffer` and authoritative + cursor remain the primary document buffer/cursor while `DispatchIdle` is + false, and the revised protocol/client contract tests name that distinction. +43. **Focusing a fresh generated panel buffer triggers no lazy-CRDT-upgrade + broadcast** (§1.3 #2 — the case rev 2 could not see). With semantic peer A + focused in panel P over document D, a target launch/upgraded-buffer + publication for D still reaches A, while one visible only as P does not + replace A's document mirror (§1.3 #21). +44. A document `Viewport` naming the document buffer while the panel is focused + aligns the **primary document window** and **does not move focus**; a + document `Pointer` aligns **and** activates the document window. With a + full-window document terminal under a focused panel, its viewport and + `TerminalResize` remain accepted, bare `TerminalPointer::Move` does not + focus or claim, and every accepted non-hover terminal gesture activates + the document before replay. +45. From a focused panel, `M-x` opens/types/closes a visible + `MinibufferPrompt`; isearch keeps its semantic prompt while panel washes + paint in the grid. A new focused `PanelFrame` arrives before the + panel-buffer `SearchPrompt`, which the GPU accepts without changing its + document mirror; on hide/close/focus-out, the old panel prompt clears + before its focused declaration is removed. A prompt naming neither current + surface is ignored. + Document→panel focus authoritatively clears a native document + menu/completion popup, while panel menu/completion overlays paint only in + `PanelFrame`; returning to the document reverses ownership cleanly. + The global prompt/clear pass also works before a document viewport exists + and while the primary document is a full-window terminal. + One statusline provider invocation supplies the primary-document wire + segments and panel mode line; a provider that mutates the layout + invalidates stale results and reconciliation runs before paint. +46. The band + divider shrink the document text area by exactly their pixel + height; document carets, hits, and scroll geometry respect the reduced + area. `Present`/`Absent` refresh the ordinary document `Viewport` or + full-window `TerminalResize` without sending a new whole-frame geometry + declaration. +47. Dragging the divider sends + `PanelResizeRows {geometry_epoch, panel_epoch, rows}` and honors + `window.min-height`; hover shows `CursorIcon::RowResize`. A stalled-writer + outbox tail-coalesces repeated resize rows and whole-frame geometry + declarations without crossing an intervening event or exhausting the + lossless queue. +48. `PanelPointer` drives listview row selection, panel selection, terminal + mouse reporting, and click-to-focus without disturbing the document mirror. + A terminal panel's non-`Move` wheel/press/drag/release first activates it + so controller ownership remains consistent; hover does neither. Keyboard + motion beyond a focused panel's viewport runs the extracted active-window + auto-scroll clamp, while a passive panel preserves `view_top`. Panel + move/drag tails coalesce; press/release/context/wheel remain lossless and + ordered. +49. Stale panel events are dropped before mutation for all four cases: + A→B replacement (`buffer_id`), close/reopen of the same A, and + hide/reappear of A (`panel_epoch` / latest-`Present` check), plus a + font/scale/resize declaration race (`geometry_epoch`). `Absent` clears + declared panel size/presentation epoch on both sides without discarding the + whole-frame geometry declaration. +50. `PanelResizeRows` / `PanelPointer` from a source with no visible current + `Present` panel are dropped. `FrontendCellGeometry` from the correctly + negotiated semantic source is accepted without a side window; grid, + pre-panel, forged-source, and wrong-version variants are rejected. +51. **Mixed session**: a pre-panel semantic frontend falls back to a document + window — with every side-specific parameter discarded, leaving the document + window undedicated (Q#BP2c) — while a grid frontend on the same daemon gets + its side window. With `LOCAL` focused in that panel, a fresh no-target + semantic attach inherits `LOCAL`'s primary document buffer, never the + panel buffer (§1.3 #22). +52. A panel projected for a `fold_projection = false` frontend does **not** + collapse folds; the stale comment at `src/window.rs:339` is updated in the + same PR. +53. Bell drain remains focus/session-scoped: a focused panel terminal rings + once per frontend, while passive and historical bells remain suppressed. +54. A `--headless-probe` run drives one real daemon + real PTY + real wgpu + through a panel-hosted terminal, followed by the full gate suite for the + Stage 2 PR. +55. A v20 initial-target attach whose `after-load`/`after-switch` hook + creates and selects a side window still reasserts the requested buffer in + and activates a non-side document window without overwriting the panel. + Closing the original document window in the hook rehomes the target to a + remaining eligible non-side window without a second hook; killing the + target buffer still fails bootstrap. The target snapshot precedes matching + `InitialTargetResult::Opened` exactly as in #148. + +**Stage 3 — adopter default flip.** + +56. Omitting `display` from real listview, compile, and terminal entry points + resolves to the Q#BP12 panel/select policy on a panel-capable grid and + semantic frontend; explicit `display = "current"` preserves each + adopter's pre-arc selected-window behavior. +57. On a pre-panel semantic frontend, the omitted Stage 3 default takes + capability fallback with no side parameters or quit action left on the + document window; its visit and `q` paths remain the existing non-side ones. +58. Updated default-placement suites exercise + open→visit→return→quit through each adopter rather than a generic helper, + preserving the Stage 1 unknown-value rollback assertions; the Stage 3 PR + then runs the full gate suite. + +## 6. Deferred (named) + +Left / right / top side windows; multiple slots per side; **rehoming a leaf +across the tree**; the entire **`no_other_window` parameter and destination-only +traversal semantics**; manual panel hide/show and a future +`window.toggle-panel`; user-facing `display-buffer-alist`-style rules; **GPU +document splits (Arc 8)**; panel +persistence (blocked on settings persistence); `OSC 22` pointer shape in the +TUI; per-panel statusline segments on the wire; proportional-font panels in the +GPU; `window-configuration` registers; atomic windows; panel-local keymaps +beyond buffer and mode scopes; horizontal (`C-x {`/`}`) resize. + +## 7. Interaction with other work + +- **Folding Stage 2 has landed** (#149, runtime base `6ed4fe9`) — the blocking + dependency, now cleared and re-verified against `ddaa80d` in §0.6 (nothing in + flight, suite green, every borrowed anchor reproducing). Canonical `main` is + now `ddaa80d`; any eventual branch starts from current canonical main. + Folding's + `FrontendView` policy-bit pattern is Q#BP13's model, its `Viewport<'a>` is what + Q#BP8 inherits, and Q#BP17 owns the one invariant this arc invalidates. + **Folding Stage 3 (GPU)** and this arc's Stage 2 both touch the semantic + projection; whichever is framed second re-scouts the other's landed state. +- **GPU initial target #148 has landed** at runtime commit `0dd16a5` and owns + protocol v20; #152 then refreshed only the durable handoff/active-work + documentation at canonical `main` `ddaa80d`. + Its attach transaction, `build_fresh_frontend_view`, private target loader, + semantic snapshot publication filter, and previous-final wire variant were + all re-scouted in §0.5. Q#BP9 now starts from v20; Q#BP11b shares the landed + load seam without routing bootstrap through Lua; Q#BP14 covers both the + publication predicate and no-target buffer inheritance. There is no + remaining branch-order dependency on #148. +- **DAP** stays parked until this arc's **Stage 1** lands, then re-baselines its + §0 touch census. Its Stage 2 panels become `display` + `display_file` calls. + +## 8. Prior art in pmacs + +Folding Stage 2 (`docs/folding-stage2-framing.md`, `src/fold_view.rs`) for the +per-`FrontendView` policy bit, the non-`Default` discipline, and per-window map +derivation; Vterm Stage 2 for the controller model, the `C-c` escape, and +per-view projection; Vterm Stage 3 for the whole-grid frame message, `validate`, +payload-complete suppression, stale-declaration rejection (extended here from +terminal-unique `buffer_id` to a panel presentation epoch), and the +`--headless-probe` seam; `listview.lua` for what a panel needs and currently +fakes; `src/desktop.rs:444-452` for activate-then-fire-per-leaf; M11.6's +`DispatchIdle` for the input gate.