pmacs/docs/bottom-panel-framing.md

1819 lines
112 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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