pmacs/docs/bottom-panel-framing.md

112 KiB
Raw Permalink Blame History

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-769src/daemon.rs:1223-1248pmacs-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 invariantpaint_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 4748
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:566fold_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 pathwindow_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 policypmacs.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_buffers (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:2020align_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-2046semantic_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-2067dispatch_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:628capture_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)

pub struct WindowParams {
    pub side: Option<Side>,               // immutable after placement (Q#BP2a)
    pub fixed_rows: Option<u32>,          // outer rows, incl. the mode line
    pub dedicated: bool,
    quit_action: Option<QuitAction>,      // implementation-owned; None off-side
    origin_document: Option<WindowId>,    // 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<u32>,
        selection: Option<Selection>,
        then: Box<QuitAction>,
    },
}

Layout::compute(area)Layout::compute(area, fixed: &HashMap<WindowId, u32>). 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<WindowId, u32>, 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: [<prior root>, 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<DeclaredFrameGeometry> 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<WindowId> 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 (topNone) — 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<WindowId> — 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<CellCoord> 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 AbsentPresent 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 46'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 Downs 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

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 12, 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_buffers (: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 activateswitch-hookreconcile 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<FrontendId, Vec<JumpEntry>>, 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)

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<DeclaredFrameGeometry>, // 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 WindowIds. 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).

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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).
  8. 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.
  9. 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.
  10. 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.
  11. 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.
  12. 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.
  13. 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.
  14. 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.
  15. 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).
  16. 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.
  17. Bell drain remains focus/session-scoped: a focused panel terminal rings once per frontend, while passive and historical bells remain suppressed.
  18. 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.
  19. 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.

  1. 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.
  2. 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.
  3. 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.