From c27f75a1b24433200d57a5aa2f93bdf649cfed41 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 24 Jul 2026 13:00:46 -0400 Subject: [PATCH 01/22] docs: bottom panel framing (revision 4, amended) Adds the approved framing for the bottom-panel arc: a buffer displayed in a fixed-height window pinned to the bottom of the frame, targeted by policy rather than by stealing the selected window, plus the missing display-buffer/window-parameter concept underneath it. Revision 4 follows three review rounds, an integration review, and a landed-state audit against GPU initial target (#148, protocol v20) and folding Stage 2 (#149). Amended before branching by the pre-implementation dependency verification recorded in section 0.6: - the folding dependency is cleared and re-verified against canonical main at ddaa80d (nothing in flight, folding Stage 2 acceptance 48/48 green, every borrowed anchor reproducing, and folding's only window.rs edit confined to one 22-line hunk that leaves the layout functions pre-folding code); - R5-B1: Layout::compute has TWO production callers, not one. The second, the peer-presence overlay pass in src/overlay_paint.rs, builds its own text-area rect from active_layout() and never routes through window_placements, so the planned compute(area, fixed) signature change would otherwise leave every peer cursor painted at its no-panel row. Corrected in section 1.1 and Q#BP2, pinned by acceptance 1, and the fixed map is now specified to come from one shared helper rather than being assembled per call site. Stage 1 (window placement + TUI side windows) changes no wire shape. --- docs/bottom-panel-framing.md | 1818 ++++++++++++++++++++++++++++++++++ 1 file changed, 1818 insertions(+) create mode 100644 docs/bottom-panel-framing.md diff --git a/docs/bottom-panel-framing.md b/docs/bottom-panel-framing.md new file mode 100644 index 0000000..35abd22 --- /dev/null +++ b/docs/bottom-panel-framing.md @@ -0,0 +1,1818 @@ +# Bottom panel — framing (window placement + side windows) + +**Revision 4 — pre-implementation, DRAFT after review round 3 plus landed-state +audit. Ground truth: canonical `main` @ `ddaa80d` (documentation landing #152; +runtime @ `0dd16a5`, GPU initial target / #148 after folding Stage 2 / #149), +protocol v20, 2026-07-24. Amended by the pre-implementation dependency +verification in §0.6: the folding dependency is cleared, and one geometry +caller-census error is corrected.** + +Give pmacs a **bottom panel**: a buffer displayed in a fixed-height window +pinned to the bottom of the frame, resizable by dragging its divider, which +feature code targets **by policy** instead of by stealing the selected window. +This is what makes vterm feel like Emacs's vterm rather than `term` in a stolen +buffer, and it is the presentation substrate flycheck, compile, LSP panels, DAP +(`docs/dap-debugging-framing.md`, **parked awaiting this arc**), remotes, and +MCP surfaces all want. + +The feature is a bottom panel. The **missing concept** underneath it is Emacs's +`display-buffer` + window parameters: pmacs has a real window tree but no way to +say *where* a buffer should appear, and no window that is anything other than +proportionally sized. + +## 0. Revision history + +### 0.1 Round 1 (rev 1 → rev 2) — 8 blocking, 6 revision points, all closed + +Verdict: **panel-as-window (Q#BP1) confirmed; everything downstream of it in the +GPU and display-policy contracts rejected.** R1-1 → Q#BP14; R1-2 → Q#BP15; +R1-3 → Q#BP16; R1-4 → Q#BP2a; R1-5 → Q#BP11a; R1-6 → Q#BP13; R1-7 → Q#BP5a; +R1-8 → Q#BP7; rp-1 → Q#BP6; rp-2 → Q#BP10a; rp-3 → Q#BP4; rp-4 → Q#BP5b; +rp-5 → Q#BP2; rp-6 → acceptance. + +### 0.2 Round 2 (rev 2 → rev 3) — 7 blocking, 5 revision points + +Verdict: substantially stronger, still not approvable. **Bets B1, B6, and B7 +falsified as written.** Every anchor below was re-verified against `6ed4fe9` +before this revision; all seven findings reproduce in the code. + +| # | Finding | Closed in | +| --- | --- | --- | +| R2-1 | Q#BP14 doesn't fully separate projection from focus; census incomplete | Q#BP14 (rewritten) | +| R2-2 | Auto round-trip marking is buffer-global, not panel-local | Q#BP14a (new) | +| R2-3 | `PanelResize { size }` conflates three geometries; first-open cycle | Q#BP15a (new) | +| R2-4 | Q#BP4 erases `select = true` | Q#BP4 (rewritten) | +| R2-5 | Real visit paths lack a target-aware load; no compile/terminal entry points | Q#BP11b (new) | +| R2-6 | The jump ring becomes wrong once a panel is a separate window | Q#BP11c (new) | +| R2-7 | `PanelPointer` has no stale-frame identity | Q#BP16 | +| rp-1 | Minimum-height must be recursive; "never violate" too strong | Q#BP2 | +| rp-2 | Hidden-panel focus must be a durable transition | Q#BP2b (new) | +| rp-3 | B6 / acceptance 2 mathematically impossible as written | Bet B6 | +| rp-4 | Q#BP5b ancestor rule and `resize(win, …)` resolution | Q#BP5b | +| rp-5 | `WindowParams` gaps: remembered id, `no_other_window`, `dedicated` vs raw switch, fallback hygiene | Q#BP2c (new) | + +**Bet corrections:** + +- **B1 falsified as written** — Q#BP14a needs one panel-aware condition in + `dispatch_idle_for`. Narrowed to terminal controller / escape routing only. +- **B6 falsified as written** — opening an N-row panel *necessarily* changes + document rectangles. Restated over the document subtree's **structure**. +- **B7 falsified** — the active-window census is larger than + `primary_document_window`; four more producers (R2-1) plus the input-side + validator. Replaced by B7' over an explicit classified census. + +### 0.3 Round 3 + integration review (rev 3 → rev 4) — 22 blocking, +7 revision points, all closed + +Verdict: the three corrected bets were honestly restated, but **B7' was +falsified immediately** by four indirect active-buffer consumers. The remaining +findings were contract holes in geometry ownership, presentation identity, +placement precedence, real adopter lifecycle, jump-history ownership, and +interactive minima. The integration review extended that audit through +terminal/statusline helpers, render preparation, wire bounds, and the Stage 3 +default lifecycle rather than stopping at the originally reported eight. + +| # | Finding | Closed in | +| --- | --- | --- | +| R3-B1 | §1.3 missed four `active_buffer_id()` semantic producers; focus chrome could disappear or remain stale | §1.3, Q#BP14, Q#BP14b | +| R3-B2 | Hidden-panel reconciliation had no authoritative geometry or mutation seam | Q#BP2b, Q#BP4, Q#BP13 | +| R3-B3 | `buffer_id` cannot identify a close/hide/reopen presentation of the same buffer | Q#BP15, Q#BP16 | +| R3-B4 | Global reuse-first defeated requested side/exact-window placement; dedication was not universal | Q#BP3, Q#BP11b | +| R3-B5 | `listview` had no Stage 1 opt-in or panel-aware quit path; compile lifecycle was incomplete | Q#BP11b, Q#BP12 | +| R3-B6 | A global jump ring plus frontend-tagged entries lets one frontend consume another's history; origin buffer was not revalidated | Q#BP11c | +| R3-B7 | `FrontendCellGeometry` lacked an exact pixel→cell contract, an unknown initial state, and a session-kind gate | Q#BP15a, Q#BP9 | +| R3-B8 | `window.min-height` had no interactive recursion and sub-floor requested heights hid a satisfiable panel | Q#BP2, Q#BP5, Q#BP5b | +| R3-B9 | The viewport terminal-context guard and semantic terminal helpers still resolved the focused panel instead of the full-window document surface | §1.3, Q#BP14 | +| R3-B10 | `StatuslineEvaluationTarget::Semantic` captures `view.active` transitively; focusing a panel could clear the document statusline and the panel painter had no callback result | §1.3, Q#BP8, Q#BP14 | +| R3-B11 | `QuitAction` had no neutral non-side state and restored only a buffer/action, leaking replacement height or dedication into the prior presentation | Q#BP2, Q#BP2c, Q#BP11b | +| R3-B12 | Ordinary reuse admitted a side window, and side→ordinary fallback could carry panel-only parameters into a document window | Q#BP3 | +| R3-B13 | The non-side invariant fallback attempted to fabricate a document leaf instead of failing closed | Q#BP11a | +| R3-B14 | A horizontal boundary may sit below a subtree, so one divider can span several exposed leaf mode-line segments | Q#BP5, Q#BP5a, Q#BP5b | +| R3-B15 | Falling back from an invalid side-origin jump could duplicate a hidden panel buffer into the document window | Q#BP11c | +| R3-B16 | A frame for old font/scale geometry could arrive after a new declaration; presentation epoch alone cannot detect that race | Q#BP15, Q#BP15a, Q#BP16 | +| R3-B17 | `display_file` resolved a default target before dedup/eligibility, so a dedicated origin could force load-before-failure | Q#BP11b | +| R3-B18 | A creation-only `origin_document` becomes stale after the user enters the panel from another document split | Q#BP2c, Q#BP11a | +| R3-B19 | A semantic panel terminal cannot use full-window `TerminalResize`, and attach `term_sizes` is the wrong 24×80 source | Q#BP7, Q#BP15a | +| R3-B20 | Active-window cursor auto-scroll lives before the per-window paint loop; extracting only the loop leaves a focused panel caret off-screen | Q#BP8, Bet B2' | +| R3-B21 | Terminal's 512-column PTY bound is not a generic panel-grid bound; wide GPU frames can exceed it | Q#BP15, Q#BP15a, Bet B5' | +| R3-B22 | Stage 3 flipped defaults without an explicit current-window opt-out or its own acceptance contract | Q#BP11b, Q#BP12, acceptance | +| R3-rp1 | Do not publish an intentionally inert `no_other_window` API | Q#BP2c, §6 | +| R3-rp2 | Keep raw `switch_buffer` as the dedication escape hatch, but make display policy honor dedication everywhere | Q#BP2c, Q#BP3 | +| R3-rp3 | `origin_document` is implementation-owned, not caller-settable | Q#BP2c, Q#BP11 | +| R3-rp4 | Terminal bell state is per session, but choosing which session to drain is focus-facing | §1.3, Q#BP14b | +| R3-rp5 | The exact base advanced to `b168dcad`; only handoff/ledger documentation changed after `6ed4fe9` | title, §1, §7 | +| R3-rp6 | An unspecified `window.toggle-panel` and focus traversal into a hidden panel are not shippable contracts | Q#BP6, Q#BP11, §6 | +| R3-rp7 | Open PR #148 overlaps the attach/protocol seams this arc must edit | §7 | + +**Bet corrections:** B7' was too syntactic. Searching only literal +`active_window_for` / `active_window` calls missed helpers such as +`active_buffer_id()` that resolve through the same focused window. It is +replaced by a transitive contract. The integration pass then falsified B7'' as +well: `semantic_terminal_key` reaches `view.active` from `src/editor.rs`, and +`StatuslineEvaluationTarget::Semantic` captures it directly in +`src/statusline.rs`. B7''' is now over **all transitive active-context reads +reached by the daemon/semantic projection**, not merely reads spelled in those +two files, plus the explicit surface-routing matrix in Q#BP14b. B2 is narrowed +to B2': the concrete painters are origin-agnostic, but the per-window active +auto-scroll preparation at `src/editor.rs:2883-2935` must be extracted with +them. B5 is narrowed to B5': cell/topology/aggregate wire validation is shared, +but terminal-specific per-axis PTY limits do not apply to a generic panel grid. + +### 0.4 What the post-folding re-scout established (2026-07-24) + +Folding Stage 2 merged as **#149**. Carried forward from rev 2, still true: + +1. **The capability seam exists.** `FrontendView.fold_projection` + (`src/window.rs:348`) is a non-`Default` bool passed explicitly into + `build_fresh_frontend_view` (`src/daemon.rs:2935`) from the attach + transaction (`src/daemon.rs:1769`), where both + `negotiated_capabilities.semantic_render` and `negotiated_protocol_version` + are in hand (`src/presence.rs:74-84`). Q#BP13 copies it. +2. **`DispatchIdle` is the existing optimistic-apply gate** — per frontend, + keyed on `active_window_for(fid)` (`src/editor.rs:753-769` → + `src/daemon.rs:1223-1248` → `pmacs-gpu/src/main.rs:4135`). Rev 2 concluded this + needed no code; round 2 corrected that (Q#BP14a). +3. **The panel band breaks a folding invariant** — `paint_frame`'s per-window + map is built ungated (`src/editor.rs:2991`) on the premise that a semantic + session never enters it. Q#BP17. +4. **No protocol version is reserved.** #148 has now landed as protocol v20; + its final `InstanceMessage` variant is `InitialTargetResult`, while + `TerminalPointer` remains the final `FrontendEvent` variant. Q#BP9. + +`Layout::compute` / `split_node` / `remove_leaf` / `collapse_single_child_splits` +took no folding edits, so Q#BP2/Q#BP2a stand. `Viewport<'a>` carries +`folds: Option<&'a VisibleLineMap>` (`src/view.rs:131-155`) and stays `Copy`. + +### 0.5 Landed #148 + final integration audit — 9 blocking findings, +1 revision point, all closed + +The runtime moved to `0dd16a5` during this review, then canonical `main` +advanced to `ddaa80d` through #152's handoff/ledger documentation only. The new +initial-target transaction directly overlaps attach, target loading, semantic +snapshot publication, and the protocol append point, so the document was +re-scouted against the landed runtime rather than retaining an open-PR +sequencing note. +**B7''' is falsified**: #148 added #21, while the final transitive scan found +the older attach inheritance (#22) and input-side source-window use (#23). +B7'''' is the corrected census bet after integrating them. + +| # | Finding | Closed in | +| --- | --- | --- | +| R4-B1 | A hidden side leaf had durable focus state but no defined effective placement; the requested fixed extent could still steal rows or become flexible | Q#BP2, Q#BP2b, acceptance 7 | +| R4-B2 | #148's semantic replica-publication filter asks whether a peer displays a buffer through its focused window; panel focus can cause both a missed document snapshot and a panel-driven mirror swap | §1.3, Q#BP14, acceptance 43 | +| R4-B3 | Fresh no-target attaches inherit `LOCAL`'s focused buffer; attaching while the TUI focuses a panel would make that panel the new frontend's document | §1.3, Q#BP13, Q#BP14, acceptance 51 | +| R4-B4 | #148's private target loader reselects `view.active` after hooks; once hooks can create/select a panel, bootstrap could overwrite it instead of reasserting the requested document | Q#BP11b, acceptance 55 | +| R4-B5 | Omitted `height`/`dedicated` semantics were undefined across same-presentation redisplay, replacement, and creation | Q#BP3, acceptance 13 | +| R4-B6 | Recursive `QuitAction::Restore` history grew without bound under repeated panel replacement | Q#BP2c, acceptance 20 | +| R4-B7 | Wire/client documentation still defines semantic `CursorByte`/mirror state as the focused “active buffer”; after panel focus that name means the primary document surface, not input focus | Q#BP14, acceptance 42 | +| R4-B8 | A panel-owned `SearchPrompt` names the panel buffer, but the GPU currently displays prompts only when their buffer matches the document mirror; frame/chrome ordering and validation were undefined | Q#BP14b, acceptance 45 | +| R4-B9 | New geometry/drag/move events omitted the GPU outbox's bounded tail-coalescing contract, so a stalled daemon could turn normal resize/pointer traffic into lossless-queue overflow | Q#BP15a, Q#BP16, acceptance 47–48 | +| R4-rp1 | The focus census also omitted remote-CRDT source-window cursor/provenance application even though its classification remains Focus | §1.3, Q#BP14a | + +### 0.6 Pre-implementation dependency verification (2026-07-24) — folding +cleared, 1 correction + +Run against canonical `main` @ `ddaa80d` before branching Stage 1. + +**The folding dependency is cleared.** #149 (`6ed4fe9`) and its landed-doc +refresh #150 (`b168dca`) are both ancestors of `ddaa80d`; no PR is open; the +retained `folding` and `folding-tui` branches carry zero commits beyond +`githubsucks/main`; and Stage 3 has neither a branch nor a framing +(`docs/active-work.md`). `cargo test --test folding_stage2_acceptance` is +48/48 green on this base. Every anchor this document borrows from the arc +reproduces: `fold_projection` (`src/window.rs:348`, non-`Default`), its attach +install (`src/daemon.rs:1769`), `build_fresh_frontend_view` +(`src/daemon.rs:2935`) and its `LOCAL`-active inheritance (`:2949-2958`), the +ungated per-window map in `paint_frame` (`src/editor.rs:2991`), the +active-frontend gate behind `fold_map_for_window` +(`src/editor_core.rs:566` → `fold_projection_active` `:549-551`), and the +`Copy` `Viewport<'a>` with `folds` (`src/view.rs:130`, `:155`). Q#BP17's stale +comment is at `src/window.rs:339-340`. Folding's entire `src/window.rs` diff was +one 22-line hunk at `:324`, and nothing has touched that file since `6ed4fe9`, +so `compute` / `compute_node` / `split_node` / `remove_leaf` / +`collapse_single_child_splits` remain pre-folding code and Q#BP2/Q#BP2a stand +unchanged. The only surviving coupling is forward and non-blocking: Stage 1 has +no folding surface at all, and if folding Stage 3 flips `fold_projection` true +for semantic sessions before this arc's Stage 2 lands, Q#BP17's "pass `None`" +becomes "pass that window's map". + +| # | Finding | Closed in | +| --- | --- | --- | +| R5-B1 | `Layout::compute` has **two** production callers, not one; the second (`src/overlay_paint.rs:112`) derives its own text-area `Rect` and never consults `window_placements`, so the Q#BP2 signature change would leave peer-cursor overlays on unfixed geometry | §1.1, Q#BP2, acceptance 1 | + +## 1. Ground truth (re-scouted 2026-07-24 against canonical `main` @ +`ddaa80d`; runtime @ `0dd16a5`) + +### 1.1 What already exists + +- **A real window tree, per frontend.** `LayoutNode::{Leaf, Split{orientation, + weights, children}}` (`src/window.rs:283`); `FrontendView { layout, active, + fold_projection }` (`:320`); all windows in one flat `core.windows`. +- **Geometry is purely proportional.** `compute_node` (`src/window.rs:435`) + divides by weight, last child takes the remainder; **zero extents are an + explicitly permitted outcome on a tiny frame** (`src/window.rs:362-365`). + `compute` (`src/window.rs:367`) has **two** production callers (R5-B1): + `window_placements` (`src/editor.rs:2359`, calling at `:2372`), and the + peer-presence overlay pass (`src/overlay_paint.rs:112`), which builds its own + text-area `Rect` from `core.active_layout()` and never routes through + `window_placements`. The remaining `compute` calls (`src/editor.rs:6363`, + `:6783`, `:6788`) are inside the `cfg(test)` module opening at `:4098`. + `compute` is the only producer of window rectangles; every other layout + consumer (`statusline.rs`, `desktop.rs`, `editor_core.rs`, `lua_bindings`) + reaches the tree through `iter_ids` / focus traversal and needs no fixed-extent + argument. +- **`WindowPlacement { outer, content }`**, `content = outer` minus the mode + line (`src/editor.rs:2352`); frame area is `rows - 1`. +- **The mode-line row is reserved in the mouse path** — `window_at_cell` + (`src/editor.rs:2391`), early return at `local_row >= inner_rows` + ("Mode-line click: reserved.", `:1865`); `MouseClickState` (`:204`). +- **`paint_frame` is a per-window loop** (`src/editor.rs:2937`) through an + origin-agnostic `Viewport<'a>` (`:3008`); one fold map per rendered window + (`:2991`). +- **The terminal controller is keyed on the window** (`src/terminal/view.rs:19`, + `:86`, `:105`); `active_terminal_key` reads `view.active` + (`src/editor.rs:989`). +- **Terminal scroll is anchor-based**, `selection_froze_top` + (`src/terminal/view.rs:360`, `:422`), `view_geometry` (`:625`), + `record_view_size` (`:273`), controller-only PTY resize + (`src/editor.rs:1234-1249`). +- **Attach-time capability plumbing is proven** (`src/presence.rs:74-84`, + `peer_declared_terminal_support` `src/daemon.rs:888`, folding's install at + `src/daemon.rs:1769`). +- **The GPU has a bottom band and renders a foreign cell grid** + (`pmacs-gpu/src/main.rs:395-402`, `:1574-1609`, `:6670-6683`; + `TerminalFrame` + `pmacs-protocol/src/terminal.rs:103`, planner `pmacs-gpu/src/terminal.rs`). + +### 1.2 What does not exist + +- **No placement policy** — `pmacs.window` (`src/lua_bindings/mod.rs:12167`) + acts only on the active window; no `display`, `pop_to_buffer`, `quit_window`. +- **No window parameters** on `Window` (`src/window.rs:158`). +- **No divider drag, no keyboard resize**, in either frontend. +- **No `CursorIcon` / `set_cursor` in `pmacs-gpu/`.** +- **No `WindowId` in `pmacs-protocol/`.** +- **No `MIN_WINDOW_*` constant, no `window.min-height`.** +- **No general target-aware load.** #148 added the private attach-only + `open_initial_target` transaction (`src/daemon.rs:1625-1677`) and the + side-effect-free `EditorCore::get_or_load_buffer` seam + (`src/editor_core.rs:660-684`), but the former still switches + `view.active` before and after hooks. The public + `pmacs.buffer.find_or_open` likewise switches the **active** window in both + branches before firing `buffer.after-switch` / `after-load` + (`src/lua_bindings/mod.rs:3089`, `:3108`, `:3113`). Neither accepts an exact + destination window. +- **No way to open a terminal off-active.** `pmacs.terminal.open` hardwires + `switch_active_buffer_for(frontend_id, …)` into that frontend's active window + (`src/lua_bindings/mod.rs:8500`), and rolls the session back if it fails. + Compile creates its buffer then `switch_buffer`s (`compile.lua:263`, `:808`). + +### 1.3 The active-context census (R2-1, R2-2, R3-B1) — every consumer, +transitively classified + +Round 2 found the literal active-window consumers. Round 3 found the remaining +trap: `active_buffer_id()` is itself an active-window read, so a census of only +the spelling `active_window*` is not exhaustive. The contract is therefore over +**every transitive read of the focused window/buffer** in the daemon and +semantic producer, including helper calls. + +The bounded production scope is: reads that choose an attached frontend's +semantic document messages/alignment, focus chrome, snapshot routing, attach +inheritance, presence/bell surface, or optimistic-input acceptance/application +in `src/daemon.rs`, `src/semantic_render.rs`, and `src/statusline.rs`, plus +their named `src/editor.rs` helpers. Ordinary grid per-window painting and +normal key/mouse command semantics are excluded—they already operate on the +real window that owns them—as are test-only reads. + +| # | Consumer | Site | Class | +| --- | --- | --- | --- | +| 1 | Semantic buffer-follow + `BufferSnapshot` re-send | `src/daemon.rs:1133-1149` | **Projection** | +| 2 | Lazy CRDT upgrade + replica broadcast | `src/daemon.rs:1096`, `:2319-2343` | **Projection** | +| 3 | `CursorByte` | `src/daemon.rs:1418-1428` | **Projection** | +| 4 | `LineNumbers` mode | `src/semantic_render.rs:1350` | **Projection** | +| 5 | Selection decorations | `src/semantic_render.rs:1706` | **Projection** | +| 6 | Terminal-frame suppression of the document pass | `src/semantic_render.rs:610` | **Projection** | +| 7 | `Viewport` alignment | `src/daemon.rs:2020` → `align_semantic_window_to_buffer` `:2900` | **Projection** (must not move focus) | +| 8 | Document `Pointer` | `src/daemon.rs:2085` → same helper | **Projection + focus** | +| 9 | `Viewport` terminal-context gate | `src/daemon.rs:2002-2009` | **Projection** — a terminal panel must not suppress a document viewport | +| 10 | Full-window semantic terminal declaration/snapshot/sync | `src/daemon.rs:2026-2046` → `semantic_terminal_key`, `src/editor.rs:1157` | **Projection** — these describe the primary document surface, never the panel band | +| 11 | Full-window `TerminalPointer` | `src/daemon.rs:2048-2067` → `dispatch_semantic_terminal_pointer`, `src/editor.rs:1276` | **Projection + focus** — a non-hover gesture on the document terminal takes document focus | +| 12 | Semantic statusline target capture | `src/semantic_render.rs:628` → `capture_target_contexts`, `src/statusline.rs:634` | **Projection + panel projection** — document segments use the primary document; panel segments paint in its mode line | +| 13 | Remote CRDT-op validation | `src/daemon.rs:2531-2537` | **Focus** | +| 14 | `dispatch_idle_for` | `src/editor.rs:753-769` | **Focus** | +| 15 | Presence snapshot (peer cursor broadcast) | `build_presence_snapshot`, `src/daemon.rs:2988-3000` | **Focus** — it answers "where is this user working", which *is* the panel when the panel is focused | +| 16 | `SearchPrompt` active-buffer gate | `src/semantic_render.rs:1106` | **Focus chrome** — the prompt follows the modal session; match washes paint on its owning window | +| 17 | `MenuPrompt` active-buffer gate | `src/semantic_render.rs:1164` | **Surface-routed** — a document menu is semantic chrome; a panel menu is painted in `PanelFrame`; the other surface receives a clear | +| 18 | `MinibufferPrompt` active-buffer gate | `src/semantic_render.rs:1220` | **Focus chrome, global** — bufferless and emitted independently of the document viewport | +| 19 | `CompletionPopup` active-buffer/window gate | `src/semantic_render.rs:1027`, `:1030` | **Surface-routed** — a document popup is semantic chrome; a panel popup is a window overlay in `PanelFrame`; the other surface receives a clear | +| 20 | Terminal bell drain | `take_pending_terminal_bell`, `src/daemon.rs:1565-1600` | **Focus/session** — the counter is per session, but the active window chooses which session may drain | +| 21 | Semantic recipient filter for lazy/initial-target `BufferSnapshot` publication | `publish_buffer_snapshot_to_replicas`, `src/daemon.rs:2441-2475` | **Projection** — “displays this buffer” means the peer's primary document surface | +| 22 | Buffer inherited by a fresh no-target frontend view | `build_fresh_frontend_view`, `src/daemon.rs:2949-2958` | **Projection** — inherit `LOCAL`'s primary document, never its focused panel | +| 23 | Remote CRDT-op source-window cursor/provenance application | `handle_remote_crdt_op`, `src/daemon.rs:2735-2797` | **Focus/input** — the validated op applies to the source's actually focused window | + +**Why #2 is the sharpest.** Focusing a *fresh generated* panel buffer triggers +the lazy CRDT upgrade, which **broadcasts a `BufferSnapshot` to every replica** +(`src/daemon.rs:1096-1107`) and records it in `last_active_buffer_sent`. That +swaps the GPU's mirror to the panel buffer — directly contradicting rev 2's +acceptance 34. It is not reachable from rev 2's three-coupling model at all. + +**Why #9–#12 are separate from ordinary document projection.** The viewport +gate, terminal declaration/key, terminal pointer, and statusline target all +reach focused-window state outside the main `render_frame` buffer producers. +If left unchanged, a focused terminal panel rejects the still-visible document +viewport, a full-window document terminal cannot repaint or receive a click, +and `DeclaredBufferMismatch` clears the document's statusline. The document +terminal events and document statusline must resolve the primary document +window, while the panel gets its own `PanelPointer` and painted mode line. + +**Why #13 and #23 forbid an opt-out.** Remote-op validation requires the op's +`buffer_id` to equal the **source's active window buffer** +(`src/daemon.rs:2531-2537`), and the accepted-op path applies cursor/provenance +to that same source window (`:2735-2797`). If a panel is focused while the GPU +still optimistically edits its document mirror, every resulting op is rejected +— silently diverging the mirror. Optimistic apply and daemon input must agree +on *one* window. + +**Why #16–#19 cannot inherit the document viewport.** `render_frame` invokes +all four producers with `vp.buffer_id` (`src/semantic_render.rs:800-807`), but +their current guards compare it with `core.active_buffer_id()`. With a panel +focused, a panel-opened `M-x` emits no `MinibufferPrompt`; search/menu chrome +can disappear; and a document completion popup can remain stuck because the +producer returns before emitting its authoritative close. Q#BP14b splits +per-window overlays from global/native semantic chrome and makes both open and +clear paths explicit. + +**Why #21 and #22 are projection even though they sit outside +`render_frame`.** #148 publishes a target/upgraded snapshot to an existing +semantic peer only when that peer “displays” the buffer. Testing the focused +panel would miss a buffer visible in the document or replace the GPU mirror +because only the panel showed it. A fresh no-target attach has the same +surface question when it clones `LOCAL`: panel focus must not turn panel +content into the new frontend's full document. Both therefore use +`primary_document_window`, not focus. + +## 2. What ships (staged) + +- **Stage 1 — window placement + TUI side windows. No wire change** (inherits + the protocol version on its eventual base). +- **Stage 2 — the GPU panel band. Next available protocol version.** Own + re-framing before implementation. +- **Stage 3 — default placement flip**, after Stage 2 (Q#BP12). + +## 3. Decisions + +### Q#BP1 — A panel is a WINDOW, not a new kind of slot *(confirmed round 1)* + +Side windows are ordinary leaves in `Layout`, carrying parameters. +`TerminalController` is keyed `(frontend_id, window_id)` and +`active_terminal_key` reads `view.active` (`src/editor.rs:989`), so child-input +routing, the fixed `C-c` escape, atomic controller replacement, and +release-on-blur need no new machinery — **this is the whole of B1 now** (round 2 +correctly removed the input-gating half; see Q#BP14a). A non-window slot would +need a second copy of the controller model plus per-window overlays, gutter, +statusline, mouse routing, selection, and desktop handling. + +### Q#BP2 — Window parameters; fixed extents; the recursive minimum (rp-1, rp-5) + +```rust +pub struct WindowParams { + pub side: Option, // immutable after placement (Q#BP2a) + pub fixed_rows: Option, // outer rows, incl. the mode line + pub dedicated: bool, + quit_action: Option, // implementation-owned; None off-side + origin_document: Option, // implementation-owned; read-only to Lua +} + +pub enum QuitAction { + Delete, + Restore { + buffer_id: BufferId, + fixed_rows: u32, + dedicated: bool, + cursor: Position, + view_top: usize, + goal_col: Option, + selection: Option, + then: Box, + }, +} +``` + +`Layout::compute(area)` → `Layout::compute(area, fixed: &HashMap)`. **Both** production callers supply the map (R5-B1): `window_placements` +(`src/editor.rs:2372`) and the peer-presence overlay pass +(`src/overlay_paint.rs:112`). The second is easy to miss because it derives its +own text-area `Rect` from `core.active_layout()` instead of reusing +`window_placements`; leaving it on unfixed geometry would paint every peer +cursor at the row it would occupy with no panel open. Since both callers need +the same `HashMap`, the fixed map is derived by one shared +helper over the frontend's side windows rather than assembled at each call +site. Two-pass inside a split: subtract fixed children, then divide the +remainder by weight among flexible children (preserving +last-flexible-takes-the-remainder). + +**The minimum is recursive (rp-1).** Rev 2's "leave the document subtree two +rows" is wrong: two rows at the root does not give each nested leaf two rows. +Define, over row extent: + +``` +subtree_min_rows(Leaf) = MIN_WINDOW_OUTER_ROWS // 2 +subtree_min_rows(Split{Horizontal, kids}) = Σ subtree_min_rows(kid) +subtree_min_rows(Split{Vertical, kids}) = max subtree_min_rows(kid) +``` + +(Horizontal splits stack rows, so minima add; vertical splits share rows, so the +tallest child governs.) + +**And the promise is bounded (rp-1).** The layout **already permits zero +extents** on an intrinsically tiny frame (`src/window.rs:360`) and renderers +already skip empty rects — rev 2's "the layout can never violate the floor" was +too strong. The honest contract: **the panel allocator never makes an otherwise +satisfiable document tree unsatisfiable.** Formally, the panel takes +`min(fixed_rows, area.rows.saturating_sub(subtree_min_rows(document_root)))`, +and if that is below `MIN_WINDOW_OUTER_ROWS` the panel is **hidden** (Q#BP2b). +What the frame does to a document tree that could not fit anyway is unchanged +behavior. + +`MIN_WINDOW_OUTER_ROWS = 2` (one text row + one mode line, since `content = +outer - 1`) is a structural floor. Every programmatic source of `fixed_rows` +(`height`, `window.panel-height`, and `set_params`) clamps a nonzero request to +that floor; a request of `0` is rejected rather than being an invisible +"open". A frame shrinking under the floor hides the panel, but a caller asking +for one row on a large frame gets a two-row panel. Side creation resolves an +omitted height through `window.panel-height`, so a live side leaf always has +`fixed_rows = Some(requested_rows)`; `None` remains the ordinary-window value. + +**Hidden has an exact effective geometry.** The requested `fixed_rows` remains +stored, but `window_placements`/`Layout::compute` receives the reconciled +effective state: while `panel_hidden`, the side leaf receives an empty rect and +the prior document root receives the full frame area (minus the one global +status row), as if the wrapper's side child consumed zero rows. The side leaf, +wrapper, weights, `WindowId`, and requested extent remain intact. It must not +fall through as a flexible child, and the requested fixed extent must not +continue stealing rows while hidden. + +`window.min-height` is a **user preference clamped into +`[MIN_WINDOW_OUTER_ROWS, …]`** that applies only to *interactive* resize (drag, +keyboard, and GPU `PanelResizeRows`). Define a second recursion with the same +sum/max shape: + +``` +interactive_min_rows(Leaf) = window.min-height +interactive_min_rows(Split{Horizontal, kids}) = Σ interactive_min_rows(kid) +interactive_min_rows(Split{Vertical, kids}) = max interactive_min_rows(kid) +``` + +Each leaf resolves the setting against that window's current `buffer_id` +(buffer-local override → global → default), and one gesture snapshots the +result before changing geometry. Side creation similarly resolves +`window.panel-height` against the buffer being displayed. + +Interactive boundary motion preserves the preferred minimum on **both** sides +when the current frame can satisfy it; if the frame is already smaller, the +motion may not make either side worse. The ordinary layout pass and +frame-resize reconciliation consult only `subtree_min_rows`, so changing a +preference never invalidates an existing layout. + +### Q#BP2a — Side-window topology (R1-4) + +- **At most one bottom-side leaf per `FrontendView`.** +- Installed as the **final child of a root-level horizontal split wrapping the + entire prior root**: `root := Split { Horizontal, weights: [1, 1], + children: [, Leaf(panel)] }`. `fixed_rows` makes the panel's + weight inert; the prior root takes the flexible remainder. +- **Closing collapses the wrapper** via `collapse_single_child_splits` + (`src/window.rs:537`) once `remove_leaf` drops the panel — no new tree code. +- **`fixed_rows` is interpreted only on that root-level side child**; elsewhere + it is inert (Q#BP2's `fixed` map is built from side windows only). +- **`side` is immutable after placement.** `set_params` rejects adding, + changing, or clearing it. Transactional rehoming is deferred by name. + +### Q#BP2b — Hiding is a durable state transition, not a per-frame effect (rp-2) + +Rev 2 said a hidden panel "hands focus to a document window for that frame". +That is a render-time dodge: keys would still route to an invisible window, and +the terminal resize path merely returns on zero content **without releasing the +controller** (`src/editor.rs:1118`). Geometry currently lives outside +`EditorCore` — the local loop reads `frontend.size()`, while the daemon owns +`term_sizes` — so "wherever layout is recomputed" is not a mutation seam. + +Each `FrontendView` therefore gains +`frame_geometry: Option` and `panel_hidden: bool`, where +`DeclaredFrameGeometry = { geometry_epoch: u64, total: CellSize }`. `None` +means **unknown**, not 24×80. Grid/LOCAL views cache their real attach/resize +size with an internal epoch; a semantic view stays `None` until its first +authenticated `FrontendCellGeometry` in Stage 2 (Q#BP15a). + +`EditorState::reconcile_panel_layout(frontend_id)` is the single idempotent +mutable transaction. It runs after attach/resize, display/split/close, +`fixed_rows`/setting changes, and any Lua hook or callback transaction that can +mutate the layout (including statusline evaluation); it also runs defensively +before final-focus resolution, input dispatch, terminal sync, and paint. Thus +two events drained in one burst cannot route the second event to a panel the +first event made invisible, and a render callback cannot leave stale panel +geometry for the painter. The transaction: + +1. If there is no live side window, set `panel_hidden = false`; Stage 2 records + and emits `Absent` as needed, clears presentation input authority, and + returns. `panel_hidden` never describes a panel that no longer exists. +2. Compute the panel's allocation per Q#BP2. Unknown geometry or + `frame_geometry.total.cols == 0` is not presentable and follows the hidden + arm; no zero-width `Present` frame is legal. Install Q#BP2's effective + empty-side/full-document placement for the hidden state rather than feeding + the stored request to ordinary fixed/flexible allocation. +3. If it is below the floor and the panel is currently visible → mark + `panel_hidden = true`; if `view.active` is the panel, **set `view.active` to + the non-side target** (Q#BP11a); **release the terminal controller** for + that view key (`release_controller`, the existing call at + `src/editor.rs:1113`). +4. If it becomes satisfiable again → `panel_hidden = false`, restore its + allocation from the still-stored requested `fixed_rows`. **Focus is not + restored** — the user moved on; `C-x o` returns. +5. Stage 2: a hide or unhide emits `PanelFrame::Absent` / a fresh `Present` + authoritatively (Q#BP15). + +`panel_hidden` is cached derived layout state on the `FrontendView`, not a +`WindowParams` field. It is recomputed from authoritative geometry and must +never be persisted or set by Lua. + +### Q#BP2c — Parameter semantics the API must pin (rp-5) + +- **`origin_document: Option`** is the remembered document window + Q#BP11a needs; rev 2 described it but omitted it from the struct. Recorded at + panel creation, then refreshed on every focus transition from a non-side + window into the panel (keyboard, pointer, or selecting display). + Panel→panel redisplay and passive display do not overwrite it. + It is **revalidated on every use** (live, in this frontend's layout, + non-side) and cleared when it fails. It is implementation-owned: + `params(win)` may report it for diagnostics, but `set_params` rejects it. +- **`no_other_window` does not ship in v1.** A public parameter that is stored + and deliberately ignored is a false contract. The whole parameter and its + traversal semantics are deferred. If added later, traversal filters it only + as a **destination**; a currently focused no-other window can always leave, + so the caller cannot strand focus. +- **`dedicated` binds `display_buffer` only.** Raw `pmacs.window.switch_buffer` + and `switch_active_buffer_for` **ignore it**: they are the deliberate + low-level escape hatch, and every existing caller predates this arc. + `display_buffer` is the policy layer and checks dedication on **every** + candidate, not only the side slot (Q#BP3); making the primitive enforce + policy would change existing behavior silently. +- **Quit restoration preserves replacement history.** `QuitAction` is + present only on a side window. Creating the side installs `Some(Delete)`; + ordinary windows and every fallback carry `None`. Replacing a side + presentation captures its buffer, requested height, dedication, cursor, + viewport/goal/selection state, and prior action in `Restore`. Restoring + rebuilds `TextView`, clamps the saved positions against the buffer's current + contents, reinstalls `then`, and fires the normal switch hook so overlays + reattach. Derived `last_visible_rows` and trait-object overlays are never + snapshotted. Thus C→B→A→delete restores the actual presentation rather than + forgetting A or leaking C's height/dedication into it. A killed restore + buffer still fails closed to `Delete` (Q#BP10a), dropping the unusable chain. + `MAX_PANEL_QUIT_DEPTH = 64` bounds the recursive history: before wrapping an + existing action, count iteratively; if the new depth would exceed the cap, + truncate the oldest retained `Restore` by replacing its `then` with + `Delete`. The newest 64 presentations therefore remain LIFO-restorable and + the following quit closes the slot. Construction, traversal, and truncation + never recurse past the same bound. +- **The two bookkeeping fields are read-only.** `params(win)` may expose + `origin_document` and a diagnostic description of `quit_action`; + `set_params` rejects both. Lua cannot forge a window id, buffer restore + chain, or stale cursor state. `window.quit` on a window with + `quit_action = None` returns a pointed error without closing or switching + anything; non-side adopter fallbacks call their existing restore path + instead. +- **Capability fallback discards every side-specific parameter.** When + `!panel_capable` (Q#BP13), `display_buffer` drops `side`, `fixed_rows`, + `dedicated`, `quit_action`, and `origin_document`, and displays into the + non-side target as an ordinary buffer switch. A fallback must never dedicate, + pin, or otherwise poison the primary document window. + +### Q#BP3 — `display_buffer`: the placement policy + +Placement affinity precedes generic reuse; otherwise a persistent compilation +buffer already visible in a document window makes `{side = "bottom"}` silently +ignore its requested placement. `action.window` and `action.side` are mutually +exclusive; supplying both is an error. `height` requires a side request or an +exact target that is already the side window. Stage 1 accepts only +`Side::Bottom`; every other side value is a pointed unsupported error, not an +ordinary fallback. + +1. **Exact target (`action.window`).** Validate that it is live and belongs to + this frontend. Use that exact window or error; generic reuse may not + substitute another. A target dedicated to a different buffer errors. +2. **Side target (`action.side`).** + 1. Reuse a window on the requested side already showing `buffer_id`. + 2. Otherwise use that side slot if absent or not dedicated to another + buffer, creating it per Q#BP2a when absent. + 3. If the one side slot is dedicated to another buffer, never create a + second one: fall back to the ordinary non-side policy below **after + discarding `side`, `height`, `dedicated`, and quit bookkeeping**. Only an + explicitly supplied `select` survives. A failed placement request may + not pin or dedicate a document window. + A non-side window already showing the buffer **does not preempt** a usable + requested side slot; displaying the same buffer in two windows is legal and + avoids the deferred rehoming problem. +3. **Ordinary target (no usable exact/side target).** + 1. Reuse a visible **non-side** window on this frontend already showing + `buffer_id`. An ordinary display never selects the panel by coincidence. + 2. Otherwise use the first candidate from Q#BP11a that is not dedicated to a + different buffer. Continue in `iter_ids()` order when the preferred + document target is dedicated. + 3. If no eligible non-side window exists, return a pointed error; do not + overwrite a dedicated window or create an unrequested split. + +After target resolution, an omitted `action.select` defaults to **false** for +an actual side target and **true** for an ordinary target. An explicit +`select` survives fallback unchanged. Placement and reuse are strictly per +frontend. + +`height` and `dedicated` are option-valued at the policy boundary; omission is +not silently equivalent to an explicit zero/false: + +- creating the side slot uses `window.panel-height` when `height` is omitted + and `dedicated = false` when dedication is omitted; +- redisplaying the **same continuously presented buffer** preserves its + current requested height, dedication, and quit action unless an explicit + value changes the first two; +- replacing the buffer in an existing usable side slot preserves its current + requested height when `height` is omitted, but the new presentation defaults + to `dedicated = false`; an explicit dedication applies only after the old + presentation passed eligibility and cannot be used to clear-and-bypass an + existing dedication in the same call; +- an ordinary/exact non-side replacement defaults to undedicated, while a + same-buffer redisplay preserves existing dedication unless explicitly + changed. + +These rules let a user-resized panel keep its height as compile/listview +replace one another, prevent a harmless same-buffer redisplay from unpinning a +window, and still make every adopter's newly installed presentation +undedicated by default. + +### Q#BP4 — The display transaction and the final-focus matrix (R2-4, rp-3) + +Rev 2's Phase 2 *always* restored `saved_active`, which erases `select = true` +outright, and restored only when the saved window was non-side, so +`select = false` from a live focused panel blurred it. Both are wrong. + +**Phase 1 (core, no Lua).** `EditorCore::display_buffer` chooses the target +(Q#BP3/Q#BP11a), installs the buffer, records `saved_active: WindowId`, returns +`DisplayOutcome { target, saved_active, select, fire: HookKind }` where +`HookKind ∈ { AfterSwitch, AfterLoad, None }`. + +**Phase 2 (the Lua-owning layer).** Activate `target`, fire the hook, run +`reconcile_panel_layout(frontend_id)` (hooks may resize, close, or replace the +target), then **revalidate both ids** against `core.windows`, the frontend's +`layout.iter_ids()`, and `panel_hidden`, and apply: + +| `select` | `target` after hooks | `saved_active` after hooks | Final focus | +| --- | --- | --- | --- | +| `true` | live + visible | — | **`target`** | +| `true` | dead or `panel_hidden` | live + visible | `saved_active` | +| `true` | dead or `panel_hidden` | dead or `panel_hidden` | non-side target rule | +| `false` | — | live + visible (**side or not**) | **`saved_active`** | +| `false` | live + visible | dead or `panel_hidden` | `target` | +| `false` | dead or `panel_hidden` | dead or `panel_hidden` | non-side target rule | + +Two corrections encoded here: `select = true` **keeps the target selected**, and +`select = false` restores a saved window **even when it is the panel** — a +passive display invoked from a focused panel must not blur it. "Visible" means +not `panel_hidden` per Q#BP2b. + +The hook-failure arms are tested in **both** `select` modes (acceptance). + +### Q#BP5 — The divider is a general split-boundary drag (sub-problem 1) + +`window_at_cell` maps a mode-line row to the window above it and `dispatch_mouse` +already reserves that row (`src/editor.rs:1865`). We spend that reservation. + +- A leaf's outer bottom row is a **drag handle** when it is an exposed segment + of a horizontal ancestor boundary. If that ancestor's upper child is a + vertical/nested subtree, every leaf segment touching the same bottom edge + paints and resolves to the **same boundary**; dragging any segment has the + same result. +- `Down` arms `WindowDragState { frontend_id, boundary, start_row, + start_extents }` beside `MouseClickState`; `Drag` recomputes — `fixed_rows` + when one side is fixed, weights when both are flexible — clamped by Q#BP2's + **interactive** recursive minimum when satisfiable, and never worsening an + already-unsatisfied side; `Up` disarms. Selection is untouched. +- A flexible pair writes weights (ratio survives a terminal resize); a side + window writes `fixed_rows` (absolute height survives). That difference is the + point. +- **No pointer-shape change in the TUI** (`OSC 22` is xterm-only) — deferred by + name. The affordance is `ui.divider` (Q#BP5a) plus keyboard parity (Q#BP5b). + +### Q#BP5a — Where the divider actually is (R1-7) + +- **TUI.** The divider is the upper subtree's exposed existing mode-line + segment(s). No row is added or consumed; every adjacent leaf segment along + that boundary renders with the reserved theme face **`ui.divider`** + (`src/highlight.rs:234-248`) plus a handle glyph. The root panel divider is + therefore full width even when the document subtree ends in several columns. + **`fixed_rows` excludes it.** +- **GPU.** The projected panel grid holds the panel window's rows and **its own + bottom mode line** — not the document's mode line, which is not part of the + panel window. So the GPU **paints its own divider chrome**: a `ui.divider` + rule of `BASE_DIVIDER_HEIGHT`, **frontend-local, outside the projected grid + and outside `fixed_rows`**, exactly as the status band is chrome outside the + document. The drag hit strip is that rule. + +**The daemon is authoritative for rows**: the GPU converts pixels to rows and +sends rows (Q#BP15a), never the reverse. + +### Q#BP5b — Keyboard resize, boundary resolution, and `resize(win, …)` (rp-4) + +`window.enlarge` (`C-x ^`) / `window.shrink` (`C-x C-^`) act on the **active** +window. `pmacs.window.resize(win, delta_rows)` resolves from the **supplied +`win`** — the Lua entry point is explicit, the commands are implicitly active. +Both resolve the boundary identically: + +1. Active/supplied window is a **side window** → its own fixed boundary. +2. Otherwise → walk up from the leaf to the **nearest horizontal-split ancestor + at which the path child has a following sibling**, and move that boundary. + (Rev 2 said "nearest horizontal ancestor", which is wrong when the subtree is + that ancestor's *final* child — there is no boundary below it there.) +3. No such ancestor → report "no adjustable horizontal boundary", no-op. + +Rule 2 moves the same boundary a drag on that window's bottom mode line moves — +that identity is an acceptance case, tested in a nested layout where the naïve +"nearest horizontal ancestor" reading picks the wrong one. All three +interactive entry points share the Q#BP2 preference clamp; programmatic +`display(..., {height = ...})` uses only the structural floor. + +### Q#BP6 — Focus, child input, and the window guards + +Input needs no new code (Q#BP1). The **guards** do: + +- `close_active` (`src/editor_core.rs:2349`) must refuse **only when the target + is the last non-side window**. Closing the side window itself is always legal, + including as the only other window. +- `close_others` from a document window also deletes the panel; from a **side + window it errors**. `split_active` from a side window errors. +- `focus_next/prev` include a side window only while it is visible. A hidden + panel is never a focus destination; after it reappears, traversal reaches it + normally (Q#BP2b/Q#BP2c). + +### Q#BP7 — Panel height vs scrollback (sub-problem 3) + +**Invariant: a height change is a viewport change, never a scroll change.** +`top` is preserved verbatim. Not "preserve the bottom row" — that fights +tail-follow. + +1. **Growth reaching the live tail re-arms follow** (`top` → `None`) — **only + when no selection is active** (R1-8). `selection_froze_top` + (`src/terminal/view.rs:360`, `:422`) and `view_geometry` (`:625`) already + encode the freeze; the re-arm goes in the **shared viewport-size path** + (`record_view_size` / `snapshot_for_view`) so grid and semantic declarations + agree. +2. **Shrink to zero never happens**: Q#BP2's clamp plus Q#BP2b's hide, and + `record_view_size` already fails closed (`src/terminal/view.rs:273-296`). +3. **Only the controller resizes the PTY** (`src/editor.rs:1234-1249`); two + frontends may hold different panel heights over one child. Pinned, not + "fixed". +4. **A semantic panel terminal sizes from the panel content rect.** At the + existing pre-child-drain terminal-sync point, the daemon resolves the + visible side window against `frame_geometry`, derives + `(fixed_rows - mode_line) × total.cols`, records that exact + `TerminalViewKey` size, and resizes the PTY only if it is the controller. + It never consumes the GPU attach `term_sizes` placeholder or the + full-document `TerminalResize` declaration. + +### Q#BP8 — The GPU panel band (Stage 2) + +The daemon projects the frontend's bottom side window into a standalone cell +grid; the GPU paints it as a band above the status band and shrinks its text +area by band + divider height. The panel projection is a sibling pass: it runs +independently of whether the primary document has declared a byte viewport or +is in full-window terminal mode, so neither existing early return can suppress +the band. + +- **Extraction, not new rendering**: `paint_frame`'s per-window body already + paints one window into a `CellGrid` through an origin-agnostic `Viewport<'a>` + (`src/editor.rs:2937-3040`); painting into a panel-sized grid at `(0,0)` is + that body lifted out. The extraction also takes the active-window + cursor-visible preparation currently just before the loop + (`src/editor.rs:2883-2935`): it runs for the panel only when that window owns + focus, uses the same supplied fold map, and leaves passive `view_top` + untouched. No concrete text/gutter/overlay/modeline painter forks (Bet B2'). + `pmacs-gpu/src/terminal.rs` is already a pure cell-space planner for this + payload shape. +- **The extraction boundary is per-window, not per-frame.** Text, gutter, + selection, mode line, and window-attached overlays (including a panel's + `SearchView` / `MenuView` / `CompletionView`) enter `PanelFrame`. The + frame-global status row, search prompt, and minibuffer do not; semantic focus + chrome carries those surfaces per Q#BP14b. +- **Statusline callbacks still run once.** Generalize the existing + `StatuslineEvaluationTarget::Grid` fan-out into a frontend-layout target: + the grid target keeps today's layout-leaf fan-out but omits a derived-hidden + side, while a semantic panel target captures exactly the primary document + window plus its visible side window (unprojected document splits do not run + callbacks). Evaluate before paint, then transactionally revalidate as today. + Route the primary document result to semantic `StatuslineSegments` and the + side result to the panel mode line. A callback mutation runs Q#BP2b + reconciliation before either result is consumed, and an invalidated + evaluation paints no stale text. This closes the indirect `view.active` read + at `src/statusline.rs:634` without evaluating a provider twice. +- **One transport for every panel kind** — a terminal panel is painted + daemon-side by the same `paint_terminal_snapshot` the TUI uses. +- **Accepted consequence**: panels are **monospace cell grids** in the GPU. + Documents keep the rich renderer. +- **Document declarations follow the installed band.** Applying + `Present`/`Absent` recomputes the GPU document clip and emits the ordinary + document `Viewport` or full-window `TerminalResize` if its effective size + changed. `FrontendCellGeometry` does not change in response—the whole-frame + declaration deliberately excludes panel presence—so this cannot feed back + into panel sizing. +- **Discipline inherited from `TerminalFrame`**: whole-grid replacement, + `validate` both sides, atomic rejection retaining the previous valid frame, + duplicate suppression on the complete ordered payload, byte-bounded payload. + +### Q#BP14 — One authoritative primary-document context (R2-1, R3-B1) + +Rev 2 proposed `primary_document_window(fid)` and named three couplings. The +transitive §1.3 census now finds twenty-three. The projection contract: + +**Definition.** `EditorCore::primary_document_window(fid) -> Option` — +the frontend's active window when it is non-side, else its non-side target +(Q#BP11a). **Every consumer classified Projection in §1.3 (#1–#12 and +#21–#22) routes through it.** Focus and surface-routed consumers follow +Q#BP14b. + +The census rule is transitive: a new call to `active_window_for`, +`active_window`, `active_buffer_id`, or any helper that reaches one of them in +the daemon/semantic projection — including helpers implemented in +`src/editor.rs` or `src/statusline.rs` — must add or reaffirm its +classification. This is a review checklist item, not a lint; acceptance pins +each class. + +**The alignment helper splits in two.** `align_semantic_window_to_buffer` +(`src/daemon.rs:2900`) unconditionally rewrites `view.active`'s buffer, which is +exactly why rejecting panel-named events does not fix the *document* event — +with the panel focused, an ordinary document `Viewport` overwrites the panel's +buffer with the document buffer. + +- **`align_primary_document_window(fid, buffer_id)`** — rewrites the **primary + document window's** buffer/`TextView`/cursor. **Never touches `view.active`.** + Used by `Viewport` (#7). +- **Document `Pointer` (#8)** calls the same aligner **and then activates the + primary document window** before dispatching the gesture — a click in the + document area means "work here", so it moves focus out of the panel. This is + the one place projection and focus legitimately move together. +- **The `Viewport` terminal-context guard (#9)** tests the primary document + window plus the declared buffer, never the focused panel. A terminal panel + therefore cannot reject the still-visible document's viewport. +- **Existing full-window terminal transport (#10–#11)** remains the document + surface. `TerminalResize`, terminal snapshot/sync, and terminal-frame + suppression resolve a terminal key from the primary document window. + `TerminalPointer` validates against that declaration; any accepted + non-`Move` gesture activates the primary document window before replaying the + existing terminal gesture, while hover neither focuses nor claims control. + Panel terminals use `PanelFrame`/`PanelPointer`, never these declarations. +- **Statusline evaluation (#12)** uses Q#BP8's one frontend-layout fan-out: + primary-document segments remain on the semantic document status band while + the side context paints only in the panel mode line. +- **Semantic snapshot publication (#21)** tests whether a recipient displays + the published buffer through that recipient's primary document window. This + predicate is shared by lazy-upgrade and #148 initial-target publication: + panel-only visibility never swaps the document mirror, while panel focus + never hides a matching document surface from the publication. +- **Fresh no-target view construction (#22)** clones the buffer in + `primary_document_window(FrontendId::LOCAL)`, not `local_view.active`. A TUI + panel may own focus at attach without becoming the new frontend's + full-window document. The new view still starts as one ordinary leaf focused + on that inherited document buffer. +- **`PanelPointer` (Q#BP16)** activates the **panel**. + +So: `Viewport` never steals focus; document clicks take it; panel clicks give +it back. And because #1–#12 plus #21–#22 use the +primary-document/surface split, focusing the panel re-sends no snapshot, +suppresses no document, swaps no mirror, clears no document terminal or +statusline declaration, and cannot leak into a newly attached document view. + +**“Active buffer” in the semantic replica is now a document-surface term, not +an input-focus term.** Stage 2 audits and updates the contracts/comments/tests +for `InstanceMessage::CursorByte`, `BufferMirror::active_buffer`, +`SemanticRenderState`, `StatusFacts`, `LineNumbers`, `StatuslineSegments`, and +`TerminalFrame`: for a panel-capable semantic session these identify the +primary document declaration/mirror while a panel may separately own focus. +No wire field is renamed and legacy/grid behavior is unchanged; grid clients +already discard the semantic families. `DispatchIdle`, authenticated input, +presence, and Q#BP14b remain the authorities for actual focus. This vocabulary +split is load-bearing—leaving “active means focused” in the replica contract +invites a later producer to reintroduce the mirror swap. + +**The lazy CRDT upgrade (#2) is the sharpest case** and gets its own rule: the +upgrade + broadcast (`src/daemon.rs:1096`) keys on the **primary document +window**, so focusing a fresh generated panel buffer never broadcasts a +`BufferSnapshot` for it. A panel buffer that genuinely needs CRDT backing gets +it when it is displayed as a document, not as a side effect of focus. + +### Q#BP14a — Panel input gating is per-window, not per-buffer (R2-2) + +Rev 2 proposed auto-marking every side window's buffer round-trip, with an +opt-out. Both are wrong. `round_trip_buffers` is a **global set keyed by +`BufferId`** across every frontend and window (`src/editor_core.rs:349`), so +marking buffer A because *one* frontend panels it disables optimistic input for +another frontend editing A as its document; replacement and close would need +reference counting plus preservation of any pre-existing mark. And an opt-out is +unsafe: with the panel focused, the GPU would optimistically edit its document +mirror while daemon input targets the panel, and every resulting op fails +remote-op validation (#13, `src/daemon.rs:2531-2537`) — a silent mirror +divergence. The accepted-op cursor/provenance path (#23) intentionally retains +the focused source window; it is not redirected to the primary document. + +**The rule: `dispatch_idle_for` returns `false` whenever the acting frontend's +active window is a side window**, independently of the buffer-global set +(`src/editor.rs:753-769`). No auto-marking, no reference counting, no opt-out. +Existing `listview` / compile / terminal marks stay exactly as they are and keep +governing their full-window behavior. + +This is **one panel-aware producer condition** — which is why B1 is narrowed to +terminal controller/escape routing rather than claiming all input gating is free. + +### Q#BP14b — Focus chrome and per-window overlay routing (R3-B1, R3-rp4) + +The semantic producer gains a **focus-chrome pass** that runs once per semantic +frontend independently of whether a document viewport exists and independently +of the document/terminal projection pass. It reads modal state through the +acting frontend's focused context, never through `vp.buffer_id`. + +| Surface | Document focused | Panel focused | +| --- | --- | --- | +| Search | `SearchPrompt` on the semantic status band; document `SearchView` supplies washes | `SearchPrompt` still uses the semantic status band; panel `SearchView` washes are in `PanelFrame` | +| Minibuffer | `MinibufferPrompt` | `MinibufferPrompt` — it is global and bufferless | +| Menu | `MenuPrompt` native popup; no document cell-grid menu | `MenuView` is painted in `PanelFrame`; semantic `MenuPrompt` emits/retains authoritative empty | +| Completion | `CompletionPopup` native popup | `CompletionView` is painted in `PanelFrame`; semantic `CompletionPopup` emits/retains authoritative close | + +The menu/completion baselines track the **currently owned surface**, not merely +a per-buffer payload. A document→panel focus change therefore emits the clear +for a formerly open native popup even if the focused panel carries a different +buffer; a panel→document change cannot leave a pre-painted panel popup in +native GPU state. `BufferSnapshot` baseline resets audit both the open and clear +mirrors, following the #120 rule. + +No new focus-owner wire field is needed: a current +`PanelFrame::Present { buffer_id, focused: true, ... }` is the authenticated +panel-surface declaration. A surface transition is ordered: +**authoritative closes for the old owner → new `PanelFrame` +focus/presence → opens/updates for the new owner**. Thus a panel-owned search +clear is accepted while the old focused declaration still exists; only then +may `Absent` or `focused = false` remove that authority. Conversely, a newly +panel-owned prompt follows the `focused = true` frame it relies on. The GPU +accepts `SearchPrompt { buffer_id, ... }` when `buffer_id` matches either its +primary document mirror or its current focused `Present` panel; the latter +still renders prompt text in the semantic status band while match washes come +only from the panel grid. A prompt naming neither surface is stale and is +dropped without changing the current prompt. +Document-native completion validates against the document as today; panel +completion/menu opens only inside `PanelFrame`, while the semantic native +variants carry authoritative close. `MinibufferPrompt` remains bufferless. + +Focus consumers #13–#15 and #23 keep the focused window. #16–#19 use the +routing table above. Bell drain #20 keeps its per-session counter but uses the +focused window to choose the eligible terminal; passive/historical bells +remain baseline-suppressed exactly as today. + +### Q#BP15 — `PanelFrame` lifecycle (R1-2) + +- **Explicit presence.** `InstanceMessage::PanelFrame(PanelFramePayload)` where + the payload is `Present(PanelFrame)` | `Absent`. **`Absent` is authoritative + and must be sent** on close *and* on hide (Q#BP2b) — silence would leave the + last valid frame on screen forever under the retain-on-invalid rule. `Absent` + is duplicate-suppressed like any payload. +- **Cursor and focus travel with the frame.** `paint_frame` returns the cursor + separately (`src/editor.rs:2833`), so cells alone lose the caret. + `PanelFrame` carries `cursor: Option` and `focused: bool` — the GPU + paints the band caret only when the panel owns focus. `focused` is + presentation/focus-chrome routing only (Q#BP14b); the *keys* decision is + `DispatchIdle` (Q#BP14a). +- **Presentation identity.** `PanelFrame` carries `buffer_id` **and** + `panel_epoch: u64`, plus the frontend-owned `geometry_epoch` it is answering. + The panel epoch is opaque and monotonic per frontend. It stays stable across + ordinary frames of one continuously present window/buffer, and changes on + buffer replacement, new side-window creation, and every + `Absent`→`Present` transition. Thus closing/hiding and reopening the same + persistent buffer cannot reuse the identity of an old frame (Q#BP16). + Allocation is checked; exhaustion fails closed to `Absent` rather than + wrapping into a stale identity. + `geometry_epoch` is different: it changes whenever the frontend declares new + effective cell geometry, even if the panel presentation is otherwise the + same (Q#BP15a). +- **Absent clears input authority.** Emitting or applying `Absent` clears the + last declared panel size and **panel** epoch on both sides before any later + event can validate. Whole-frame geometry remains valid until superseded by a + newer authenticated declaration. +- **Cell-grid validation is shared, terminal dimensions are not.** Factor the + cell count, cursor, glyph width/continuation topology, aggregate glyph-byte, + visible-cell, and transport-safety checks out of + `pmacs-protocol/src/terminal.rs` into one parameterized wire-cell-grid + validator. `TerminalFrame` still adds its PTY-specific + `MAX_TERMINAL_ROWS/COLS = 512`; `PanelFrame` does **not** inherit that + per-axis cap. A common 4K/small-font panel wider than 512 columns remains + legal as long as its checked area and aggregate glyph bytes fit the shared + wire budget (Bet B5'). + +### Q#BP15a — Three geometries, two messages, one exact conversion +(R2-3, R3-B2, R3-B7) + +Rev 2's `PanelResize { size: CellSize }` conflated the frontend's total frame, +the requested panel rows, and the resulting grid — and created a **first-open +cycle**: the declaration was gated on a side window existing, but the daemon +needs columns before it can paint the first frame. The GPU's attach `CellSize` +cannot fill the gap: it is permanently the placeholder `24×80` +(`pmacs-gpu/src/attach.rs:420-429`, `:573-577`) and no resize updates it. + +Two messages with different lifetimes: + +- **`FrontendEvent::FrontendCellGeometry { frontend_id, geometry_epoch, + total: CellSize }`** — the frontend's authoritative cell-equivalent layout + capacity. It is valid **without a side window**, sent immediately after + attach acceptance and refreshed on **window resize, font change, and scale + change**. `geometry_epoch` is a checked, monotonically increasing + frontend-owned declaration id; exhaustion fails closed rather than wrapping, + and a lower/repeated epoch with different data is stale/invalid. The event is + accepted only from the authenticated, negotiated + panel-capable semantic session; the word "without" refers to side-window + presence, not protocol/session gates. +- **`FrontendEvent::PanelResizeRows { frontend_id, geometry_epoch, + panel_epoch, rows }`** — the requested fixed panel rows from a divider drag. + Its only size component is rows; the epochs are identities, not geometry. It + is accepted only for the currently visible `Present` panel matching both the + latest geometry declaration and presentation epoch, then clamped by Q#BP2's + interactive preference. + +Both events join `pmacs-gpu/src/attach.rs`'s bounded outbox policy as distinct +same-kind **tail-coalescible** classes. Geometry is latest-wins (epochs need +only increase, not be consecutive); resize drag is latest-wins over the +complete event, including its epochs, so a new presentation may supersede a +queued stale drag. Tail-only replacement preserves ordering across a click, +key, `PanelPointer`, or geometry transition, and daemon-side epoch validation +still rejects anything stale. Neither human-rate stream consumes the 8192 +lossless-event budget while the writer is stalled. + +The GPU declares **whole-cell capacity**, not pixels or a guessed grid. For +current GPU geometry: + +``` +available_height_px = + max(0, surface_height_px + - status_band_height_px + - TEXT_TOP_px + - divider_height_px) + +layout_rows = floor(available_height_px / code_line_height_px) +total.rows = layout_rows + 1 // virtual daemon status row +total.cols = floor(surface_width_px / resolved_monospace_advance_px) +``` + +All quantities use the frontend's current scale. `divider_height_px` is the +scaled frontend-local divider reserved **for sizing purposes even while the +panel is absent**; this keeps the declaration independent of panel presence +and breaks the first-open cycle. The document renderer does not actually lose +those divider pixels until a `Present` panel is painted. `total.cols` describes +the full-width panel grid beginning at x=0; document `TEXT_LEFT`/gutter padding +is unrelated. Only full cells count. While the band is present, any fractional +right-edge remainder is painted as panel background but maps to no cell and +emits no `PanelPointer`; above the band, the document keeps its normal full +pixel width. + +The conversion accepts only finite, positive line-height/advance metrics and +uses checked/saturating conversion to `u32`. A zero surface, non-finite metric, +or non-positive advance/line height declares zero usable geometry under a new +epoch and therefore hides the panel; it never divides, wraps, or emits a giant +grid. Aggregate area validation still applies after conversion. + +The added row is virtual because the shared grid placement helper subtracts one +global status row before laying out windows. The GPU's real status band remains +pixel chrome; it is not painted into `PanelFrame`. + +**The daemon derives the third geometry.** Panel grid cols = `total.cols`. +Rows are `fixed_rows` clamped per Q#BP2 against `total` and, for a semantic +panel, by `shared_visible_cell_budget / total.cols`; if that wire-area cap is +below the structural two-row floor, the panel follows Q#BP2b's hidden arm. +The requested `fixed_rows` remains stored, so a later narrower geometry can +restore it. The daemon paints and ships the resulting grid in `PanelFrame`; the +GPU never asserts its size. The rendered band is exactly +`grid.rows * code_line_height_px`; divider and status-band pixels remain +frontend chrome, so document shrink is exact and contains no row-rounding +feedback loop. For a terminal panel, the grid's content rows exclude its one +mode line and feed Q#BP7's pre-drain terminal view/controller sync before the +snapshot is painted. + +**Unknown is first-class.** A semantic `FrontendView` starts with +`frame_geometry = None`; the daemon must not consult the attach request's 24×80 +placeholder for panel layout. A panel requested before the first real +declaration remains non-presentable under Q#BP2b. The GPU sends geometry before +enabling user input; receipt stores it, reconciles visibility, and permits the +first `Present`. Grid/LOCAL frontends continue to populate the same cached field +from their existing real attach/resize sizes and never send this new event. + +**Geometry changes fail closed.** As soon as the GPU sends a new +`geometry_epoch`, it retains but does not paint or hit-test an older +`PanelFrame`; only a matching `Present` can make the band visible and +interactive again. An `Absent` is always safe to apply because it only removes +paint/input authority. Every `Present` echoes the daemon's latest accepted +geometry epoch. This is the font/scale/resize analogue of terminal-frame size +validation and prevents an old grid from being interpreted under new metrics. + +### Q#BP16 — GPU panel pointer transport and presentation identity +(R1-3, R2-7, R3-B3) + +Existing events cannot carry panel gestures: semantic `Pointer` carries a +**document byte**, `TerminalPointer` is keyed to a terminal buffer, and `Mouse` +is contractually the **grid** path (`src/daemon.rs:3122-3130` drops terminal +declarations from grid sessions for exactly this reason). + +`FrontendEvent::PanelPointer { frontend_id, geometry_epoch, panel_epoch, +buffer_id, coord: CellCoord, kind: MouseKind, mods: Modifiers }`. + +`buffer_id` catches A→B replacement, but it cannot catch close/hide/reopen of +the **same** persistent buffer. `panel_epoch` closes that hole without putting +`WindowId` on the wire. `PanelPointer` is validated in this order: + +1. The authenticated source negotiated the panel event and matches/owns the + claimed `frontend_id`. +2. Its `FrontendView` has a live side window that is **not + `panel_hidden`**, and its latest daemon→frontend declaration is `Present`. +3. The payload's `geometry_epoch` equals both the latest accepted frontend + geometry and the echoed epoch in that `Present`. +4. The payload's `panel_epoch` equals that declaration's presentation epoch. +5. The side window's current `buffer_id` equals the payload's. +6. `coord` is inside that declaration's panel size. + +`Absent` clears steps 4–6's presentation state. Any failure drops the event +before any view, controller, selection, menu, or PTY mutation. A +`PanelResizeRows` follows the same +source/visible/Present/geometry-epoch/panel-epoch validation before changing +`fixed_rows`. + +`PanelPointer` events whose `kind` is `Move` or `Drag` receive their own +same-kind tail-coalescing tags beside document/terminal motion and drag. +Every `Down`/`Up` and wheel step remains lossless and ordered: repeated left +`Down`s are what the existing daemon click state interprets as a multi-click, +and `Down(Right)` is the context-menu gesture, so neither may collapse. The +event's geometry/presentation identities remain part of daemon validation; +coalescing never crosses an intervening event or combines different kinds. + +Once accepted, the daemon re-derives the panel window and replays existing +semantics: a terminal panel takes the Stage 2 vterm pointer path (child SGR +reporting when eligible, else per-view scroll/selection/menu); otherwise the +ordinary document gesture path in cell space. Click-to-focus is a `Down` on the +band; it activates the panel and, per Q#BP14, does **not** disturb the document +mirror. One terminal-specific consequence is explicit: every accepted +non-`Move` terminal gesture activates the panel before the shared terminal +adapter runs, because that adapter deliberately claims the controller for +wheel/press/drag/release as well as clicks. Bare hover neither focuses nor +claims. Non-terminal wheel motion keeps today's scroll-without-focus behavior. + +### Q#BP17 — Fold projection for the panel grid + +Folding asserts *"a semantic session never enters `paint_frame`"* and builds the +per-window map **ungated** on that basis (`src/editor.rs:2991`). The panel band +breaks the premise. + +**Rule: the panel projection honors the owning frontend's `fold_projection`.** +The extracted per-window painter takes the map as a **parameter** rather than +building it; the panel path passes `None` when the owning frontend's +`fold_projection` is false. The panel path must **not** call +`EditorCore::fold_map_for_window`, which gates on the **active** frontend +(`src/editor_core.rs:566`) — right for command-time reckoning, wrong for +painting another frontend's panel. **Updating the now-stale invariant comment at +`src/window.rs:339` is part of Stage 2.** + +### Q#BP9 — Protocol: Stage 1 none; Stage 2 takes the next available version + +- Stage 1 changes no wire shape. The reviewed base is v20 after #148 and Stage + 1 inherits it without adding or reserving another version. +- Stage 2 appends `InstanceMessage::PanelFrame` after whatever that enum's final + variant is at the time, and appends + `FrontendEvent::{FrontendCellGeometry, PanelResizeRows, PanelPointer}` after + that enum's final variant. **Each extended enum gets a byte pin on its own + previous final variant's discriminant.** On `0dd16a5`, those pins are + `InstanceMessage::InitialTargetResult` and + `FrontendEvent::TerminalPointer`. Gated in both directions. +- **No future version is reserved.** Stage 2 takes the next available version + at implementation time—v21 if no intervening protocol PR lands—per + `docs/dap-debugging-framing.md` Q#DAP8. +- **Every gate keys on the daemon's own state.** All three events require an + authenticated semantic session whose claimed `frontend_id` equals the + transport source and that negotiated the panel version/capability. + `PanelResizeRows` / `PanelPointer` additionally require the current visible + `Present` declaration and matching geometry/presentation epochs; + `FrontendCellGeometry` deliberately does **not** require a side window + (Q#BP15a). A grid session or pre-panel semantic peer sending any new event is + rejected before payload state is trusted. + +### Q#BP10 — Persistence: side windows are not saved + +`src/desktop.rs`'s save walk skips side leaves; restore never creates one. +The v1 `SavedLeaf` shape remains unchanged: every restored ordinary window gets +default `WindowParams` (`side/fixed_rows/quit_action/origin_document` empty, +`dedicated = false`). Thus this arc does not bump `DESKTOP_VERSION` merely to +persist transient display policy. +Deferred: persisting panel geometry as a setting (blocked on settings +persistence). + +### Q#BP10a — Killing a panel buffer (rp-2 of round 1) + +`kill_buffer` redirects **every** window showing the victim to `*scratch*` +(`src/editor_core.rs:3046`). For a side window that is wrong twice. + +- Killing the buffer in a **side window closes the side window** (Q#BP2a + collapse) rather than redirecting it. +- If that would leave no non-side window — impossible under Q#BP6, asserted + anyway — the wrapper collapse restores the prior root, which by construction + holds a leaf. +- `QuitAction::Restore { buffer_id, .. }` **revalidates** at quit time; a + killed target degrades to `Delete`. This lifts `listview`'s existing fallback + (`builtin/runtime/listview.lua:164-166`) into the core. + +### Q#BP11 — Lua surface + +```lua +pmacs.window.display(buf, { side = "bottom", height = 12, + dedicated = true, select = false }) +pmacs.window.display_file(path, { window = win, select = true }) -- Q#BP11b +pmacs.window.quit() +pmacs.window.panel() +pmacs.window.params(win) / set_params(win, {...}) -- side/origin/quit action are read-only +pmacs.window.resize(win, delta_rows) -- boundary per Q#BP5b +pmacs.window.display_target() -- the non-side target +``` + +Commands: `window.quit`, `window.enlarge`, `window.shrink`. Settings: +`window.panel-height` (default 12 outer rows), `window.min-height` (Q#BP2). +Every Lua operation taking a `WindowId` validates that it is live and belongs +to the acting frontend's layout; a cross-frontend id is a pointed error before +read or mutation. + +### Q#BP11a — The non-side target rule (R1-5) + +1. Selected window is **not** side → it is the target (byte-identical to today). +2. Else the **remembered document window** (`origin_document`, Q#BP2c) if it + revalidates. +3. Else the **first non-side window in `iter_ids()` order**. +4. Else (no non-side window — forbidden as a resting state by Q#BP6) → + `debug_assert!` the broken invariant and return a pointed error without + mutation. There is no document leaf from which a valid fallback can be + fabricated. + +### Q#BP11b — A target-aware load transaction (R2-5) + +`display_target()` returns a *window*, but Lua has **no operation that loads or +switches into an arbitrary window**. `pmacs.buffer.find_or_open` switches the +**active** window in both branches before firing hooks +(`src/lua_bindings/mod.rs:3089`, `:3108`, `:3113`), and LSP +(`builtin/runtime/lsp.lua:1597`) and compile (`builtin/runtime/compile.lua:869`) +call it directly. #148's private `open_initial_target` +(`src/daemon.rs:1625-1677`) proves the useful off-ambient load seam +(`EditorCore::get_or_load_buffer`), but it too installs and reasserts through +`switch_active_buffer_for`, so it is not an arbitrary-window API. A visit to a +**previously unopened file** would still replace a focused panel before +`display_buffer` could help. Rev 2's Q#BP4 also covered only `after-switch`, +while a fresh load must fire `after-load` with the **document target** active. + +**`pmacs.window.display_file(path, { window, select })`** — one transaction: + +1. Construct the same path key `find_or_open` uses and perform its + side-effect-free registry dedup; do **not** read the file yet. +2. Resolve the destination before I/O. An explicit `window` is an **exact + target** under Q#BP3, not a hint; it must be live, owned by this frontend, + and not dedicated to a different already-open buffer (or, on a miss, to any + buffer). With no explicit window, an existing buffer uses Q#BP3's ordinary + non-side reuse/candidate policy; a miss chooses the first non-dedicated + Q#BP11a candidate. No eligible target is an error **before loading**. +3. On a registry miss, load/create the buffer; on a hit, preserve its unsaved + contents exactly as `find_or_open` does. +4. Enter Q#BP4's transaction with `fire = AfterLoad` on a fresh load, + `AfterSwitch` on a reuse (including a same-buffer no-op), and `None` for a + newly created `NotFound` path, matching #148/local-startup behavior — so any + hook observes the **document target** as active, which saveplace / recentf / + syntax / LSP all require. +5. Apply Q#BP4's final-focus matrix. + +The implementation factors one Rust/editor-core **resolve/load-without-switch** +primitive and one exact-window install primitive for both `display_file` and +#148's `open_initial_target`; the daemon bootstrap does not call back through +the public Lua binding. This prevents two path-normalization, dedup, and hook +transactions from drifting. + +Initial-target bootstrap retains its stronger Q#GT5/Q#GT8 postcondition. It +captures the fresh view's original document window before I/O and runs the +shared exact-window transaction with `select = true`. After its one hook, it +revalidates the target `BufferId`: removal is still bootstrap failure. If the +original document window remains live, reassert the target there and activate +it; if a hook closed that window, resolve an eligible non-side window in the +same new frontend, install the target there without firing a second hook, and +activate it. A hook-created/selected side window is never overwritten merely +because it became `view.active`. Snapshot publication and +`InitialTargetResult::Opened` retain #148's existing order and name the +reasserted document buffer. + +Adopters route through this: `listview` visit +(`builtin/runtime/listview.lua:126`), LSP `visit_location`, compile +`visit_error`. Raw `find_or_open` stays for programmatic use. + +**Stage 1 also needs real opt-in entry points**, because compile, terminal, and +listview currently create/switch their buffers through active-window-only +paths. Calling a generic display afterward was the rev-3 vacuous path; Q#BP3's +placement-aware rule and these entry points make the requested side placement +the first real display: + +All three parse the same strict placement value: +`display = "current" | "panel"`. Unknown values error before creating a buffer, +session, process, or wrapper. In Stages 1–2, omission means `"current"`; in +Stage 3, omission means `"panel"`. Explicit `"current"` always preserves the +adopter's pre-arc selected-window behavior and is the user-facing opt-out from +the default flip. + +- `pmacs.terminal.open{ display = "panel" }` — `pmacs.terminal.open` hardwires + `switch_active_buffer_for(frontend_id, …)` into the active window + (`src/lua_bindings/mod.rs:8500`) and rolls the session back on failure. The + binding takes an optional exact target window (mutually exclusive with + `display = "panel"`), defaulting to today's behavior; the panel opt-in uses + `select = true`. Placement failure removes any side wrapper created by the + transaction before the existing session/buffer rollback completes. +- `compile.run{ display = "panel" }` — compile creates its buffer + (`compile.lua:263`) then `switch_buffer`s (`:808`); the first display becomes + a side-affine `display` call even when an older document window already shows + `*compilation*`, explicitly with `select = false`. Recompile reuses the + current panel. `compile.quit` routes through `pmacs.window.quit` when the + compilation buffer is in a side window, so it deletes/restores the + presentation instead of leaving a source buffer stranded in the side slot. + In capability fallback it keeps today's previous-buffer restore in the + selected document window. +- `pmacs.listview.open{ ..., display = "panel" }` — `listview.open` currently + hardwires `switch_buffer` (`listview.lua:126`). The opt-in calls + `display(..., {side = "bottom", select = true})`, because `seat_cursor` and + refresh are active-window-only. `listview.quit` keeps the same `q` command and + user-visible behavior, delegating to `pmacs.window.quit` only when the + listview is in a side window; capability fallback retains the current + previous-buffer switch. + +For all three adopters the default panel is **undedicated**, so the one side +slot can be replaced. Creating a new side slot records +`Some(QuitAction::Delete)`. Replacing it snapshots the prior buffer, height, +dedication, cursor/view/selection state, and quit action into +`Some(QuitAction::Restore { … })`; merely redisplaying the same buffer +preserves its action. `origin_document` belongs to the slot lifetime: a +replacement retains the existing valid origin rather than remembering the +currently focused panel. + +`window.quit` executes through Q#BP4's activate–switch-hook–reconcile +transaction. Restoring C→B→A reinstalls each saved presentation and its +`then`; executing `Delete` collapses the wrapper and focuses the revalidated +origin/non-side target. Capability fallback creates no window-level quit +action and leaves no side parameters behind; each adopter uses its existing +ordinary document-window restore path. + +Acceptance pre-seeds the persistent listview/compilation buffer in a document +window before asking for panel placement. That is the bite against accidentally +restoring global reuse-first. + +### Q#BP11c — Jump-ring origins (R2-6) + +The jump ring stores only `(BufferId, Position)` (`src/editor_core.rs:279`), and +`jump_back` switches the **currently active** window to that buffer +(`src/editor_core.rs:811`). After `RET` from an outline or compilation panel, +`M-,` would put the **panel buffer into the document window** while the panel +stays open — a duplicate-buffer/window corruption, and a regression of today's +"M-, returns to the panel row" behavior. + +**History becomes per frontend**, matching `command_history`: +`HashMap>`, where `JumpEntry` is +`{ window_id, buffer_id, position, side_origin }`. `push_jump` and `jump_back` +address only the acting frontend's vector; detach purges it. One frontend can +therefore neither pop nor destroy another frontend's navigation trail. +`JUMP_RING_CAP` applies independently to each vector with today's oldest-entry +eviction. + +`jump_back` restores into the **origin window** only when all of these +revalidate: the window is live, belongs to the acting frontend's layout, is not +hidden when side, **and still shows the recorded `BufferId`**. A live panel +that has since been replaced does not resurrect its old buffer. When validation +fails for a **non-side** origin, the entry degrades to today's active-window +switch behavior within the same acting frontend. When it fails for a recorded +**side** origin (closed, hidden, replaced, or moved out of the layout), the +entry is skipped: switching its buffer into the document window would recreate +the duplicate-panel corruption this design is meant to remove. Entries whose +buffer is gone are likewise skipped. + +Acceptance runs the real paths: **panel → `RET` source → `M-,`** for both +outline and compilation, asserting focus returns to the **existing** panel with +its row restored and the document window unchanged. A second acceptance +interleaves two frontends' jump histories and replaces one origin window's +buffer before `M-,`. + +### Q#BP12 — Default placement flips in Stage 3 + +Stage 1 ships the mechanism **opt-in**; existing acceptance suites keep their +meaning. Between Stage 1 and Stage 2 a semantic frontend could hold a side +window it cannot render, so the flip waits. + +**Stage 3 is not "one line per consumer"**: each adopter also moves its visit +path onto `display_file`/`display_target` and takes its own `select` decision: + +| Adopter | Panel placement | Dedicated | Quit action | Visit | `select` on visit | +| --- | --- | --- | --- | --- | --- | +| `listview` (references/outline) | panel, `select = true` | `false` | delete if created; restore replaced panel | `display_file` | `true` | +| compile output | panel, `select = false` | `false` | delete if created; restore replaced panel | `display_file` | `true` | +| terminal | panel, `select = true` | `false` | delete if created; restore replaced panel | n/a | n/a | +| DAP stack/variables | panel, `select = true` | `false` | delete if created; restore replaced panel | `display_file` | `true` | + +An interactive `listview` **must** take `select = true`: `seat_cursor` +(`builtin/runtime/listview.lua:64`) and `listview.refresh` are active-window-only +and would silently seat the wrong window otherwise. + +The Stage 3 default is resolved as a panel request and therefore still passes +through Q#BP13 capability fallback. It is not a hidden global setting. +Explicit `display = "current"` bypasses side placement deliberately and keeps +the old adopter-specific quit/previous-buffer path; like today's entry points, +it uses the raw switch escape and does not consult display-policy dedication. + +### Q#BP13 — Panel capability: a per-`FrontendView` bit set at attach (R1-6) + +```rust +pub struct FrontendView { + pub layout: Layout, + pub active: WindowId, + pub fold_projection: bool, // Arc 6 Stage 2 + pub panel_capable: bool, // this arc; no Default + pub frame_geometry: Option, // epoch + total; None != 24x80 + pub panel_hidden: bool, // cached derived state, never persisted +} +``` + +Set in the attach transaction that already computes `fold_projection` +(`src/daemon.rs:1769`) from `SessionState` (`src/presence.rs:74-84`): + +| Session | `panel_capable` | +| --- | --- | +| `FrontendId::LOCAL` / grid | `true` | +| semantic, `negotiated_protocol_version < PANEL_MIN_VERSION` | `false` | +| semantic, `>= PANEL_MIN_VERSION` | `true` | + +`peer_declared_terminal_support` (`src/daemon.rs:888`) is the helper shape. +`peer_declared_panel_support` is explicitly +`semantic_render && negotiated_protocol_version >= PANEL_MIN_VERSION`; no +client-asserted standalone boolean is trusted. Stage 1 sets `true` for +grid/LOCAL, `false` for every semantic session; Stage 2 flips the version arm +on. `display_buffer` with a `side` falls back to the non-side target **and +discards every side-specific parameter** (Q#BP2c). + +Grid/LOCAL construction supplies real geometry before first input/render. +Semantic construction supplies `None`; Stage 2's authenticated declaration +fills it. Desktop restore spells all fields explicitly, preserving folding's +non-`Default` discipline. Stage 2 additionally holds the current presentation +epoch/declaration beside the semantic render baseline; it is runtime-only and +never desktop state. The same constructor inherits its initial buffer through +Q#BP14's `primary_document_window(LOCAL)`, so adding the capability fields +cannot preserve the old panel-focused attach leak. + +## 4. Bets (explicit, falsifiable) + +- **B1 (narrowed after R2-2) — panel-as-window means the terminal controller, + the `C-c` escape, and release-on-blur need zero new code.** Falsified if any + `TerminalViewKey` / `TerminalController` / escape-dispatch code needs a panel + case. *Input gating is explicitly excluded: Q#BP14a is one new condition.* +- **B2' (narrowed after R3-B20) — the active-window preparation plus + `paint_frame`'s per-window body extract to a standalone panel grid without + modifying a concrete painter.** Falsified if a text/gutter/overlay/modeline + painter reads absolute frame coordinates or `term_size` rather than its + `Viewport<'a>`/placement, or if the shared preparation cannot keep a focused + panel cursor visible. +- **B3 — the terminal's anchor model absorbs height changes with no new state.** + Falsified if Q#BP7 needs a new `TerminalViewState` field. +- **B4 — no document painter breaks when a window's rect becomes fixed rather + than proportional.** Falsified if any painter assumes the flexible-remainder + rule. +- **B5' (narrowed after R3-B21) — `PanelFrame` reuses one factored wire-cell + validator and aggregate area/glyph/transport budgets, but not terminal PTY + per-axis limits.** Falsified if panel cells need a second glyph/topology + implementation or if a legal >512-column, area-bounded panel cannot + round-trip. +- **B6 (restated after rp-3) — opening a panel leaves the prior document + subtree's STRUCTURE byte-identical**: same nodes, same weights, same order, + same `WindowId`s. Its **rectangles necessarily change**, being recomputed + inside the smaller flexible remainder. Falsified if opening a panel reorders, + reweights, or re-ids any document node. +- **B7'''' (replacing the falsified B7'/B7''/B7''') — the transitive §1.3 + census and Q#BP14b surface matrix are complete.** Falsified if any direct or helper- + mediated read of active window/buffer state reached by the daemon/semantic + projection is missing, if a focus surface inherits the document viewport + again, or if an open/clear baseline survives on the wrong surface under + acceptance. + +## 5. Acceptance + +**Stage 1 — core + TUI (no wire change from its eventual base).** + +1. `Layout::compute` honors a fixed extent: a bottom child of N rows gets + exactly N; siblings divide the remainder by weight. **Both production + callers are pinned through their real paths** (R5-B1): a document window's + rows come from `window_placements`, and a peer cursor in that same window is + painted by the overlay pass (`src/overlay_paint.rs:112`) at an identical row + whether or not a panel is open — the assertion that fails if the second + caller keeps computing unfixed geometry. +2. Opening a panel leaves the prior document subtree's **structure** identical + (nodes, weights, order, ids); its rects are recomputed (B6). +3. `subtree_min_rows` is recursive: a **nested** document tree (horizontal + inside vertical inside horizontal) keeps every leaf at the floor, and the + panel is clamped — not the document — when they compete. +4. Programmatic `height`, `window.panel-height`, and side `fixed_rows` requests + of one row clamp to `MIN_WINDOW_OUTER_ROWS`; zero rejects. An intrinsically + too-small or zero-column frame uses saturating arithmetic and hides rather + than underflows or emitting a zero-width panel. +5. A terminal resize preserves a side window's **absolute** height and a + flexible pair's **ratio**, in one layout. +6. Geometry is cached before first input. A command/hook opens and selects a + panel in a too-small frame, then a second key in the **same drained burst**: + reconciliation marks the panel hidden, moves focus to a document, and + releases the observed terminal controller before that key dispatches. +7. Growing the frame enough to make the request satisfiable restores the panel + at its exact requested `fixed_rows`; focus is **not** auto-restored, and + `focus_next/prev` skip it while hidden but reach it after reappearance. + While hidden, its rect is empty and the unchanged document subtree receives + every reclaimed row; the stored request, wrapper, ids, weights, and order + remain intact. +8. Keys typed while the panel is hidden reach the document window, never the + invisible panel. +9. `window.min-height` below the structural floor clamps; a value materially + above it constrains drag/keyboard resize recursively across a nested tree, + while frame-resize layout ignores the preference. +10. Closing the panel collapses the wrapper and restores the prior root exactly. +11. `set_params` rejects adding/changing/clearing `side` and rejects + `origin_document`; `params` may report the origin; a stray `fixed_rows` on + a non-side window is inert. Every `WindowId`-taking Lua operation rejects a + live id owned by another frontend. +12. Raw `switch_buffer` **ignores** `dedicated`; `display_buffer` honors it on + side, reused, exact, and non-side candidates, falling through or erroring + without overwriting one. An ordinary display never reuses a matching side + window. +13. Side placement is affinity-aware: a buffer already visible in a document + window does not preempt a requested usable side slot. An explicit + `window` is exact. A dedicated side fallback never creates a second side + window and discards height/dedication/quit state before touching the + document target; `window` + `side` and a freestanding `height` reject. + Same-buffer redisplay preserves omitted height/dedication/action; + replacement preserves an omitted user-resized height but defaults the new + presentation undedicated; creation uses the setting/default. Explicit + `dedicated = false` cannot bypass an existing dedication in the same call. +14. Capability fallback discards all side-only parameters and leaves the + document target undedicated/unpinned. +15. **Final-focus matrix (Q#BP4), all six rows**, including `select = true` + leaving the target selected and `select = false` restoring a **side** + `saved_active`. +16. The three hook-failure arms (hook closes target / closes saved / switches + buffers) are covered in **both** `select` modes, with reconciliation between + the hook and final-focus decision. +17. A panel displayed into a passive window has its overlays re-attached. +18. **`display_file` to a previously unopened file from a focused panel** opens + it in the exact document target, leaves the panel intact, and fires + `buffer.after-load` with the **document target** active — asserted through + the real LSP and compile visit paths. A dedicated exact target fails + without loading/switching it; an omitted target skips a dedicated + remembered origin and chooses the next eligible non-side window before I/O. + A `NotFound` path creates a path-backed buffer and fires no load/switch + hook, matching initial-target/local-startup behavior. +19. `pmacs.terminal.open{display="panel"}`, + `compile.run{display="panel"}`, and + `pmacs.listview.open{display="panel"}` place through their real entry + points. The fixture first shows persistent `*compilation*` / `*outline*` in + a document window, proving side-affine placement is not vacuous. Unknown + `display` values fail before buffer/process/session/wrapper creation. +20. `listview`/compile `q` route through `window.quit`: the first panel deletes + its wrapper; C→B→A restores each saved height, dedication, + cursor/view/goal/selection, hook-attached overlays, and prior quit action; + a killed restore target collapses safely. Capability fallback restores the + prior document through the adopter's old path and leaves no quit action. + Terminal placement failure removes a newly created wrapper before its + existing session/buffer rollback completes. Replacing more than + `MAX_PANEL_QUIT_DEPTH` times retains exactly the newest 64 presentations, + then terminates in `Delete`; depth never grows beyond the cap. +21. **`panel → RET source → M-,`** for outline and compilation returns focus to + the same still-showing-origin panel row; the document window remains + unchanged and no duplicate presentation is created. +22. Jump histories are per frontend: interleaved pushes/pops cannot consume a + peer's entries. A live origin window now showing a different buffer + is skipped when it was a side origin rather than resurrecting or + duplicating the old panel; an invalid non-side origin retains today's + acting-frontend fallback. +23. `window.quit` revalidates `QuitAction::Restore`; a killed restore target + degrades to delete. +24. Killing a panel buffer **closes the side window** rather than redirecting to + `*scratch*`. +25. `close_active` refuses only when the target is the last **non-side** window; + closing the side window itself is legal even as the only other window. +26. `close_others` from a document window deletes the panel; from a side window + it errors. `split_active` from a side window errors. +27. `C-x o` reaches the panel and returns; the terminal controller is claimed on + entry and released on exit. With two document windows, entering the panel + from B refreshes `origin_document`, so `display_target`, a panel visit, and + a Delete-form `window.quit` target B rather than the window from panel + creation. +28. With the panel focused, unescaped bound keys reach the child; `C-c` escapes + for exactly one key; `C-c C-c` sends one literal interrupt. **B1 pin.** +29. A focused side window makes `dispatch_idle_for` return `false` **without** + marking its buffer round-trip, and another frontend editing that same + buffer as a document keeps optimistic apply. A forged/stale optimistic op + for the document is rejected before source-window cursor/provenance + mutation; a valid round-trip edit still updates the focused panel window. +30. Divider drag changes side `fixed_rows` and document-pair weights under the + interactive recursive preference; a click on the reserved row creates no + selection, and `ui.divider` resolves through the `ui.*` face walk. A + boundary whose upper child is a vertical split paints all adjacent exposed + mode-line segments, and dragging either segment resolves the same boundary. +31. `window.enlarge`/`shrink` equal the equivalent drag in a **nested** layout + where the active subtree is its nearest horizontal ancestor's final child; + `resize(win, …)` resolves from `win`; no horizontal ancestor reports/no-ops. +32. A terminal panel scrolled back keeps its `top` across a height change; + growth reaching the tail re-arms follow; later output scrolls in. +33. Growth reaching the tail with a historical selection leaves the selection + and anchor frozen, via the shared viewport-size path. +34. Only the controller's height change resizes the PTY. A semantic panel + terminal uses the daemon-derived panel content rect at the pre-drain sync + point, never the 24×80 attach placeholder or full-window terminal + declaration. +35. The desktop round-trips a layout containing a file-backed side window + **without** the side leaf or its root wrapper; restored document leaves + have default parameters and the desktop format version does not change. +36. Full gate suite per `AGENTS.md`; because Stage 1 factors #148's target-load + seam, this includes `gpu_initial_target_acceptance` in default and CRDT + configurations in addition to the new/touched panel suites. + +**Stage 2 — GPU band (own re-framing; next available protocol version).** + +37. `PanelFrame` round-trips, including `panel_epoch` and `geometry_epoch`; + independent **byte pins on the previous final + `InstanceMessage::InitialTargetResult` and + `FrontendEvent::TerminalPointer` variants** catch a shift in either + extended enum. +38. Full lifecycle: **open → replace buffer → hidden by a tiny frame → + reappear → close**, with authoritative `Absent` at hide/close and a new + epoch on replacement/reappearance. +39. An invalid `PanelFrame` is rejected atomically; the previous valid frame is + retained. A duplicate valid frame (including duplicate `Absent`) does no + work. Shared cell/topology/glyph/area validation accepts an area-bounded + panel wider than 512 columns, while terminal frames retain their 512-column + PTY cap; maximum legal panel encoding stays below the transport limit. +40. **First open at a non-80×24 frame before any valid panel baseline** remains + absent until real `FrontendCellGeometry` arrives, then produces the correct + grid without consulting the 24×80 attach placeholder. +41. Pixel→cell conversion is pinned at fractional widths/heights: status band, + `TEXT_TOP`, potential divider, virtual status row, full-width monospace + columns, and floor rounding agree. Geometry refreshes on window resize, + font change, and scale change; the daemon alone derives the grid. After a + new `geometry_epoch` is sent, an older retained frame neither paints nor + accepts input until a matching `Present` arrives; `Absent` remains an + always-safe removal, and stale/conflicting epochs reject. A requested panel + whose rows×cols would exceed the shared wire-area budget is row-clamped + without losing its stored request, or hidden when even two rows cannot fit. + Zero/non-finite/non-positive metric inputs fail closed to zero usable + geometry without overflow or an oversized allocation. +42. **Focus into and out of a terminal panel while the document stays visible + and unchanged**: no `BufferSnapshot` re-send, no document suppression, no + mirror swap, no `CursorByte` for the panel buffer, no line-number, + selection-decoration, document-terminal declaration, or document + statusline replacement/clear with the panel buffer. Document statusline + callbacks may truthfully observe `active = false`; presence reports the + focused panel context. The GPU replica's `active_buffer` and authoritative + cursor remain the primary document buffer/cursor while `DispatchIdle` is + false, and the revised protocol/client contract tests name that distinction. +43. **Focusing a fresh generated panel buffer triggers no lazy-CRDT-upgrade + broadcast** (§1.3 #2 — the case rev 2 could not see). With semantic peer A + focused in panel P over document D, a target launch/upgraded-buffer + publication for D still reaches A, while one visible only as P does not + replace A's document mirror (§1.3 #21). +44. A document `Viewport` naming the document buffer while the panel is focused + aligns the **primary document window** and **does not move focus**; a + document `Pointer` aligns **and** activates the document window. With a + full-window document terminal under a focused panel, its viewport and + `TerminalResize` remain accepted, bare `TerminalPointer::Move` does not + focus or claim, and every accepted non-hover terminal gesture activates + the document before replay. +45. From a focused panel, `M-x` opens/types/closes a visible + `MinibufferPrompt`; isearch keeps its semantic prompt while panel washes + paint in the grid. A new focused `PanelFrame` arrives before the + panel-buffer `SearchPrompt`, which the GPU accepts without changing its + document mirror; on hide/close/focus-out, the old panel prompt clears + before its focused declaration is removed. A prompt naming neither current + surface is ignored. + Document→panel focus authoritatively clears a native document + menu/completion popup, while panel menu/completion overlays paint only in + `PanelFrame`; returning to the document reverses ownership cleanly. + The global prompt/clear pass also works before a document viewport exists + and while the primary document is a full-window terminal. + One statusline provider invocation supplies the primary-document wire + segments and panel mode line; a provider that mutates the layout + invalidates stale results and reconciliation runs before paint. +46. The band + divider shrink the document text area by exactly their pixel + height; document carets, hits, and scroll geometry respect the reduced + area. `Present`/`Absent` refresh the ordinary document `Viewport` or + full-window `TerminalResize` without sending a new whole-frame geometry + declaration. +47. Dragging the divider sends + `PanelResizeRows {geometry_epoch, panel_epoch, rows}` and honors + `window.min-height`; hover shows `CursorIcon::RowResize`. A stalled-writer + outbox tail-coalesces repeated resize rows and whole-frame geometry + declarations without crossing an intervening event or exhausting the + lossless queue. +48. `PanelPointer` drives listview row selection, panel selection, terminal + mouse reporting, and click-to-focus without disturbing the document mirror. + A terminal panel's non-`Move` wheel/press/drag/release first activates it + so controller ownership remains consistent; hover does neither. Keyboard + motion beyond a focused panel's viewport runs the extracted active-window + auto-scroll clamp, while a passive panel preserves `view_top`. Panel + move/drag tails coalesce; press/release/context/wheel remain lossless and + ordered. +49. Stale panel events are dropped before mutation for all four cases: + A→B replacement (`buffer_id`), close/reopen of the same A, and + hide/reappear of A (`panel_epoch` / latest-`Present` check), plus a + font/scale/resize declaration race (`geometry_epoch`). `Absent` clears + declared panel size/presentation epoch on both sides without discarding the + whole-frame geometry declaration. +50. `PanelResizeRows` / `PanelPointer` from a source with no visible current + `Present` panel are dropped. `FrontendCellGeometry` from the correctly + negotiated semantic source is accepted without a side window; grid, + pre-panel, forged-source, and wrong-version variants are rejected. +51. **Mixed session**: a pre-panel semantic frontend falls back to a document + window — with every side-specific parameter discarded, leaving the document + window undedicated (Q#BP2c) — while a grid frontend on the same daemon gets + its side window. With `LOCAL` focused in that panel, a fresh no-target + semantic attach inherits `LOCAL`'s primary document buffer, never the + panel buffer (§1.3 #22). +52. A panel projected for a `fold_projection = false` frontend does **not** + collapse folds; the stale comment at `src/window.rs:339` is updated in the + same PR. +53. Bell drain remains focus/session-scoped: a focused panel terminal rings + once per frontend, while passive and historical bells remain suppressed. +54. A `--headless-probe` run drives one real daemon + real PTY + real wgpu + through a panel-hosted terminal, followed by the full gate suite for the + Stage 2 PR. +55. A v20 initial-target attach whose `after-load`/`after-switch` hook + creates and selects a side window still reasserts the requested buffer in + and activates a non-side document window without overwriting the panel. + Closing the original document window in the hook rehomes the target to a + remaining eligible non-side window without a second hook; killing the + target buffer still fails bootstrap. The target snapshot precedes matching + `InitialTargetResult::Opened` exactly as in #148. + +**Stage 3 — adopter default flip.** + +56. Omitting `display` from real listview, compile, and terminal entry points + resolves to the Q#BP12 panel/select policy on a panel-capable grid and + semantic frontend; explicit `display = "current"` preserves each + adopter's pre-arc selected-window behavior. +57. On a pre-panel semantic frontend, the omitted Stage 3 default takes + capability fallback with no side parameters or quit action left on the + document window; its visit and `q` paths remain the existing non-side ones. +58. Updated default-placement suites exercise + open→visit→return→quit through each adopter rather than a generic helper, + preserving the Stage 1 unknown-value rollback assertions; the Stage 3 PR + then runs the full gate suite. + +## 6. Deferred (named) + +Left / right / top side windows; multiple slots per side; **rehoming a leaf +across the tree**; the entire **`no_other_window` parameter and destination-only +traversal semantics**; manual panel hide/show and a future +`window.toggle-panel`; user-facing `display-buffer-alist`-style rules; **GPU +document splits (Arc 8)**; panel +persistence (blocked on settings persistence); `OSC 22` pointer shape in the +TUI; per-panel statusline segments on the wire; proportional-font panels in the +GPU; `window-configuration` registers; atomic windows; panel-local keymaps +beyond buffer and mode scopes; horizontal (`C-x {`/`}`) resize. + +## 7. Interaction with other work + +- **Folding Stage 2 has landed** (#149, runtime base `6ed4fe9`) — the blocking + dependency, now cleared and re-verified against `ddaa80d` in §0.6 (nothing in + flight, suite green, every borrowed anchor reproducing). Canonical `main` is + now `ddaa80d`; any eventual branch starts from current canonical main. + Folding's + `FrontendView` policy-bit pattern is Q#BP13's model, its `Viewport<'a>` is what + Q#BP8 inherits, and Q#BP17 owns the one invariant this arc invalidates. + **Folding Stage 3 (GPU)** and this arc's Stage 2 both touch the semantic + projection; whichever is framed second re-scouts the other's landed state. +- **GPU initial target #148 has landed** at runtime commit `0dd16a5` and owns + protocol v20; #152 then refreshed only the durable handoff/active-work + documentation at canonical `main` `ddaa80d`. + Its attach transaction, `build_fresh_frontend_view`, private target loader, + semantic snapshot publication filter, and previous-final wire variant were + all re-scouted in §0.5. Q#BP9 now starts from v20; Q#BP11b shares the landed + load seam without routing bootstrap through Lua; Q#BP14 covers both the + publication predicate and no-target buffer inheritance. There is no + remaining branch-order dependency on #148. +- **DAP** stays parked until this arc's **Stage 1** lands, then re-baselines its + §0 touch census. Its Stage 2 panels become `display` + `display_file` calls. + +## 8. Prior art in pmacs + +Folding Stage 2 (`docs/folding-stage2-framing.md`, `src/fold_view.rs`) for the +per-`FrontendView` policy bit, the non-`Default` discipline, and per-window map +derivation; Vterm Stage 2 for the controller model, the `C-c` escape, and +per-view projection; Vterm Stage 3 for the whole-grid frame message, `validate`, +payload-complete suppression, stale-declaration rejection (extended here from +terminal-unique `buffer_id` to a panel presentation epoch), and the +`--headless-probe` seam; `listview.lua` for what a panel needs and currently +fakes; `src/desktop.rs:444-452` for activate-then-fire-per-leaf; M11.6's +`DispatchIdle` for the input gate. From 6c8a76e235e004f57b057de41f4520331979a56f Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 24 Jul 2026 13:53:14 -0400 Subject: [PATCH 02/22] feat(window): window parameters, fixed extents, and the display policy Stage 1 substrate for the bottom-panel arc (docs/bottom-panel-framing.md). - `WindowParams` (side / fixed_rows / dedicated + implementation-owned quit action and remembered document origin), `Side`, `QuitAction` with a bounded replacement history, and the `MIN_WINDOW_OUTER_ROWS` floor. - `Layout::compute(area, fixed)` allocates fixed rows before dividing the remainder by weight; both production callers feed the same shared map, including the peer-presence overlay pass that derives its own rect. - `subtree_min_rows` / `interactive_min_rows`: the recursive minima, and `boundary_below` for the shared drag / keyboard resize boundary rule. - `FrontendView` gains `panel_capable`, `frame_geometry`, and the derived `panel_hidden`, each spelled explicitly at every construction site. - `EditorCore`: `primary_document_window`, the non-side target rule, `display_buffer` + placement policy, `quit_window`, side-window removal on `kill_buffer`, per-frontend jump entries with origin windows, and the shared resolve/load-without-switch seam the initial-target bootstrap now uses too. - `EditorState`: the panel reconciliation transaction, geometry declaration, the side-window `dispatch_idle_for` gate, divider paint, and divider drag. - `pmacs.window.display / display_file / quit / panel / params / set_params / resize / display_target`, plus `builtin/runtime/window.lua` with `window.panel-height`, `window.min-height`, and the resize commands. Co-Authored-By: Claude Opus 5 (1M context) --- builtin/runtime/window.lua | 58 + src/daemon.rs | 143 ++- src/desktop.rs | 14 + src/editor.rs | 317 ++++- src/editor_core.rs | 1459 ++++++++++++++++++++++- src/lua_bindings/mod.rs | 36 +- src/lua_bindings/window_panel.rs | 499 ++++++++ src/overlay_paint.rs | 7 +- src/window.rs | 597 +++++++++- tests/folding_stage2_acceptance.rs | 3 + tests/statusline_segments_acceptance.rs | 3 + tests/vterm_stage3_acceptance.rs | 3 + 12 files changed, 3027 insertions(+), 112 deletions(-) create mode 100644 builtin/runtime/window.lua create mode 100644 src/lua_bindings/window_panel.rs diff --git a/builtin/runtime/window.lua b/builtin/runtime/window.lua new file mode 100644 index 0000000..cf63443 --- /dev/null +++ b/builtin/runtime/window.lua @@ -0,0 +1,58 @@ +-- window.lua --- side-window settings, quit, and keyboard resize. +-- +-- The Lua half of the bottom-panel arc's window surface. The placement +-- policy itself is Rust (`pmacs.window.display` / `display_file` / +-- `quit` / `resize`); this module owns the two settings those paths +-- resolve, plus the interactive commands and their Emacs bindings. +-- +-- Both settings are read against the window's OWN buffer (buffer-local +-- override -> global -> default), so a project or a mode hook can pin a +-- taller panel for one buffer with `pmacs.config.set_local`. +-- +-- Framing: docs/bottom-panel-framing.md (Q#BP2, Q#BP5b, Q#BP11). + +-- Outer rows (text + mode line) a freshly created panel takes when the +-- caller supplies no explicit `height`. Only consulted at CREATION: a +-- replacement preserves whatever height the user dragged the slot to. +pmacs.config.define { + name = "window.panel-height", + description = "Outer rows a newly created bottom panel occupies.", + type = "integer", + default = 12, + min = 2, + mutability = "live", +} + +-- A preference, not a structural rule: it constrains INTERACTIVE resize +-- (drag and the commands below) and is deliberately ignored by the +-- ordinary layout pass and by frame-resize reconciliation, so raising it +-- can never invalidate a layout that already exists. +pmacs.config.define { + name = "window.min-height", + description = "Smallest outer rows interactive resize will leave a window.", + type = "integer", + default = 2, + min = 2, + mutability = "live", +} + +pmacs.command.define { + name = "window.quit", + description = "Quit the selected side window: restore or delete it", + fn = function() pmacs.window.quit() end, +} + +pmacs.command.define { + name = "window.enlarge", + description = "Make the selected window one row taller", + fn = function() pmacs.window.resize(nil, 1) end, +} + +pmacs.command.define { + name = "window.shrink", + description = "Make the selected window one row shorter", + fn = function() pmacs.window.resize(nil, -1) end, +} + +pmacs.keymap.bind { scope = "global", sequence = "C-x ^", command = "window.enlarge" } +pmacs.keymap.bind { scope = "global", sequence = "C-x C-^", command = "window.shrink" } diff --git a/src/daemon.rs b/src/daemon.rs index 98a688a..cb82c3b 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -894,6 +894,21 @@ fn peer_declared_terminal_support( .is_some_and(|state| state.negotiated_protocol_version >= 19) } +/// Whether a session can **render** a side window (bottom-panel arc, +/// Q#BP13). +/// +/// Grid sessions paint the whole cell grid the daemon composes, so a side +/// window is just another leaf for them. A semantic session needs the +/// Stage 2 `PanelFrame` band, which does not exist yet — so Stage 1 +/// answers `false` for every semantic peer, whatever it declares. No +/// client-asserted standalone boolean is trusted: the answer is derived +/// from the daemon's own negotiated state, and Stage 2 turns the version +/// arm on (`semantic_render && negotiated_protocol_version >= +/// PANEL_MIN_VERSION`). +fn peer_declared_panel_support(session_state: &crate::presence::SessionState) -> bool { + !session_state.negotiated_capabilities.semantic_render +} + /// The same belt-and-braces write-loop gate for the additive /// protocol-v19 terminal frame. The semantic producer skips construction /// for an older peer; this filter independently prevents an unknown @@ -1628,38 +1643,40 @@ fn open_initial_target( target: InitialTarget, ) -> Result { let path = resolve_initial_target(target); - let display_path = path.display().to_string(); - let (buffer_id, newly_loaded, newly_created) = { + // Bottom-panel arc (Q#BP11b, R4-B4): capture the fresh view's + // ORIGINAL document window before any I/O. A startup hook may now + // create and select a side window, and bootstrap must reassert the + // requested buffer in a document window rather than overwriting a + // panel merely because it became `view.active`. + let (origin_window, buffer_id, fire) = { let mut core = editor.core.borrow_mut(); core.active_frontend = frontend_id; - let (buffer_id, newly_loaded, newly_created) = match core.get_or_load_buffer(&path) { - Ok((buffer_id, newly_loaded)) => (buffer_id, newly_loaded, false), - Err(error) if error.kind() == ErrorKind::NotFound => { - let buffer_id = core.registry.borrow_mut().create(display_path.clone()); - core.set_buffer_path(buffer_id, Some(path.clone())); - "[new file]".clone_into(&mut core.status); - (buffer_id, false, true) - } - Err(error) => { - return Err(format!("cannot open {}: {error}", path.display())); - } - }; - core.switch_active_buffer_for(frontend_id, buffer_id) + let origin_window = core + .primary_document_window(frontend_id) + .ok_or_else(|| "attaching frontend has no document window".to_string())?; + let (buffer_id, fire) = core.resolve_target_buffer(&path)?; + core.install_buffer_in_window(origin_window, buffer_id) .map_err(|error| format!("cannot select {}: {error}", path.display()))?; - (buffer_id, newly_loaded, newly_created) + core.focus_window(frontend_id, origin_window); + (origin_window, buffer_id, fire) }; - if newly_loaded { - editor - .lua_host - .run_hook("buffer.after-load", mlua::MultiValue::new()); - } else if !newly_created { + match fire { + crate::editor_core::HookKind::AfterLoad => { + editor + .lua_host + .run_hook("buffer.after-load", mlua::MultiValue::new()); + } // Dedup is a logical switch even when the fresh view already shares // this BufferId; configuration must observe it exactly once. - editor - .lua_host - .run_hook("buffer.after-switch", mlua::MultiValue::new()); + crate::editor_core::HookKind::AfterSwitch => { + editor + .lua_host + .run_hook("buffer.after-switch", mlua::MultiValue::new()); + } + crate::editor_core::HookKind::None => {} } + editor.reconcile_panel_layout(frontend_id); let mut core = editor.core.borrow_mut(); core.active_frontend = frontend_id; @@ -1669,11 +1686,28 @@ fn open_initial_target( path.display() )); } - core.switch_active_buffer_for(frontend_id, buffer_id) + // Reassert into the original document window when it is still live; + // if a hook closed it, rehome to an eligible non-side window in the + // same frontend WITHOUT firing a second hook. + let destination = if core + .views + .get(&frontend_id) + .is_some_and(|view| view.layout.iter_ids().contains(&origin_window)) + { + origin_window + } else { + core.non_side_target(frontend_id) + .map_err(|error| format!("cannot reselect {}: {error}", path.display()))? + }; + core.install_buffer_in_window(destination, buffer_id) .map_err(|error| format!("cannot reselect {}: {error}", path.display()))?; + core.focus_window(frontend_id, destination); Ok(OpenedInitialTarget { buffer_id, - publish_to_replicas: newly_loaded || newly_created, + publish_to_replicas: matches!( + fire, + crate::editor_core::HookKind::AfterLoad | crate::editor_core::HookKind::None + ), }) } @@ -1766,9 +1800,15 @@ fn handle_session_established( // `RenderState` vs a `SemanticRenderState` below — a grid session // collapses folds, a semantic one keeps raw-line reckoning until // Stage 3. + // Bottom-panel arc (Q#BP13): panel capability comes from the SAME + // negotiated bit in this same transaction. Stage 1 ships the TUI + // side windows only, so a semantic session is not panel-capable and + // a `side` request falls back to its document target with every + // side-specific parameter discarded. let fresh_view = build_fresh_frontend_view( editor, !session_state.negotiated_capabilities.semantic_render, + peer_declared_panel_support(&session_state), ); { let mut core = editor.core.borrow_mut(); @@ -1850,6 +1890,14 @@ fn handle_session_established( } streams.insert(frontend_id, write_stream); term_sizes.insert(frontend_id, initial_size); + // Bottom-panel arc (Q#BP2b): a grid session's real attach size IS its + // authoritative geometry declaration, cached BEFORE any input can + // reach it. A semantic session deliberately stays UNKNOWN — Stage 2's + // authenticated `FrontendCellGeometry` fills it, and the permanent + // 24x80 attach placeholder is never consulted for panel layout. + if editor.core.borrow().panel_capable_for(frontend_id) { + editor.sync_frame_geometry(frontend_id, initial_size); + } if let Some(opened) = opened_target { last_active_buffer_sent.insert(frontend_id, opened.buffer_id); @@ -1933,6 +1981,13 @@ fn handle_dispatcher_event( if let Some(ts) = term_sizes.get_mut(&source) { *ts = size; } + // Bottom-panel arc (Q#BP2b): a frame that can no + // longer satisfy the panel hides it, moves focus out, + // and releases its terminal controller here — before + // the next drained event dispatches. + if editor.core.borrow().panel_capable_for(source) { + editor.sync_frame_geometry(source, size); + } } #[cfg(feature = "crdt")] FrontendEvent::CrdtOp { @@ -2938,6 +2993,10 @@ fn build_fresh_frontend_view( // collapses folds. Passed explicitly from the negotiated // selected-render bit at the call site — never inferred here. fold_projection: bool, + // Bottom-panel arc (Q#BP13): whether this session can RENDER a side + // window. Same explicit-at-the-call-site discipline as + // `fold_projection`; never inferred from a `FrontendId` here. + panel_capable: bool, ) -> crate::window::FrontendView { use crate::text_view::TextView; use crate::window::{FrontendView, Layout, Window, WindowId}; @@ -2946,16 +3005,14 @@ fn build_fresh_frontend_view( // scratch). M10.8's fresh-scratch behavior made overlays // never fire because attaching frontends were in distinct // buffers. - let local_view = core - .views - .get(&FrontendId::LOCAL) - .expect("LOCAL view present"); - let local_active_win_id = local_view.active; + // + // Bottom-panel arc (§1.3 #22): clone LOCAL's PRIMARY DOCUMENT + // buffer, not `local_view.active`. A TUI panel may own focus at + // attach time, and panel content must never become a newly attached + // frontend's full-window document. let buffer_id = core - .windows - .get(&local_active_win_id) - .expect("LOCAL's active window present in core.windows") - .buffer_id; + .primary_document_buffer(FrontendId::LOCAL) + .expect("LOCAL always retains a document window"); let text_view = { let reg = core.registry.borrow(); let buf = reg.get(buffer_id).expect("shared buffer present"); @@ -2968,6 +3025,13 @@ fn build_fresh_frontend_view( layout: Layout::single(id), active: id, fold_projection, + panel_capable, + // Grid sessions cache their real attach/resize size; a semantic + // session stays UNKNOWN until Stage 2's authenticated + // declaration, and must never be sized against the attach + // request's permanent 24×80 placeholder (Q#BP15a). + frame_geometry: None, + panel_hidden: false, } } @@ -3233,7 +3297,7 @@ mod tests { let semantic_peer = FrontendId(20); let live_grid_peer = FrontendId(21); let dead_grid_peer = FrontendId(22); - let semantic_view = build_fresh_frontend_view(&mut editor, false); + let semantic_view = build_fresh_frontend_view(&mut editor, false, false); editor .core .borrow_mut() @@ -3885,6 +3949,9 @@ mod tests { layout: Layout::single(wid), active: wid, fold_projection: true, + panel_capable: true, + frame_geometry: None, + panel_hidden: false, }, ); } @@ -4017,7 +4084,7 @@ mod tests { let fid = FrontendId(99); // Both these fixtures model a SEMANTIC session (Q#FD21: no fold // projection until Stage 3). - let view = build_fresh_frontend_view(&mut editor, false); + let view = build_fresh_frontend_view(&mut editor, false, false); editor.core.borrow_mut().register_frontend_view(fid, view); let before = editor @@ -4080,7 +4147,7 @@ mod tests { let fid = FrontendId(99); // Both these fixtures model a SEMANTIC session (Q#FD21: no fold // projection until Stage 3). - let view = build_fresh_frontend_view(&mut editor, false); + let view = build_fresh_frontend_view(&mut editor, false, false); editor.core.borrow_mut().register_frontend_view(fid, view); assert_eq!( editor diff --git a/src/desktop.rs b/src/desktop.rs index 7f44524..16c15cc 100644 --- a/src/desktop.rs +++ b/src/desktop.rs @@ -264,6 +264,14 @@ pub fn snapshot(core: &EditorCore, session_key: String) -> Option let resolve = |wid: WindowId| -> Option { let win = core.windows.get(&wid)?; + // Bottom-panel arc (Q#BP10): side windows are transient display + // policy, never desktop state. Dropping the leaf here makes the + // existing single-surviving-child collapse remove the root + // wrapper too, so the saved tree is the document tree exactly — + // no `SavedLeaf` shape change and no `DESKTOP_VERSION` bump. + if win.is_side() { + return None; + } let path = reg.get(win.buffer_id).ok()?.file_path()?; Some(SavedLeaf { path: path.display().to_string(), @@ -437,6 +445,12 @@ pub fn restore_into( active, // Desktop restore rebuilds LOCAL's grid view (Q#FD21). fold_projection: true, + // …which renders side windows natively (Q#BP13). Every + // field is spelled explicitly, preserving folding's + // non-`Default` discipline. + panel_capable: true, + frame_geometry: None, + panel_hidden: false, }, ); active diff --git a/src/editor.rs b/src/editor.rs index 3cf59e6..d6c6a51 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -161,6 +161,11 @@ pub struct EditorState { /// Last left-button down event, used to synthesize terminal double /// clicks from crossterm's plain Down/Up mouse event stream. mouse_click: Option, + /// In-progress split-boundary drag (bottom-panel arc, Q#BP5), armed + /// by a left press on a mode-line row that is an exposed segment of a + /// horizontal boundary. Lives beside `mouse_click`; selection is + /// untouched for the whole gesture. + window_drag: Option, } #[derive(Default)] @@ -208,8 +213,26 @@ struct MouseClickState { at: Instant, } +/// An armed split-boundary drag (Q#BP5). +/// +/// `owner` is the window whose bottom mode-line row was pressed; the +/// boundary it resolves to is recomputed on every motion, so a layout +/// mutation mid-drag cannot move a boundary that no longer exists. +#[derive(Copy, Clone)] +struct WindowDragState { + frontend_id: FrontendId, + owner: WindowId, + last_row: u32, +} + const DOUBLE_CLICK_MAX_DELAY: Duration = Duration::from_millis(500); +/// Grip glyph stamped at the right end of a divider segment (Q#BP5a). +/// +/// It lands on the mode line's protected trailing blank, so it adds no +/// column and clobbers no information. +const DIVIDER_HANDLE_GLYPH: char = '⇕'; + impl EditorState { /// Construct a fresh editor for an unnamed scratch buffer. /// @@ -488,6 +511,16 @@ impl EditorState { include_str!("../builtin/runtime/indent.lua"), ) .expect("load indent builtin chunk"); + // Bottom-panel arc: `window.panel-height` / `window.min-height` + // plus the quit and keyboard-resize commands. Must load BEFORE + // listview/compile/terminal, which resolve `window.panel-height` + // when they open a panel. + lua_host + .eval( + Some("@pmacs/builtin/runtime/window.lua"), + include_str!("../builtin/runtime/window.lua"), + ) + .expect("load window builtin chunk"); // Compile-mode (Arc 5 stage 1, Q#CM1) — ORDERING CONTRACT: // compile.lua must load AFTER lsp.lua. It takes over // `M-g n` / `M-g p` for the unified error dispatchers, and @@ -586,6 +619,7 @@ impl EditorState { snippets, statusline_registry, mouse_click: None, + window_drag: None, } } @@ -763,9 +797,74 @@ impl EditorState { && !core.search_active() && !core.query_replace_active() && !core.menu_is_open() - && core - .active_window_for(frontend_id) - .is_some_and(|window| !core.buffer_round_trips(window.buffer_id)) + && core.active_window_for(frontend_id).is_some_and(|window| { + // Bottom-panel arc (Q#BP14a): a focused SIDE window turns + // optimistic apply off for this frontend, independently + // of the buffer-global round-trip set. + // + // Marking the panel's BUFFER round-trip instead would be + // wrong twice: `round_trip_buffers` is keyed by + // `BufferId` across every frontend and window, so it + // would disable optimistic input for another frontend + // editing the same buffer as its document; and an opt-out + // would be unsafe, because the GPU would optimistically + // edit its document mirror while daemon input targets the + // panel — every resulting op then fails remote-op + // validation and the mirror silently diverges. + !window.is_side() && !core.buffer_round_trips(window.buffer_id) + }) + } + + /// The idempotent panel-reconciliation transaction (Q#BP2b). + /// + /// Runs after attach / resize / display / split / close, after any + /// `fixed_rows` or setting change, after any Lua hook or callback + /// transaction that can mutate the layout, and **defensively** before + /// final-focus resolution, input dispatch, terminal sync, and paint. + /// Two events drained in one burst therefore cannot route the second + /// to a panel the first made invisible, and a render callback cannot + /// leave stale panel geometry for the painter. + pub fn reconcile_panel_layout(&self, frontend_id: FrontendId) -> bool { + let outcome = self + .core + .borrow_mut() + .reconcile_panel_layout_core(frontend_id); + if let Some(window_id) = outcome.released_terminal { + // Hiding is a DURABLE transition: the terminal resize path + // merely returns on zero content without releasing the + // controller, so an invisible panel would otherwise keep + // owning its child. + let buffer_id = self + .core + .borrow() + .windows + .get(&window_id) + .map(|window| window.buffer_id); + if let Some(buffer_id) = buffer_id { + let _ = self + .terminal_manager + .borrow_mut() + .release_controller(crate::terminal::TerminalViewKey { + frontend_id, + window_id, + buffer_id, + }); + } + } + outcome.changed + } + + /// Cache one frontend's authoritative frame capacity and reconcile + /// (Q#BP2b / Q#BP15a). + /// + /// The single seam for grid and `LOCAL` views, whose real attach and + /// resize sizes ARE the declaration. A semantic view never calls this + /// in Stage 1; its geometry stays **unknown**. + pub fn sync_frame_geometry(&self, frontend_id: FrontendId, total: CellSize) { + self.core + .borrow_mut() + .declare_frame_geometry(frontend_id, total); + self.reconcile_panel_layout(frontend_id); } /// Local-frontend compatibility wrapper. @@ -800,6 +899,10 @@ impl EditorState { // Authenticate every path through this input event, including modal // callbacks such as M-x minibuffer acceptance. let _origin = self.interactive_origin.enter(frontend_id); + // Bottom-panel arc (Q#BP2b): reconcile defensively before input + // dispatch, so two events drained in one burst cannot route the + // second to a panel the first made invisible. + self.reconcile_panel_layout(frontend_id); let chord = key_event_to_chord(key); { let mut core = self.core.borrow_mut(); @@ -1084,6 +1187,10 @@ impl EditorState { /// /// This is called before process drain and paint, never from rendering. pub fn sync_terminal_layout(&mut self, frontend_id: FrontendId, term_size: CellSize) -> bool { + // Bottom-panel arc (Q#BP2b): a panel that just became + // unsatisfiable must have released its controller before this + // runs, or the child would be resized against a dead rect. + self.reconcile_panel_layout(frontend_id); let Some(key) = self .terminal_manager .borrow() @@ -1815,6 +1922,21 @@ impl EditorState { return; } + // Bottom-panel arc (Q#BP5): an armed divider drag owns the + // pointer for the whole gesture, INCLUDING rows outside any + // window — otherwise tracking would stop the moment the pointer + // crossed the frame's status row. + if self.window_drag.is_some() { + match ev.kind { + MouseEventKind::Drag(MouseButton::Left) => { + self.drag_window_boundary(frontend_id, cell_row, term_size); + } + MouseEventKind::Up(MouseButton::Left) => self.window_drag = None, + _ => self.window_drag = None, + } + return; + } + let Some((win_id, rect)) = window_at_cell( &self.core.borrow(), frontend_id, @@ -1826,6 +1948,15 @@ impl EditorState { }; let inner_rows = rect.size.rows.saturating_sub(1); let local_row = cell_row.saturating_sub(rect.origin.row); + // A press on a mode-line row that is an exposed segment of a + // horizontal boundary arms a divider drag, ahead of the terminal + // router: a document terminal above the panel owns a boundary + // too. Selection is untouched, so this click still creates none. + if matches!(ev.kind, MouseEventKind::Down(MouseButton::Left)) && local_row >= inner_rows { + self.mouse_click = None; + self.arm_window_drag(frontend_id, win_id, cell_row); + return; + } let buffer_id = self.core.borrow().windows[&win_id].buffer_id; if self.terminal_manager.borrow().is_terminal(buffer_id) { let content_size = CellSize::new(inner_rows, rect.size.cols); @@ -1928,6 +2059,112 @@ impl EditorState { } } + /// Arm a divider drag if `owner`'s bottom row really is an exposed + /// segment of a horizontal boundary (Q#BP5). + fn arm_window_drag(&mut self, frontend_id: FrontendId, owner: WindowId, cell_row: u32) { + let is_divider = self.core.borrow().views.get(&frontend_id).is_some_and(|view| { + view.layout.boundary_below(owner).is_some() + }); + self.window_drag = is_divider.then_some(WindowDragState { + frontend_id, + owner, + last_row: cell_row, + }); + } + + /// Continue an armed divider drag (Q#BP5). + /// + /// The boundary is re-resolved from `owner` on every motion, so a + /// layout mutation mid-drag cannot move a boundary that no longer + /// exists. Motion is applied incrementally and re-anchored each + /// event, so the clamp absorbs over-travel instead of accumulating it. + fn drag_window_boundary(&mut self, frontend_id: FrontendId, cell_row: u32, term_size: CellSize) { + let Some(drag) = self.window_drag else { + return; + }; + if drag.frontend_id != frontend_id { + return; + } + self.window_drag = Some(WindowDragState { last_row: cell_row, ..drag }); + let delta = i64::from(cell_row) - i64::from(drag.last_row); + let Ok(delta) = i32::try_from(delta) else { + return; + }; + if delta == 0 || term_size.rows < 2 { + return; + } + // A drag that runs into the clamp is a no-op, not an error to + // surface: the pointer simply cannot move the boundary further. + let _ = self.resize_window_boundary(frontend_id, drag.owner, delta, term_size.rows - 1); + } + + /// Move the boundary `win` owns by `delta_rows`, growing `win` + /// (Q#BP5 / Q#BP5b), under the interactive `window.min-height` + /// preference snapshotted before any geometry changes. + /// + /// Returns the core's pointed error, if any; a `no adjustable + /// horizontal boundary` result is a no-op by construction. + pub fn resize_window_boundary( + &self, + frontend_id: FrontendId, + win: WindowId, + delta_rows: i32, + area_rows: u32, + ) -> Result<(), String> { + // One gesture, one set of minima: resolved against each leaf's + // CURRENT buffer (buffer-local override → global → default) + // before the geometry moves. + let minima: HashMap = { + let core = self.core.borrow(); + core.views + .get(&frontend_id) + .map(|view| { + view.layout + .iter_ids() + .into_iter() + .map(|id| { + let buffer_id = core.windows.get(&id).map(|w| w.buffer_id); + (id, self.window_min_height(buffer_id)) + }) + .collect() + }) + .unwrap_or_default() + }; + let result = self.core.borrow_mut().resize_boundary( + frontend_id, + win, + delta_rows, + area_rows, + &|id| { + minima + .get(&id) + .copied() + .unwrap_or(crate::window::MIN_WINDOW_OUTER_ROWS) + }, + ); + if result.is_ok() { + self.reconcile_panel_layout(frontend_id); + } + result + } + + /// Resolve the `window.min-height` preference for a buffer, clamped + /// into `[MIN_WINDOW_OUTER_ROWS, …]` (Q#BP2). + /// + /// A core with no Lua host — or one whose runtime has not defined the + /// setting — falls back to the structural floor, so the preference + /// can never make an existing layout invalid. + #[must_use] + pub fn window_min_height(&self, buffer_id: Option) -> u32 { + crate::lua_bindings::config_u32( + self.lua_host.lua(), + "window.min-height", + buffer_id, + crate::window::MIN_WINDOW_OUTER_ROWS, + ) + .max(crate::window::MIN_WINDOW_OUTER_ROWS) + } + fn dispatch_terminal_mouse( &mut self, key: TerminalViewKey, @@ -2368,8 +2605,12 @@ pub(crate) fn window_placements( return HashMap::new(); }; let area = Rect::new(0, 0, term_size.rows - 1, term_size.cols); + // Bottom-panel arc (Q#BP2, R5-B1): both production `Layout::compute` + // callers feed in the SAME shared fixed map, so a side window's rows + // are identical in the placement pass and the peer-overlay pass. + let fixed = core.panel_fixed_rows(frontend_id, area.size.rows); view.layout - .compute(area) + .compute(area, &fixed) .into_iter() .map(|(window_id, outer)| { let content = Rect::new( @@ -2834,6 +3075,13 @@ pub fn paint_frame( if term_size.rows < 2 || term_size.cols == 0 { return None; } + // Bottom-panel arc (Q#BP2b/Q#BP15a): a grid frontend's real frame + // size IS its authoritative geometry declaration. Declaring and + // reconciling here — before the statusline fan-out and before the + // long mutable borrow — means the painter never sees stale panel + // geometry, and a panel the frame can no longer satisfy has already + // surrendered focus and its terminal controller. + state.sync_frame_geometry(frontend_id, term_size); // Statusline callbacks may call arbitrary editor APIs. Evaluate the // complete visible-window fan-out before the long mutable core borrow // below, then paint only the transactionally validated owned results. @@ -2871,6 +3119,21 @@ pub fn paint_frame( let placements = window_placements(core, frontend_id, term_size); let active = core.views.get(&frontend_id)?.active; + // Bottom-panel arc (Q#BP5a): the divider IS the upper subtree's + // existing mode-line row — no row is added or consumed, and + // `fixed_rows` excludes it. Resolved once per frame, before the + // mutable per-window loop borrows `core.windows`. A boundary whose + // upper child is a nested subtree exposes SEVERAL leaf segments along + // the same edge, so the root panel divider is full width even when + // the document subtree ends in several columns. + let divider_windows: Vec = core.views.get(&frontend_id).map_or_else(Vec::new, |view| { + view.layout + .iter_ids() + .into_iter() + .filter(|id| view.layout.boundary_below(*id).is_some()) + .collect() + }); + let divider_style = theme.face("ui.divider"); // Clear the whole grid first so windows that shrink on resize // don't leak the old contents. @@ -3094,6 +3357,12 @@ pub fn paint_frame( } drop(reg); + for id in ÷r_windows { + if let Some(placement) = placements.get(id) { + paint_divider_segment(grid, &placement.outer, divider_style); + } + } + paint_status_line(grid, core, &state.lua_host, dispatcher, term_size, &theme); // An active isearch owns the bottom row (its prompt + match @@ -3582,6 +3851,33 @@ fn mode_line_grapheme_width(graphemes: &[ModeLineGrapheme]) -> u32 { /// Paint complete graphemes at a logical signed origin. A grapheme that /// straddles either clip edge is omitted wholesale, so a wide glyph can never /// leave a dangling half-cell at a window or left/right collision boundary. +/// Restyle one exposed segment of a horizontal split boundary and stamp +/// its grip (Q#BP5a). +/// +/// The segment is the window's own mode-line row: the glyphs the mode +/// line already painted are preserved, only the *surface* changes, and +/// the grip lands on the protected suffix's trailing blank. `ui.divider` +/// resolves through the ordinary `ui.*` face walk, so an unset face +/// leaves today's mode-line surface untouched and the affordance is the +/// grip alone. +fn paint_divider_segment( + grid: &mut crate::cell::CellGrid<'_>, + rect: &crate::window::Rect, + style: Option, +) { + if rect.size.rows == 0 || rect.size.cols == 0 { + return; + } + let row = rect.origin.row + rect.size.rows - 1; + if let Some(style) = style { + for col in 0..rect.size.cols { + grid.at(CellCoord::new(row, rect.origin.col + col)).style = style; + } + } + let cell = grid.at(CellCoord::new(row, rect.origin.col + rect.size.cols - 1)); + cell.glyph = crate::cell::Glyph::Char(DIVIDER_HANDLE_GLYPH); +} + fn paint_mode_line_graphemes( grid: &mut crate::cell::CellGrid<'_>, rect: &crate::window::Rect, @@ -6360,7 +6656,8 @@ mod tests { let core = s.core.borrow(); assert_eq!(core.windows.len(), 8); let area = crate::window::Rect::new(0, 0, 40, 120); - let placements = core.active_layout().compute(area); + let fixed = core.panel_fixed_rows(core.active_frontend_key(), area.size.rows); + let placements = core.active_layout().compute(area, &fixed); assert_eq!(placements.len(), 8); for r in placements.values() { assert!(!r.is_empty(), "rect was empty: {r:?}"); @@ -6780,12 +7077,18 @@ mod tests { .core .borrow() .active_layout() - .compute(crate::window::Rect::new(0, 0, 24, 90)); + .compute( + crate::window::Rect::new(0, 0, 24, 90), + &std::collections::HashMap::new(), + ); let p2 = s .core .borrow() .active_layout() - .compute(crate::window::Rect::new(0, 0, 24, 60)); + .compute( + crate::window::Rect::new(0, 0, 24, 60), + &std::collections::HashMap::new(), + ); // Both should preserve the 2:1 ratio. Find the two windows // and verify the larger:smaller ratio is 2:1 in both. let wider1 = p1.values().map(|r| r.size.cols).max().unwrap(); diff --git a/src/editor_core.rs b/src/editor_core.rs index 37cdabf..9ab4bca 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -33,7 +33,10 @@ use crate::rope::Edit; use crate::rope::{Position, Range}; use crate::text_view::TextView; use crate::view::{DisplayCoord, View}; -use crate::window::{FrontendView, Layout, Orientation, Window, WindowId}; +use crate::window::{ + FrontendView, Layout, LayoutNode, MAX_PANEL_QUIT_DEPTH, MIN_WINDOW_OUTER_ROWS, Orientation, + QuitAction, Side, Window, WindowId, subtree_min_rows, +}; /// T M10.10 post-audit-round-3 F16 — origin of a queued CRDT op. /// @@ -57,6 +60,157 @@ pub enum CrdtOpOrigin { DaemonKey, } +/// One recorded jump origin (bottom-panel arc, Q#BP11c). +/// +/// `window_id` and `side_origin` are what make `M-,` correct once a panel +/// can be a separate window: restoring into the recorded window keeps the +/// document window untouched, and a *side* origin that no longer +/// revalidates is **skipped** rather than degrading to an active-window +/// switch — that degradation is exactly the duplicate-panel corruption +/// this design removes. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct JumpEntry { + /// Window the origin was recorded in. + pub window_id: WindowId, + /// Buffer displayed there at the time. + pub buffer_id: BufferId, + /// Cursor position to restore. + pub position: Position, + /// Whether `window_id` was a side window when recorded. + pub side_origin: bool, +} + +/// Which lifecycle hook Phase 2 of the display transaction must fire +/// **with the target window active** (Q#BP4 / Q#BP11b). +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum HookKind { + /// `buffer.after-switch` — a reuse, including a same-buffer no-op. + AfterSwitch, + /// `buffer.after-load` — a fresh load. saveplace, recentf, syntax + /// and LSP all require the document target to be active for this. + AfterLoad, + /// Nothing to fire (a newly created path-backed buffer for a + /// `NotFound` path, matching initial-target / local-startup). + None, +} + +/// A `display_buffer` request (Q#BP3). +/// +/// `height` and `dedicated` are deliberately option-valued at the policy +/// boundary: omission is **not** silently equivalent to an explicit +/// zero/false, which is what lets a user-resized panel keep its height as +/// compile and listview replace one another. +#[derive(Clone, Debug)] +pub struct DisplayRequest { + /// Buffer to display. + pub buffer_id: BufferId, + /// Exact target window. Mutually exclusive with `side`. + pub window: Option, + /// Requested side. Mutually exclusive with `window`. + pub side: Option, + /// Explicit requested outer rows for a side placement. + pub height: Option, + /// Explicit dedication for the installed presentation. + pub dedicated: Option, + /// Explicit final-focus request. Omission defaults to `false` for an + /// actual side target and `true` for an ordinary one; an explicit + /// value survives fallback unchanged. + pub select: Option, + /// The caller's resolved `window.panel-height`, used only when a side + /// slot is **created** with no explicit `height`. + pub default_panel_rows: u32, +} + +impl DisplayRequest { + /// A bare ordinary-placement request for `buffer_id`. + #[must_use] + pub fn new(buffer_id: BufferId) -> Self { + Self { + buffer_id, + window: None, + side: None, + height: None, + dedicated: None, + select: None, + default_panel_rows: crate::window::DEFAULT_PANEL_ROWS, + } + } +} + +/// What Phase 1 of the display transaction decided (Q#BP4). +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct DisplayOutcome { + /// Window the buffer was installed in. + pub target: WindowId, + /// The frontend's focused window before Phase 1 ran. + pub saved_active: WindowId, + /// Resolved final-focus request. + pub select: bool, + /// Whether this call created the side window — the adopter rollback + /// hook (a terminal whose session fails to start must remove the + /// wrapper it just created). + pub created_side: bool, +} + +/// What [`EditorCore::reconcile_panel_layout_core`] resolved (Q#BP2b). +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] +pub struct PanelReconciliation { + /// The panel's effective visibility after the transaction. + pub hidden: bool, + /// Whether `hidden` changed in this transaction — Stage 2 keys its + /// authoritative `PanelFrame::Absent` / fresh `Present` on this. + pub changed: bool, + /// A side window whose terminal controller the caller must release, + /// because focus just left an invisible panel. + pub released_terminal: Option, +} + +/// Row extent of an arbitrary subtree, derived from its leaves' computed +/// rects: leaves tile their parent, so the union's height is the node's. +fn node_row_extent(node: &LayoutNode, placements: &HashMap) -> u32 { + let ids = crate::window::node_ids(node); + let mut lo = u32::MAX; + let mut hi = 0u32; + for id in ids { + let Some(rect) = placements.get(&id) else { + continue; + }; + lo = lo.min(rect.origin.row); + hi = hi.max(rect.origin.row + rect.size.rows); + } + if lo == u32::MAX { 0 } else { hi - lo } +} + +/// What Phase 1 of `window.quit` did (Q#BP2c). +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum QuitOutcome { + /// The side window was closed and its wrapper collapsed. + Deleted { + /// Where focus landed, when the frontend still has a view. + focus: Option, + }, + /// A saved presentation was reinstalled; Phase 2 must fire the + /// ordinary switch hook so overlays reattach. + Restored { + /// The window that was restored. + target: WindowId, + /// The buffer now displayed there. + buffer_id: BufferId, + }, +} + +#[derive(Copy, Clone, Debug)] +struct Placement { + target: WindowId, + kind: PlacementKind, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +enum PlacementKind { + Ordinary, + Side { created: bool, replacing: bool }, +} + /// Live state of an in-progress incremental search (Q#SR5). /// /// Present only while an isearch is running (`EditorCore::search`); @@ -285,7 +439,16 @@ pub struct EditorCore { /// this without limit. Entries naming a now-removed buffer are /// skipped on pop (stale-handle safe, mirrors the registry's /// `Missing` contract). - pub jump_ring: Vec<(BufferId, Position)>, + /// + /// **Per frontend** (bottom-panel arc, Q#BP11c), matching + /// `command_history`. Once a panel is a separate window, an entry + /// must remember *which window* it was recorded in — otherwise `M-,` + /// from a source file would switch the **document** window to the + /// panel's buffer while the panel stays open, duplicating the + /// presentation. Keying the whole ring by frontend additionally + /// stops one frontend consuming or destroying another's navigation + /// trail; detach purges the vector. + pub jump_ring: HashMap>, /// In-buffer incremental search store (Q#SR1). Per-buffer query + /// matches + active index, written by the search session / /// `search.*` commands and read by the decorations producer @@ -388,6 +551,11 @@ impl EditorCore { active: id, // LOCAL is the in-process grid editor (Q#FD21). fold_projection: true, + // …and it renders side windows natively (Q#BP13). + panel_capable: true, + // Real geometry arrives with the first render/resize. + frame_geometry: None, + panel_hidden: false, }, ); Self { @@ -400,7 +568,7 @@ impl EditorCore { minibuffer: Minibuffer::new(), active_frontend: FrontendId::LOCAL, pending_crdt_ops: Vec::new(), - jump_ring: Vec::new(), + jump_ring: HashMap::new(), search_store: crate::search::make_shared_store(), theme: None, search: None, @@ -595,6 +763,10 @@ impl EditorCore { /// closing a window left others intact). pub fn unregister_frontend_view(&mut self, fid: FrontendId) { self.views.remove(&fid); + // Bottom-panel arc (Q#BP11c): a detached frontend's navigation + // trail dies with its view — its `WindowId`s are gone, and no + // other frontend may pop or destroy those entries. + self.jump_ring.remove(&fid); if self.active_frontend == fid { self.active_frontend = FrontendId::LOCAL; } @@ -668,10 +840,10 @@ impl EditorCore { /// Propagates a load failure (e.g. a since-deleted file) so restore /// can skip that leaf rather than abort. pub fn get_or_load_buffer(&mut self, path: &Path) -> std::io::Result<(BufferId, bool)> { - let normalized = normalize_buffer_path(path.to_path_buf()); - if let Some(id) = self.registry.borrow().find_by_path(&normalized) { + if let Some(id) = self.find_buffer_for_path(path) { return Ok((id, false)); } + let normalized = normalize_buffer_path(path.to_path_buf()); let (bytes, meta) = crate::file_io::load_file(path)?; let display_name = path.display().to_string(); let id = self @@ -683,6 +855,51 @@ impl EditorCore { Ok((id, true)) } + /// The buffer already bound to `path`, under the same normalization + /// [`Self::get_or_load_buffer`] uses — **side-effect free**, so a + /// target-aware display can resolve its destination *before* any I/O + /// (Q#BP11b step 1: an ineligible destination must fail without + /// loading the file). + #[must_use] + pub fn find_buffer_for_path(&self, path: &Path) -> Option { + let normalized = normalize_buffer_path(path.to_path_buf()); + self.registry.borrow().find_by_path(&normalized) + } + + /// The shared resolve/load-without-switch primitive behind both + /// `pmacs.window.display_file` and the daemon's initial-target + /// bootstrap (Q#BP11b). + /// + /// Returns the buffer plus the hook Phase 2 must fire **with the + /// destination window active**: `AfterSwitch` for a dedup hit + /// (including a same-buffer no-op), `AfterLoad` for a fresh load, and + /// `None` for a path that does not exist yet — a `NotFound` path + /// becomes an empty path-backed buffer and fires nothing, matching + /// the initial-target and local-startup contract. + /// + /// One primitive, so two path-normalization, dedup, and hook + /// transactions cannot drift apart. + /// + /// # Errors + /// Any load failure other than `NotFound`. + pub fn resolve_target_buffer( + &mut self, + path: &Path, + ) -> Result<(BufferId, HookKind), String> { + match self.get_or_load_buffer(path) { + Ok((buffer_id, true)) => Ok((buffer_id, HookKind::AfterLoad)), + Ok((buffer_id, false)) => Ok((buffer_id, HookKind::AfterSwitch)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let display_path = path.display().to_string(); + let buffer_id = self.registry.borrow_mut().create(display_path); + self.set_buffer_path(buffer_id, Some(path.to_path_buf())); + "[new file]".clone_into(&mut self.status); + Ok((buffer_id, HookKind::None)) + } + Err(error) => Err(format!("cannot open {}: {error}", path.display())), + } + } + /// Cursor of the active window (compatibility shim for callers /// migrated from pre-M2.8 code). #[must_use] @@ -796,11 +1013,27 @@ impl EditorCore { /// origin is evicted (front drop) — the user keeps the most /// recent trail, which is the one they're likely to unwind. pub fn push_jump(&mut self) { - let entry = (self.active_buffer_id(), self.cursor()); - if self.jump_ring.len() >= Self::JUMP_RING_CAP { - self.jump_ring.remove(0); + let fid = self.active_frontend; + let window_id = self.active_window_id(); + let entry = JumpEntry { + window_id, + buffer_id: self.active_buffer_id(), + position: self.cursor(), + side_origin: self + .windows + .get(&window_id) + .is_some_and(crate::window::Window::is_side), + }; + let ring = self.jump_ring.entry(fid).or_default(); + if ring.len() >= Self::JUMP_RING_CAP { + ring.remove(0); } - self.jump_ring.push(entry); + ring.push(entry); + } + + /// Drop one detached frontend's navigation trail (Q#BP11c). + pub fn purge_jump_ring(&mut self, fid: FrontendId) { + self.jump_ring.remove(&fid); } /// Pop the most recent jump origin and move there. Returns @@ -811,21 +1044,66 @@ impl EditorCore { /// it finds a live target or the ring empties), so a jump-back /// never lands on a missing buffer. The restored cursor is /// clamped to the (possibly now shorter) buffer length. + /// + /// # Origin windows (Q#BP11c) + /// + /// The entry restores into its **origin window** when that window is + /// live, belongs to the acting frontend's layout, is not a hidden + /// side window, and **still shows the recorded buffer**. A live panel + /// that has since been replaced does not resurrect its old buffer. + /// + /// When revalidation fails the entry degrades differently by origin + /// kind. A **non-side** origin falls back to today's active-window + /// switch. A **side** origin is *skipped*: switching a panel's buffer + /// into the document window is precisely the duplicate-presentation + /// corruption this design removes. pub fn jump_back(&mut self) -> bool { - while let Some((bid, pos)) = self.jump_ring.pop() { - if !self.registry.borrow().contains(bid) { + let fid = self.active_frontend; + loop { + let Some(entry) = self + .jump_ring + .get_mut(&fid) + .and_then(std::vec::Vec::pop) + else { + return false; + }; + if !self.registry.borrow().contains(entry.buffer_id) { continue; } - if self.active_buffer_id() != bid && self.switch_active_buffer(bid).is_err() { + let origin_valid = self + .views + .get(&fid) + .is_some_and(|view| view.layout.iter_ids().contains(&entry.window_id)) + && self + .windows + .get(&entry.window_id) + .is_some_and(|window| window.buffer_id == entry.buffer_id) + && !self.side_window_is_hidden(fid, entry.window_id); + if origin_valid { + self.set_active_window_id(entry.window_id); + } else if entry.side_origin { + continue; + } else if self.active_buffer_id() != entry.buffer_id + && self.switch_active_buffer(entry.buffer_id).is_err() + { continue; } - let clamped = pos.min(self.active_buffer_len()); + let clamped = entry.position.min(self.active_buffer_len()); let aw = self.active_window_mut(); aw.cursor = clamped; aw.goal_col = None; return true; } - false + } + + /// True when `win` is a side window on `fid` and that frontend's + /// panel is currently derived-hidden (Q#BP2b). + #[must_use] + fn side_window_is_hidden(&self, fid: FrontendId, win: WindowId) -> bool { + self.windows + .get(&win) + .is_some_and(crate::window::Window::is_side) + && self.views.get(&fid).is_some_and(|view| view.panel_hidden) } // ---- incremental search (Q#SR5) ---------------------------------------- @@ -2296,6 +2574,27 @@ impl EditorCore { // ---- window operations ------------------------------------------------- + /// [`Self::split_active`], refusing a side window (Q#BP6): the panel + /// is a leaf of the root-level wrapper, so splitting it would produce + /// a second, unallocatable side slot. + /// + /// # Errors + /// When the active window is a side window. + pub fn try_split_active( + &mut self, + orientation: Orientation, + same_buffer: bool, + ) -> Result { + if self + .windows + .get(&self.active_window_id()) + .is_some_and(crate::window::Window::is_side) + { + return Err("window.split: not available in a side window".into()); + } + Ok(self.split_active(orientation, same_buffer)) + } + /// Split the active window. Returns the new window's id. /// `same_buffer` controls whether the new window opens on the /// active buffer (Emacs default) or a fresh `*scratch*` buffer. @@ -2334,52 +2633,129 @@ impl EditorCore { /// Move focus to the next window in iteration order. pub fn focus_next(&mut self) { - let active = self.active_window_id(); - let next = self.active_layout().focus_next(active); - self.set_active_window_id(next); + self.focus_step(true); } /// Move focus to the previous window in iteration order. pub fn focus_prev(&mut self) { - let active = self.active_window_id(); - let prev = self.active_layout().focus_prev(active); - self.set_active_window_id(prev); + self.focus_step(false); } - /// Close the active window (unless it's the only one in this - /// frontend). Returns false if the active frontend's layout has a - /// single window. + /// Shared `C-x o` traversal, skipping a **hidden** side window + /// (Q#BP6): keys must never route to an invisible panel, and once it + /// reappears traversal reaches it normally again. + /// + /// Also the seam that refreshes `origin_document` (Q#BP2c): entering + /// the panel from document window B must retarget `display_target`, + /// panel visits, and a `Delete`-form `window.quit` at B rather than + /// at whichever window happened to create the panel. + fn focus_step(&mut self, forward: bool) { + let fid = self.active_frontend_key(); + let active = self.active_window_id(); + let hidden_panel = if self.views.get(&fid).is_some_and(|view| view.panel_hidden) { + self.side_window_for(fid) + } else { + None + }; + let next = self + .active_layout() + .focus_step(active, forward, &|id| Some(id) != hidden_panel); + self.set_active_window_id(next); + self.note_focus_transition(fid, active, next); + } + + /// Focus an explicit window in the acting frontend, refreshing the + /// panel's remembered document origin on the way (Q#BP2c). + pub fn focus_window(&mut self, fid: FrontendId, target: WindowId) { + let Some(view) = self.views.get_mut(&fid) else { + return; + }; + let previous = view.active; + view.active = target; + self.note_focus_transition(fid, previous, target); + } + + /// Close the active window. Returns false when the layout would be + /// left with no **document** window. + /// + /// Q#BP6 narrows the pre-arc "unless it's the only one" rule: a side + /// window is never load-bearing, so closing the panel itself is + /// always legal — including when it is the only other window — while + /// closing the last *non-side* window is always refused. pub fn close_active(&mut self) -> bool { // Per-frontend: gate on the *active frontend's* window count, not // the global `self.windows` set. Every attached frontend keeps its // own windows in `self.windows`, so a global `<= 1` check let a // multi-frontend session close a frontend's last window and then // panic picking a successor from the now-empty layout. - if self.active_layout().iter_ids().len() <= 1 { - return false; - } + let fid = self.active_frontend_key(); let target = self.active_window_id(); + let target_is_side = self + .windows + .get(&target) + .is_some_and(crate::window::Window::is_side); + if !target_is_side { + let remaining_documents = self + .active_layout() + .iter_ids() + .into_iter() + .filter(|id| { + *id != target + && !self + .windows + .get(id) + .is_some_and(crate::window::Window::is_side) + }) + .count(); + if remaining_documents == 0 { + return false; + } + } self.active_layout_mut().close_window(target); self.windows.remove(&target); - // Pick an adjacent window as the new focus. - let next = *self - .active_layout() - .iter_ids() - .first() - .expect("at least one window remains"); + if target_is_side { + if let Some(view) = self.views.get_mut(&fid) { + view.panel_hidden = false; + } + } + // Pick an adjacent window as the new focus, preferring a document. + let ids = self.active_layout().iter_ids(); + let next = ids + .iter() + .copied() + .find(|id| { + !self + .windows + .get(id) + .is_some_and(crate::window::Window::is_side) + }) + .unwrap_or_else(|| *ids.first().expect("at least one window remains")); + let previous = self.active_window_id(); self.set_active_window_id(next); + self.note_focus_transition(fid, previous, next); true } /// Close every window except the active one, *within the active - /// frontend*. - pub fn close_others(&mut self) { + /// frontend* — including the panel (Q#BP6). + /// + /// # Errors + /// From a side window: a panel cannot swallow the document tree. + pub fn close_others(&mut self) -> Result<(), String> { // Per-frontend: only prune the active frontend's own layout. The // global `self.windows` set holds every frontend's windows, so a // global `retain(|id| id == keep)` deleted OTHER frontends' // windows — leaving their `view.active` dangling and panicking the // next `active_window()` (the multi-frontend close-others crash). let keep = self.active_window_id(); + if self + .windows + .get(&keep) + .is_some_and(crate::window::Window::is_side) + { + return Err("window.close-others: not available in a side window".into()); + } + let fid = self.active_frontend_key(); let doomed: Vec = self .active_layout() .iter_ids() @@ -2390,6 +2766,993 @@ impl EditorCore { for id in doomed { self.windows.remove(&id); } + if let Some(view) = self.views.get_mut(&fid) { + view.panel_hidden = false; + } + Ok(()) + } + + /// The `views` key the active-frontend accessors resolve to. + #[must_use] + pub fn active_frontend_key(&self) -> FrontendId { + if self.views.contains_key(&self.active_frontend) { + self.active_frontend + } else { + FrontendId::LOCAL + } + } + + // ---- side windows + display policy (bottom-panel arc) ------------------ + + /// The one side leaf in `fid`'s layout, if it has one (Q#BP2a). + #[must_use] + pub fn side_window_for(&self, fid: FrontendId) -> Option { + let view = self.views.get(&fid)?; + view.layout.side_leaf(|id| { + self.windows + .get(&id) + .is_some_and(crate::window::Window::is_side) + }) + } + + /// Whether `fid`'s side window exists but is currently hidden. + #[must_use] + pub fn panel_hidden_for(&self, fid: FrontendId) -> bool { + self.views.get(&fid).is_some_and(|view| view.panel_hidden) + && self.side_window_for(fid).is_some() + } + + /// Whether `fid` can render a side window at all (Q#BP13). + #[must_use] + pub fn panel_capable_for(&self, fid: FrontendId) -> bool { + self.views.get(&fid).is_some_and(|view| view.panel_capable) + } + + /// **The** primary document window for `fid` (Q#BP14). + /// + /// The frontend's active window when it is non-side, else its + /// non-side target. Every consumer classified *Projection* in the + /// framing's §1.3 census routes through this rather than through + /// `active_window_for` / `active_buffer_id`, so focusing a panel + /// re-sends no snapshot, suppresses no document, swaps no mirror, + /// and cannot leak into a newly attached frontend's document view. + #[must_use] + pub fn primary_document_window(&self, fid: FrontendId) -> Option { + let view = self.views.get(&fid)?; + if !self + .windows + .get(&view.active) + .is_some_and(crate::window::Window::is_side) + { + return Some(view.active); + } + self.non_side_target(fid).ok() + } + + /// [`Self::primary_document_window`]'s buffer, falling back to the + /// focused window's when the layout is degenerate. + #[must_use] + pub fn primary_document_buffer(&self, fid: FrontendId) -> Option { + let win = self.primary_document_window(fid)?; + self.windows.get(&win).map(|window| window.buffer_id) + } + + /// The non-side target rule (Q#BP11a). + /// + /// 1. the selected window when it is **not** a side window + /// (byte-identical to pre-arc behavior), + /// 2. else the remembered `origin_document`, when it revalidates, + /// 3. else the first non-side window in `iter_ids()` order, + /// 4. else a pointed error. There is no document leaf from which a + /// valid fallback could be fabricated, and Q#BP6 forbids this as + /// a resting state, so the broken invariant is asserted rather + /// than papered over. + /// + /// # Errors + /// When `fid` has no view, or its layout holds no non-side window. + pub fn non_side_target(&self, fid: FrontendId) -> Result { + let view = self + .views + .get(&fid) + .ok_or_else(|| format!("frontend {fid:?} has no window layout"))?; + let is_side = |id: WindowId| { + self.windows + .get(&id) + .is_some_and(crate::window::Window::is_side) + }; + if !is_side(view.active) { + return Ok(view.active); + } + if let Some(origin) = self + .windows + .get(&view.active) + .and_then(|w| w.params.origin_document()) + && view.layout.iter_ids().contains(&origin) + && !is_side(origin) + { + return Ok(origin); + } + if let Some(first) = view.layout.iter_ids().into_iter().find(|id| !is_side(*id)) { + return Ok(first); + } + debug_assert!( + false, + "invariant (Q#BP6): a frontend layout always retains at least one non-side window" + ); + Err("no document window is available".into()) + } + + /// Record the document window a focus transition into the panel came + /// from (Q#BP2c). + /// + /// Called on every focus change. Only a **non-side → side** + /// transition refreshes the memory: panel→panel redisplay and + /// passive display must not overwrite it, and a creation-only + /// origin would go stale the moment the user entered the panel from + /// a different document split. + pub fn note_focus_transition(&mut self, fid: FrontendId, from: WindowId, to: WindowId) { + if from == to { + return; + } + debug_assert!( + self.views + .get(&fid) + .is_some_and(|view| view.layout.iter_ids().contains(&to)), + "focus transition target must belong to the acting frontend's layout" + ); + let from_side = self + .windows + .get(&from) + .is_some_and(crate::window::Window::is_side); + let to_side = self + .windows + .get(&to) + .is_some_and(crate::window::Window::is_side); + if from_side || !to_side { + return; + } + if let Some(window) = self.windows.get_mut(&to) { + window.params.set_origin_document(Some(from)); + } + } + + /// Minimum outer rows the document subtree beneath `fid`'s panel + /// wrapper needs (Q#BP2). Falls back to the whole root when the tree + /// does not have the wrapper shape. + #[must_use] + fn document_min_rows(&self, fid: FrontendId) -> u32 { + let Some(view) = self.views.get(&fid) else { + return MIN_WINDOW_OUTER_ROWS; + }; + let node = self + .side_window_for(fid) + .and_then(|side| view.layout.document_subtree(side)) + .unwrap_or(&view.layout.root); + subtree_min_rows(node) + } + + /// The panel's **effective** row allocation on a frame whose window + /// area is `area_rows` (Q#BP2), or `None` when it cannot be + /// satisfied and must be hidden. + /// + /// `min(requested, area_rows - subtree_min_rows(document_root))`, then + /// the structural floor. This is the whole bounded promise: the panel + /// allocator never makes an otherwise satisfiable document tree + /// unsatisfiable, and what the frame does to a document tree that + /// could not fit anyway is unchanged behavior. + #[must_use] + pub fn panel_allocation(&self, fid: FrontendId, area_rows: u32) -> Option { + let side = self.side_window_for(fid)?; + let requested = self.windows.get(&side)?.params.fixed_rows?; + let allowed = area_rows.saturating_sub(self.document_min_rows(fid)); + let alloc = requested.min(allowed); + (alloc >= MIN_WINDOW_OUTER_ROWS).then_some(alloc) + } + + /// The fixed-extent map both [`crate::window::Layout::compute`] + /// production callers feed in (Q#BP2, R5-B1). + /// + /// Derived by this one shared helper rather than assembled at each + /// call site: `window_placements` and the peer-presence overlay pass + /// build different areas, and leaving the second on unfixed geometry + /// would paint every peer cursor at the row it would occupy with no + /// panel open. + /// + /// A hidden panel maps to `0`, which is Q#BP2's exact effective + /// geometry for that state: the side leaf gets an empty rect, the + /// document subtree receives every reclaimed row, and the stored + /// request, wrapper, ids, weights, and order all stay intact. + #[must_use] + pub fn panel_fixed_rows(&self, fid: FrontendId, area_rows: u32) -> HashMap { + let mut fixed = HashMap::new(); + let Some(side) = self.side_window_for(fid) else { + return fixed; + }; + if self.views.get(&fid).is_some_and(|view| view.panel_hidden) { + fixed.insert(side, 0); + return fixed; + } + fixed.insert(side, self.panel_allocation(fid, area_rows).unwrap_or(0)); + fixed + } + + /// Delete `side` from `fid`'s layout, collapsing the root-level + /// wrapper and rehoming focus (Q#BP2a). + /// + /// Idempotent and safe to call from `kill_buffer`: the wrapper + /// collapse is `Layout::close_window`'s existing + /// `collapse_single_child_splits` pass, so no new tree code runs. + pub fn remove_side_window(&mut self, fid: FrontendId, side: WindowId) { + let Some(view) = self.views.get_mut(&fid) else { + return; + }; + if !view.layout.close_window(side) { + return; + } + view.panel_hidden = false; + let was_active = view.active == side; + if was_active { + let fallback = *view + .layout + .iter_ids() + .first() + .expect("Q#BP6: a document leaf always survives the wrapper collapse"); + view.active = fallback; + } + self.windows.remove(&side); + if was_active + && let Ok(target) = self.non_side_target(fid) + { + if let Some(view) = self.views.get_mut(&fid) { + view.active = target; + } + } + // A remembered origin pointing at a now-dead window is cleared by + // `non_side_target`'s revalidation on next use; nothing else here + // may reference the removed id. + for window in self.windows.values_mut() { + if window.params.origin_document() == Some(side) { + window.params.set_origin_document(None); + } + } + } + + /// Phase 1 of `window.quit` (Q#BP2c / Q#BP11b). + /// + /// Executes the window's recorded [`QuitAction`], returning the + /// Phase-2 transaction Q#BP4 owns. A `Restore` whose buffer has been + /// killed fails closed to `Delete`, dropping the unusable chain. + /// + /// # Errors + /// A window with no recorded action returns a pointed error **without + /// closing or switching anything** — non-side adopter fallbacks call + /// their own existing restore path instead. + pub fn quit_window( + &mut self, + fid: FrontendId, + target: WindowId, + ) -> Result { + let action = self + .windows + .get(&target) + .ok_or_else(|| format!("window {} is not live", target.raw()))? + .params + .quit_action() + .cloned() + .ok_or_else(|| "window.quit: this window has no quit action".to_string())?; + let action = match action { + QuitAction::Restore { buffer_id, .. } + if !self.registry.borrow().contains(buffer_id) => + { + QuitAction::Delete + } + other => other, + }; + match action { + QuitAction::Delete => { + let saved_active = self.views.get(&fid).map(|view| view.active); + self.remove_side_window(fid, target); + Ok(QuitOutcome::Deleted { + focus: self + .views + .get(&fid) + .map(|view| view.active) + .or(saved_active), + }) + } + QuitAction::Restore { + buffer_id, + fixed_rows, + dedicated, + cursor, + view_top, + goal_col, + selection, + then, + } => { + self.install_buffer_in_window(target, buffer_id)?; + let len = { + let reg = self.registry.borrow(); + reg.get(buffer_id).map(Buffer::len).unwrap_or(0) + }; + let window = self + .windows + .get_mut(&target) + .ok_or_else(|| "window.quit: target vanished".to_string())?; + window.params.fixed_rows = Some(fixed_rows.max(MIN_WINDOW_OUTER_ROWS)); + window.params.dedicated = dedicated; + window.params.set_quit_action(Some(*then)); + // Clamp saved positions against the buffer's CURRENT + // contents: it may have shrunk while the panel showed + // something else. Derived `last_visible_rows` and + // trait-object overlays are deliberately not snapshotted — + // the switch hook reattaches overlays. + window.cursor = cursor.min(len); + window.view_top = view_top; + window.goal_col = goal_col; + window.selection = selection.filter(|sel| sel.anchor <= len); + Ok(QuitOutcome::Restored { + target, + buffer_id, + }) + } + } + } + + /// Clamp a programmatic `fixed_rows` request (Q#BP2). + /// + /// # Errors + /// A request of `0` is rejected rather than being an invisible + /// "open". + pub fn clamp_panel_rows(rows: u32) -> Result { + if rows == 0 { + return Err("panel height must be at least 1 row".into()); + } + Ok(rows.max(MIN_WINDOW_OUTER_ROWS)) + } + + /// The window area a frontend's layout is computed into: the whole + /// declared frame minus the one global status row, matching + /// `window_placements`. `None` while geometry is **unknown**. + #[must_use] + pub fn frontend_area_rows(&self, fid: FrontendId) -> Option { + let geometry = self.views.get(&fid)?.frame_geometry?; + (geometry.total.rows >= 2 && geometry.total.cols > 0) + .then(|| geometry.total.rows - 1) + } + + /// Cache a frontend's authoritative frame capacity (Q#BP2b). + /// + /// Grid / `LOCAL` views call this from their real attach and resize + /// sizes with an internally minted epoch; a semantic view stays + /// `None` until Stage 2's authenticated declaration. A repeated + /// identical size is not a new declaration. + pub fn declare_frame_geometry(&mut self, fid: FrontendId, total: crate::cell::CellSize) { + let Some(view) = self.views.get_mut(&fid) else { + return; + }; + if view + .frame_geometry + .is_some_and(|geometry| geometry.total == total) + { + return; + } + let next = view + .frame_geometry + .map_or(1, |geometry| geometry.geometry_epoch.saturating_add(1)); + view.frame_geometry = Some(crate::window::DeclaredFrameGeometry { + geometry_epoch: next, + total, + }); + } + + /// Core half of the idempotent panel-reconciliation transaction + /// (Q#BP2b). The caller owns the terminal manager, so releasing a + /// controller is reported rather than performed. + /// + /// Hiding is a **durable state transition**, not a per-frame effect: + /// a render-time dodge would still route keys to an invisible window + /// and would leave the terminal controller claimed, because the + /// resize path merely returns on zero content without releasing it. + pub fn reconcile_panel_layout_core(&mut self, fid: FrontendId) -> PanelReconciliation { + let mut result = PanelReconciliation::default(); + let Some(side) = self.side_window_for(fid) else { + // `panel_hidden` never describes a panel that no longer + // exists. + if let Some(view) = self.views.get_mut(&fid) { + result.changed = view.panel_hidden; + view.panel_hidden = false; + } + return result; + }; + let was_hidden = self.views.get(&fid).is_some_and(|view| view.panel_hidden); + // Unknown geometry (a semantic view before Stage 2's declaration) + // and a zero-column frame are both non-presentable, and follow the + // hidden arm rather than being sized against a placeholder. + let satisfiable = self + .frontend_area_rows(fid) + .and_then(|rows| self.panel_allocation(fid, rows)) + .is_some(); + let Some(view) = self.views.get_mut(&fid) else { + return result; + }; + view.panel_hidden = !satisfiable; + result.hidden = !satisfiable; + result.changed = was_hidden != result.hidden; + if satisfiable { + // Focus is deliberately NOT restored when the panel + // reappears — the user moved on; `C-x o` returns. + return result; + } + if view.active == side { + // Durable transition: move focus out and tell the caller to + // release the terminal controller for this view key. + result.released_terminal = Some(side); + if let Ok(target) = self.non_side_target(fid) + && let Some(view) = self.views.get_mut(&fid) + { + view.active = target; + } + } + result + } + + /// Move the horizontal boundary that `win` owns by `delta_rows`, + /// growing `win` (Q#BP5 / Q#BP5b). + /// + /// `min_for` resolves each leaf's `window.min-height` preference; it + /// is snapshotted by the caller **before** any geometry changes, so + /// one gesture uses one set of minima. + /// + /// # Errors + /// When `win` is not live in `fid`'s layout, when the panel is + /// hidden, or when no adjustable horizontal boundary exists. + pub fn resize_boundary( + &mut self, + fid: FrontendId, + win: WindowId, + delta_rows: i32, + area_rows: u32, + min_for: &impl Fn(WindowId) -> u32, + ) -> Result<(), String> { + let view = self + .views + .get(&fid) + .ok_or_else(|| format!("frontend {fid:?} has no window layout"))?; + if !view.layout.iter_ids().contains(&win) { + return Err(format!( + "window {} does not belong to this frontend", + win.raw() + )); + } + let win_is_side = self + .windows + .get(&win) + .is_some_and(crate::window::Window::is_side); + if win_is_side && view.panel_hidden { + return Err("window.resize: the panel is not currently visible".into()); + } + // Q#BP5b rule 1: a side window resolves to its OWN fixed + // boundary; rule 2: any other window resolves to the nearest + // horizontal ancestor at which its path child has a following + // sibling — the same boundary a drag on its bottom mode-line row + // moves. + let (boundary, lower_grows) = if win_is_side { + let side = self + .side_window_for(fid) + .ok_or_else(|| "window.resize: no side window".to_string())?; + let path = view + .layout + .path_to(side) + .ok_or_else(|| "window.resize: side window is not in the layout".to_string())?; + let (&last, parent) = path + .split_last() + .ok_or_else(|| "window.resize: no adjustable horizontal boundary".to_string())?; + if last == 0 { + return Err("window.resize: no adjustable horizontal boundary".into()); + } + ( + crate::window::SplitBoundary { + path: parent.to_vec(), + upper: last - 1, + }, + true, + ) + } else { + ( + view.layout + .boundary_below(win) + .ok_or_else(|| "window.resize: no adjustable horizontal boundary".to_string())?, + false, + ) + }; + + let placements = view.layout.compute( + crate::window::Rect::new(0, 0, area_rows, 1), + &self.panel_fixed_rows(fid, area_rows), + ); + let view = self + .views + .get(&fid) + .ok_or_else(|| format!("frontend {fid:?} has no window layout"))?; + let LayoutNode::Split { children, .. } = view + .layout + .node_at(&boundary.path) + .ok_or_else(|| "window.resize: boundary vanished".to_string())? + else { + return Err("window.resize: boundary is not a split".into()); + }; + let upper_node = &children[boundary.upper]; + let lower_node = &children[boundary.upper + 1]; + let upper_rows = node_row_extent(upper_node, &placements); + let lower_rows = node_row_extent(lower_node, &placements); + let total = upper_rows + lower_rows; + let min_upper = crate::window::interactive_min_rows(upper_node, min_for); + let min_lower = crate::window::interactive_min_rows(lower_node, min_for); + // Preserve the preferred minimum on BOTH sides when the frame can + // satisfy it; when it is already smaller, the motion may not make + // either side worse than it already is. + let floor_upper = min_upper.min(upper_rows); + let floor_lower = min_lower.min(lower_rows); + let boundary_delta = if lower_grows { -delta_rows } else { delta_rows }; + let proposed = i64::from(upper_rows) + i64::from(boundary_delta); + let lo = i64::from(floor_upper); + let hi = i64::from(total.saturating_sub(floor_lower)); + if hi < lo { + return Err("window.resize: no room to move this boundary".into()); + } + let new_upper = u32::try_from(proposed.clamp(lo, hi)) + .map_err(|_| "window.resize: boundary out of range".to_string())?; + let new_lower = total - new_upper; + + // A side window writes `fixed_rows` (its ABSOLUTE height survives + // a terminal resize); a flexible pair writes weights (its RATIO + // survives). That difference is the point. + let lower_id = match lower_node { + LayoutNode::Leaf(id) => Some(*id), + LayoutNode::Split { .. } => None, + }; + let lower_is_side = lower_id.is_some_and(|id| { + self.windows + .get(&id) + .is_some_and(crate::window::Window::is_side) + }); + if lower_is_side { + let id = lower_id.expect("checked above"); + if let Some(window) = self.windows.get_mut(&id) { + window.params.fixed_rows = Some(new_lower.max(MIN_WINDOW_OUTER_ROWS)); + } + return Ok(()); + } + // Rewrite every flexible child's weight as its current row + // extent, with the two adjacent children replaced. Untouched + // siblings therefore keep the extents they already had. + let extents: Vec = children + .iter() + .enumerate() + .map(|(i, child)| { + if i == boundary.upper { + new_upper + } else if i == boundary.upper + 1 { + new_lower + } else { + node_row_extent(child, &placements) + } + }) + .collect(); + let fixed = self.panel_fixed_rows(fid, area_rows); + let view = self + .views + .get_mut(&fid) + .ok_or_else(|| format!("frontend {fid:?} has no window layout"))?; + let Some(LayoutNode::Split { + weights, children, .. + }) = view.layout.node_at_mut(&boundary.path) + else { + return Err("window.resize: boundary vanished".into()); + }; + weights.resize(children.len(), 1); + for (i, child) in children.iter().enumerate() { + let pinned = matches!(child, LayoutNode::Leaf(id) if fixed.contains_key(id)); + if !pinned { + weights[i] = extents[i].max(1); + } + } + Ok(()) + } + + /// Install `buffer_id` in an explicit window, resetting its view + /// state exactly as [`Self::switch_active_buffer_for`] does — except + /// that redisplaying the buffer a window **already shows** is a no-op + /// on cursor, viewport, selection, and overlays. + /// + /// # Errors + /// Unknown window or buffer. + pub fn install_buffer_in_window( + &mut self, + window_id: WindowId, + buffer_id: BufferId, + ) -> Result<(), String> { + let text_view = { + let reg = self.registry.borrow(); + let buf = reg.get(buffer_id).map_err(|e| e.to_string())?; + TextView::new(buf) + }; + let window = self + .windows + .get_mut(&window_id) + .ok_or_else(|| format!("window {window_id:?} is not live"))?; + if window.buffer_id == buffer_id { + return Ok(()); + } + window.buffer_id = buffer_id; + window.text_view = text_view; + window.overlays.clear(); + window.cursor = 0; + window.selection = None; + window.view_top = 0; + window.goal_col = None; + Ok(()) + } + + /// Phase 1 of the display transaction (Q#BP4): choose a target, + /// install the buffer, and report what Phase 2 must do. + /// + /// Contains **no** Lua: the hook fan-out, the reconciliation, and the + /// final-focus matrix all belong to the layer that owns the Lua host. + /// + /// # Errors + /// An unusable exact target, an unsatisfiable placement request, or a + /// layout with no eligible document window. + pub fn display_buffer( + &mut self, + fid: FrontendId, + request: &DisplayRequest, + ) -> Result { + let saved_active = self + .views + .get(&fid) + .ok_or_else(|| format!("frontend {fid:?} has no window layout"))? + .active; + let placement = self.resolve_placement(fid, request)?; + self.apply_placement(fid, request, &placement)?; + let select = request + .select + .unwrap_or(!matches!(placement.kind, PlacementKind::Side { .. })); + Ok(DisplayOutcome { + target: placement.target, + saved_active, + select, + created_side: matches!( + placement.kind, + PlacementKind::Side { + created: true, + .. + } + ), + }) + } + + /// Answer "is there a usable destination for this visit?" **without + /// loading anything** (Q#BP11b step 2, R3-B17). + /// + /// `existing` is the side-effect-free dedup result: `None` means the + /// file is not open yet, in which case an eligible destination must + /// not be dedicated to *any* buffer — otherwise a dedicated origin + /// could force a load that then has nowhere to go. + /// + /// # Errors + /// An exact target that is dead, foreign, or dedicated; or a layout + /// with no eligible document window. + pub fn probe_display_target( + &self, + fid: FrontendId, + existing: Option, + window: Option, + ) -> Result { + let view = self + .views + .get(&fid) + .ok_or_else(|| format!("frontend {fid:?} has no window layout"))?; + let eligible = |id: WindowId| { + self.windows.get(&id).is_some_and(|w| { + !w.params.dedicated || existing.is_some_and(|buffer_id| w.buffer_id == buffer_id) + }) + }; + if let Some(target) = window { + if !view.layout.iter_ids().contains(&target) { + return Err(format!( + "display_file: window {} does not belong to this frontend", + target.raw() + )); + } + if !eligible(target) { + return Err(format!( + "display_file: window {} is dedicated to another buffer", + target.raw() + )); + } + return Ok(target); + } + let is_side = |id: WindowId| { + self.windows + .get(&id) + .is_some_and(crate::window::Window::is_side) + }; + if let Some(buffer_id) = existing + && let Some(showing) = view.layout.iter_ids().into_iter().find(|id| { + !is_side(*id) + && self + .windows + .get(id) + .is_some_and(|w| w.buffer_id == buffer_id) + }) + { + return Ok(showing); + } + let mut candidates: Vec = Vec::new(); + if let Ok(preferred) = self.non_side_target(fid) { + candidates.push(preferred); + } + candidates.extend(view.layout.iter_ids().into_iter().filter(|id| !is_side(*id))); + candidates + .into_iter() + .find(|id| eligible(*id)) + .ok_or_else(|| "display_file: no eligible document window is available".into()) + } + + /// Q#BP3's precedence: exact target, then side affinity, then + /// ordinary reuse. Placement affinity precedes generic reuse — + /// otherwise a persistent `*compilation*` buffer already visible in a + /// document window makes `{side = "bottom"}` silently ignore its + /// requested placement. + fn resolve_placement( + &self, + fid: FrontendId, + request: &DisplayRequest, + ) -> Result { + if request.window.is_some() && request.side.is_some() { + return Err("display: `window` and `side` are mutually exclusive".into()); + } + let view = self + .views + .get(&fid) + .ok_or_else(|| format!("frontend {fid:?} has no window layout"))?; + + // 1. Exact target. + if let Some(target) = request.window { + if !view.layout.iter_ids().contains(&target) { + return Err(format!( + "display: window {} does not belong to this frontend", + target.raw() + )); + } + let window = self + .windows + .get(&target) + .ok_or_else(|| format!("display: window {} is not live", target.raw()))?; + if window.params.dedicated && window.buffer_id != request.buffer_id { + return Err(format!( + "display: window {} is dedicated to another buffer", + target.raw() + )); + } + if request.height.is_some() && !window.is_side() { + return Err("display: `height` requires a side window".into()); + } + return Ok(Placement { + target, + kind: if window.is_side() { + PlacementKind::Side { + created: false, + replacing: window.buffer_id != request.buffer_id, + } + } else { + PlacementKind::Ordinary + }, + }); + } + + // 2. Side target — only on a panel-capable frontend. + if request.side.is_some() && view.panel_capable { + match self.side_window_for(fid) { + Some(side) => { + let window = self + .windows + .get(&side) + .ok_or_else(|| "display: side window is not live".to_string())?; + if window.buffer_id == request.buffer_id { + return Ok(Placement { + target: side, + kind: PlacementKind::Side { + created: false, + replacing: false, + }, + }); + } + if !window.params.dedicated { + return Ok(Placement { + target: side, + kind: PlacementKind::Side { + created: false, + replacing: true, + }, + }); + } + // The one side slot is dedicated to another buffer. + // Never create a second one: fall through to the + // ordinary policy, discarding every side-specific + // parameter (Q#BP3 2.iii). + } + None => { + return Ok(Placement { + target: WindowId::next(), + kind: PlacementKind::Side { + created: true, + replacing: false, + }, + }); + } + } + } else if request.height.is_some() { + return Err("display: `height` requires a side window".into()); + } + + // 3. Ordinary target. + let is_side = |id: WindowId| { + self.windows + .get(&id) + .is_some_and(crate::window::Window::is_side) + }; + // 3.i — reuse a visible NON-side window already showing it. An + // ordinary display never selects the panel by coincidence. + if let Some(existing) = view.layout.iter_ids().into_iter().find(|id| { + !is_side(*id) + && self + .windows + .get(id) + .is_some_and(|w| w.buffer_id == request.buffer_id) + }) { + return Ok(Placement { + target: existing, + kind: PlacementKind::Ordinary, + }); + } + // 3.ii — the Q#BP11a candidate, then `iter_ids()` order, skipping + // any window dedicated to a different buffer. + let mut candidates: Vec = Vec::new(); + if let Ok(preferred) = self.non_side_target(fid) { + candidates.push(preferred); + } + candidates.extend(view.layout.iter_ids().into_iter().filter(|id| !is_side(*id))); + for candidate in candidates { + let eligible = self.windows.get(&candidate).is_some_and(|w| { + !w.params.dedicated || w.buffer_id == request.buffer_id + }); + if eligible { + return Ok(Placement { + target: candidate, + kind: PlacementKind::Ordinary, + }); + } + } + Err("display: no eligible document window is available".into()) + } + + /// Create the side window when needed, then install the buffer and + /// reconcile the parameter semantics of Q#BP3. + fn apply_placement( + &mut self, + fid: FrontendId, + request: &DisplayRequest, + placement: &Placement, + ) -> Result<(), String> { + let side = match placement.kind { + PlacementKind::Ordinary => { + // Reaching Ordinary while a side was REQUESTED means the + // request fell back (not panel-capable, or the one slot + // is dedicated elsewhere). A failed placement request may + // never pin or dedicate a document window, so `side`, + // `height`, `dedicated`, and quit bookkeeping are all + // discarded here; only an explicit `select` survives, and + // that is Phase 2's business. + let fell_back = request.side.is_some(); + let same_buffer_redisplay = self + .windows + .get(&placement.target) + .is_some_and(|w| w.buffer_id == request.buffer_id); + self.install_buffer_in_window(placement.target, request.buffer_id)?; + let window = self + .windows + .get_mut(&placement.target) + .ok_or_else(|| "display: target window vanished".to_string())?; + match request.dedicated { + Some(dedicated) if !fell_back => window.params.dedicated = dedicated, + // A same-buffer redisplay must not silently unpin a + // window; a genuine replacement starts undedicated. + _ if !same_buffer_redisplay => window.params.dedicated = false, + _ => {} + } + return Ok(()); + } + PlacementKind::Side { created, replacing } => (created, replacing), + }; + let (created, replacing) = side; + let requested_side = request.side.unwrap_or(Side::Bottom); + + if created { + let rows = Self::clamp_panel_rows(request.height.unwrap_or(request.default_panel_rows))?; + let origin = self.non_side_target(fid).ok(); + let text_view = { + let reg = self.registry.borrow(); + let buf = reg.get(request.buffer_id).map_err(|e| e.to_string())?; + TextView::new(buf) + }; + let mut window = Window::new(placement.target, request.buffer_id, text_view); + window.params.side = Some(requested_side); + window.params.fixed_rows = Some(rows); + window.params.dedicated = request.dedicated.unwrap_or(false); + window.params.set_quit_action(Some(QuitAction::Delete)); + window.params.set_origin_document(origin); + self.windows.insert(placement.target, window); + self.views + .get_mut(&fid) + .ok_or_else(|| format!("frontend {fid:?} has no window layout"))? + .layout + .install_side_leaf(placement.target); + return Ok(()); + } + + // Reusing the existing slot. Capture the outgoing presentation + // BEFORE the install resets the window's view state. + let prior = { + let window = self + .windows + .get(&placement.target) + .ok_or_else(|| "display: side window vanished".to_string())?; + QuitAction::Restore { + buffer_id: window.buffer_id, + fixed_rows: window.params.fixed_rows.unwrap_or(MIN_WINDOW_OUTER_ROWS), + dedicated: window.params.dedicated, + cursor: window.cursor, + view_top: window.view_top, + goal_col: window.goal_col, + selection: window.selection, + then: Box::new( + window + .params + .quit_action() + .cloned() + .unwrap_or(QuitAction::Delete), + ), + } + }; + self.install_buffer_in_window(placement.target, request.buffer_id)?; + let height = match request.height { + Some(rows) => Some(Self::clamp_panel_rows(rows)?), + None => None, + }; + let window = self + .windows + .get_mut(&placement.target) + .ok_or_else(|| "display: side window vanished".to_string())?; + if let Some(rows) = height { + window.params.fixed_rows = Some(rows); + } + if replacing { + // A replacement's new presentation defaults to undedicated so + // the one slot stays replaceable; an explicit dedication + // applies only after the OLD presentation already passed + // eligibility, so `dedicated = false` cannot clear-and-bypass + // an existing dedication in the same call. + window.params.dedicated = request.dedicated.unwrap_or(false); + let mut action = prior; + action.truncate_to(MAX_PANEL_QUIT_DEPTH); + window.params.set_quit_action(Some(action)); + } else if let Some(dedicated) = request.dedicated { + window.params.dedicated = dedicated; + } + Ok(()) } // ---- selection / region (T M2.12) -------------------------------------- @@ -3070,6 +4433,25 @@ impl EditorCore { } } }; + // Q#BP10a: a side window showing the victim is CLOSED, not + // redirected to `*scratch*`. Redirecting would strand an + // unrelated buffer in the panel slot; the wrapper collapse + // restores the prior root, which by construction holds a leaf. + let doomed_sides: Vec<(FrontendId, WindowId)> = self + .views + .iter() + .filter_map(|(fid, view)| { + let side = view.layout.side_leaf(|id| { + self.windows + .get(&id) + .is_some_and(crate::window::Window::is_side) + })?; + (self.windows.get(&side)?.buffer_id == buffer_id).then_some((*fid, side)) + }) + .collect(); + for (fid, side) in doomed_sides { + self.remove_side_window(fid, side); + } { let reg = self.registry.borrow(); let buf = reg.get(fallback).map_err(|e| e.to_string())?; @@ -3509,6 +4891,9 @@ mod tests { layout: Layout::single(win_id), active: win_id, fold_projection: true, + panel_capable: true, + frame_geometry: None, + panel_hidden: false, }, ); win_id @@ -3525,7 +4910,7 @@ mod tests { let win2 = attach_frontend(&mut s, fid2); s.active_frontend = fid2; - s.close_others(); + s.close_others().expect("document window may close others"); assert!( s.windows.contains_key(&win2), @@ -4093,8 +5478,10 @@ mod tests { s.active_window_mut().cursor = (i % 10) as u64; s.push_jump(); } + // Bottom-panel arc (Q#BP11c): the cap applies independently to + // each frontend's own vector, with today's oldest-entry eviction. assert_eq!( - s.jump_ring.len(), + s.jump_ring[&FrontendId::LOCAL].len(), EditorCore::JUMP_RING_CAP, "ring must stay bounded at JUMP_RING_CAP" ); diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index 7cf0bc4..a1ea3fc 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -88,6 +88,7 @@ mod diag; mod fold; mod index; mod mcp; +mod window_panel; // Every `pub` item a moved domain owned is re-exported so its prior // `crate::lua_bindings::` path still resolves — the split must not // shrink the public API surface. That includes the `install_*` wiring fns: @@ -647,6 +648,26 @@ impl PackageInstallOverride { } } +/// Resolve an integer setting out of the shared `pmacs.config` registry +/// (bottom-panel arc, Q#BP2 / Q#BP11). +/// +/// The registry lives in Lua app data, so Rust-side consumers — the +/// divider drag, the keyboard resize commands, and side-window creation +/// — reach it here rather than round-tripping through Lua. `fallback` +/// covers a bare core whose runtime never defined the setting (unit-test +/// construction), and a negative or out-of-range stored value. +#[must_use] +pub fn config_u32(lua: &Lua, name: &str, buffer_id: Option, fallback: u32) -> u32 { + let Some(registry) = lua.app_data_ref::() else { + return fallback; + }; + let borrowed = registry.borrow(); + match borrowed.get(name, buffer_id) { + Ok(crate::config_registry::ConfigValue::Int(v)) => u32::try_from(*v).unwrap_or(fallback), + _ => fallback, + } +} + /// Short-circuit a binding when the init phase has completed. /// /// Lifecycle-affecting Lua APIs (currently just `pmacs.attach`; M5.6d+) @@ -1572,7 +1593,7 @@ fn after_buffer_removed(lua: &Lua, id: BufferId) { } } -fn run_hook_if_defined(lua: &Lua, name: &str, args: mlua::MultiValue) { +pub(crate) fn run_hook_if_defined(lua: &Lua, name: &str, args: mlua::MultiValue) { let snapshot = match lua.app_data_ref::() { Some(hooks) => hooks.borrow().snapshot(name), None => None, @@ -8467,6 +8488,11 @@ fn install_terminal( manager: &crate::terminal::SharedTerminalManager, supervisor: &SharedProcessSupervisor, ) -> mlua::Result<()> { + // Bottom-panel arc (Q#BP2b): the panel-reconciliation transaction + // must be able to RELEASE a hidden panel's terminal controller from a + // Lua-owning context, so the manager joins the LSP manager and the + // process supervisor as app data. + lua.set_app_data(manager.clone()); let pmacs: Table = lua.globals().get("pmacs")?; let terminal = lua.create_table()?; @@ -12166,6 +12192,9 @@ fn lua_compat_ctx_args(ctx: &CompletionContext) -> LuaProviderArgs { )] fn install_window_module(lua: &Lua, core: &SharedCore) -> mlua::Result { let win = lua.create_table()?; + // Bottom-panel arc (Q#BP11): display policy, side windows, quit, and + // boundary resize live in their own module. + window_panel::install(lua, core, &win)?; { let cc = core.clone(); @@ -12271,8 +12300,9 @@ fn install_window_module(lua: &Lua, core: &SharedCore) -> mlua::Result
{ win.set( "close_others", lua.create_function(move |_, ()| { - cc.borrow_mut().close_others(); - Ok(()) + cc.borrow_mut() + .close_others() + .map_err(mlua::Error::runtime) })?, )?; } diff --git a/src/lua_bindings/window_panel.rs b/src/lua_bindings/window_panel.rs new file mode 100644 index 0000000..a28add0 --- /dev/null +++ b/src/lua_bindings/window_panel.rs @@ -0,0 +1,499 @@ +// window_panel.rs --- `pmacs.window` display policy + side windows. + +//! The Lua surface of the bottom-panel arc (Q#BP11): `display`, +//! `display_file`, `quit`, `panel`, `params` / `set_params`, `resize`, +//! and `display_target`. +//! +//! # Where the transaction lives +//! +//! [`crate::editor_core::EditorCore::display_buffer`] is **Phase 1**: it +//! picks a target under Q#BP3, installs the buffer, and reports what must +//! happen next. It contains no Lua. This module is **Phase 2** (Q#BP4): +//! activate the target, fire the lifecycle hook so overlays reattach and +//! saveplace / recentf / syntax / LSP observe the right active window, +//! run panel reconciliation (a hook may resize, close, or replace the +//! target), then **revalidate both window ids** and apply the final-focus +//! matrix. +//! +//! Two corrections that matrix encodes, both of which an earlier revision +//! of the framing got wrong: +//! +//! * `select = true` **keeps the target selected** — restoring the saved +//! window unconditionally would erase the request outright; +//! * `select = false` restores a saved window **even when it is the +//! panel** — a passive display invoked from a focused panel must not +//! blur it. +//! +//! # What Lua may not write +//! +//! `side` is immutable after placement (Q#BP2a), and `quit_action` / +//! `origin_document` are implementation-owned (Q#BP2c): `params` reports +//! them for diagnostics, `set_params` refuses them. Lua therefore cannot +//! forge a window id, a buffer restore chain, or stale cursor state. + +use mlua::{Lua, Table, Value}; + +use super::{BufferIdLua, SharedCore, config_u32, run_hook_if_defined}; +use crate::editor_core::{DisplayOutcome, DisplayRequest, HookKind, QuitOutcome}; +use crate::protocol::FrontendId; +use crate::window::{DEFAULT_PANEL_ROWS, MIN_WINDOW_OUTER_ROWS, Side, WindowId}; + +/// The frontend a `pmacs.window.*` call acts for. +/// +/// An interactive command carries authenticated origin; a programmatic +/// call falls back to the ambient active frontend, exactly as the +/// terminal surface does. +pub(crate) fn acting_frontend(lua: &Lua, core: &SharedCore) -> FrontendId { + lua.app_data_ref::() + .and_then(|origin| origin.current()) + .unwrap_or_else(|| core.borrow().active_frontend_key()) +} + +/// Run the panel-reconciliation transaction from a Lua-owning context +/// (Q#BP2b). +/// +/// The core half is pure; releasing a terminal controller needs the +/// manager, which the terminal module publishes as Lua app data for +/// exactly this reason. A bare core without one still reconciles — it +/// simply has no controller to release. +pub(crate) fn reconcile_panel_layout(lua: &Lua, core: &SharedCore, fid: FrontendId) { + let outcome = core.borrow_mut().reconcile_panel_layout_core(fid); + let Some(window_id) = outcome.released_terminal else { + return; + }; + let Some(manager) = lua.app_data_ref::() else { + return; + }; + let buffer_id = core + .borrow() + .windows + .get(&window_id) + .map(|window| window.buffer_id); + if let Some(buffer_id) = buffer_id { + let _ = manager + .borrow_mut() + .release_controller(crate::terminal::TerminalViewKey::new( + fid, window_id, buffer_id, + )); + } +} + +/// A window is "visible" for the final-focus matrix when it is live in +/// this frontend's layout and not a derived-hidden panel (Q#BP2b). +fn visible(core: &SharedCore, fid: FrontendId, win: WindowId) -> bool { + let core = core.borrow(); + let Some(view) = core.views.get(&fid) else { + return false; + }; + if !view.layout.iter_ids().contains(&win) { + return false; + } + !(view.panel_hidden + && core + .windows + .get(&win) + .is_some_and(crate::window::Window::is_side)) +} + +/// Phase 2 of the display transaction (Q#BP4). +fn complete_display( + lua: &Lua, + core: &SharedCore, + fid: FrontendId, + outcome: DisplayOutcome, + fire: HookKind, +) -> mlua::Result<()> { + core.borrow_mut().focus_window(fid, outcome.target); + match fire { + HookKind::AfterSwitch => { + run_hook_if_defined(lua, "buffer.after-switch", mlua::MultiValue::new()); + } + HookKind::AfterLoad => { + run_hook_if_defined(lua, "buffer.after-load", mlua::MultiValue::new()); + } + HookKind::None => {} + } + // A hook may have resized, closed, or replaced the target, so + // reconcile BEFORE the final-focus decision reads visibility. + reconcile_panel_layout(lua, core, fid); + + let target_ok = visible(core, fid, outcome.target); + let saved_ok = visible(core, fid, outcome.saved_active); + let final_focus = match (outcome.select, target_ok, saved_ok) { + (true, true, _) => Some(outcome.target), + (true, false, true) => Some(outcome.saved_active), + (false, _, true) => Some(outcome.saved_active), + (false, true, false) => Some(outcome.target), + // Both ids died with the hook: fall back to the non-side target + // rule rather than leaving focus on a dead window. + _ => None, + }; + let resolved = match final_focus { + Some(win) => win, + None => core + .borrow() + .non_side_target(fid) + .map_err(mlua::Error::runtime)?, + }; + core.borrow_mut().focus_window(fid, resolved); + Ok(()) +} + +/// Parse the shared `{side, window, height, dedicated, select}` option +/// table. +fn parse_request( + lua: &Lua, + core: &SharedCore, + fid: FrontendId, + buffer_id: crate::buffer::BufferId, + opts: Option
, +) -> mlua::Result { + let mut request = DisplayRequest::new(buffer_id); + let Some(opts) = opts else { + return Ok(request); + }; + if let Some(side) = opts.get::>("side")? { + request.side = Some(Side::from_name(&side).ok_or_else(|| { + mlua::Error::runtime(format!( + "pmacs.window.display: unsupported side {side:?} (only \"bottom\" ships)" + )) + })?); + } + if let Some(raw) = opts.get::>("window")? { + request.window = Some(lookup_window(core, fid, raw)?); + } + if let Some(height) = opts.get::>("height")? { + request.height = Some(height); + } + if let Some(dedicated) = opts.get::>("dedicated")? { + request.dedicated = Some(dedicated); + } + if let Some(select) = opts.get::>("select")? { + request.select = Some(select); + } + // The setting is resolved against the buffer being displayed, and + // only consumed when the slot is actually CREATED (Q#BP3). + request.default_panel_rows = config_u32( + lua, + "window.panel-height", + Some(buffer_id), + DEFAULT_PANEL_ROWS, + ) + .max(MIN_WINDOW_OUTER_ROWS); + Ok(request) +} + +/// Resolve a raw Lua window id, refusing one that is not live in the +/// acting frontend's layout (Q#BP11). +fn lookup_window(core: &SharedCore, fid: FrontendId, raw: u64) -> mlua::Result { + let core = core.borrow(); + let view = core + .views + .get(&fid) + .ok_or_else(|| mlua::Error::runtime("pmacs.window: acting frontend has no layout"))?; + view.layout + .iter_ids() + .into_iter() + .find(|id| id.raw() == raw) + .ok_or_else(|| { + mlua::Error::runtime(format!( + "pmacs.window: window {raw} is not live in this frontend's layout" + )) + }) +} + +/// Install the bottom-panel surface onto the existing `pmacs.window` +/// table. +pub(crate) fn install(lua: &Lua, core: &SharedCore, win: &Table) -> mlua::Result<()> { + { + let cc = core.clone(); + win.set( + "display", + lua.create_function( + move |lua, (buffer, opts): (BufferIdLua, Option
)| -> mlua::Result { + let fid = acting_frontend(lua, &cc); + let request = parse_request(lua, &cc, fid, buffer.0, opts)?; + let outcome = cc + .borrow_mut() + .display_buffer(fid, &request) + .map_err(mlua::Error::runtime)?; + complete_display(lua, &cc, fid, outcome, HookKind::AfterSwitch)?; + Ok(outcome.target.raw()) + }, + )?, + )?; + } + + { + // Q#BP11b — the target-aware load transaction. `find_or_open` + // switches the ACTIVE window in both branches before firing + // hooks, so a visit to a previously unopened file would replace + // a focused panel before any display policy could help. + let cc = core.clone(); + win.set( + "display_file", + lua.create_function( + move |lua, (path, opts): (String, Option
)| -> mlua::Result { + let fid = acting_frontend(lua, &cc); + let path_buf = std::path::PathBuf::from(&path); + let mut explicit_window = None; + let mut select = None; + if let Some(opts) = opts.as_ref() { + if let Some(raw) = opts.get::>("window")? { + explicit_window = Some(lookup_window(&cc, fid, raw)?); + } + select = opts.get::>("select")?; + } + // 1. Side-effect-free dedup: do NOT read the file yet. + let existing = cc.borrow().find_buffer_for_path(&path_buf); + // 2. Resolve the destination BEFORE I/O, so a + // dedicated origin cannot force load-before-failure. + cc.borrow() + .probe_display_target(fid, existing, explicit_window) + .map_err(mlua::Error::runtime)?; + // 3. Load, dedup, or create the path-backed buffer. + let (buffer_id, fire) = cc + .borrow_mut() + .resolve_target_buffer(&path_buf) + .map_err(mlua::Error::runtime)?; + // 4. Enter Q#BP4's transaction, so any hook observes + // the DOCUMENT TARGET as active. + let mut request = DisplayRequest::new(buffer_id); + request.window = explicit_window; + request.select = select; + let outcome = cc + .borrow_mut() + .display_buffer(fid, &request) + .map_err(mlua::Error::runtime)?; + complete_display(lua, &cc, fid, outcome, fire)?; + Ok(outcome.target.raw()) + }, + )?, + )?; + } + + { + // Q#BP11a — the non-side target: what an ordinary visit from a + // panel should address. + let cc = core.clone(); + win.set( + "display_target", + lua.create_function(move |lua, ()| -> mlua::Result { + let fid = acting_frontend(lua, &cc); + let core = cc.borrow(); + core.non_side_target(fid) + .map(WindowId::raw) + .map_err(mlua::Error::runtime) + })?, + )?; + } + + { + // The acting frontend's side window, or nil. + let cc = core.clone(); + win.set( + "panel", + lua.create_function(move |lua, ()| -> mlua::Result> { + let fid = acting_frontend(lua, &cc); + Ok(cc.borrow().side_window_for(fid).map(WindowId::raw)) + })?, + )?; + } + + { + // Q#BP2c — `window.quit`. A window with no recorded action gets + // a pointed error WITHOUT closing or switching anything. + let cc = core.clone(); + win.set( + "quit", + lua.create_function(move |lua, target: Option| -> mlua::Result<()> { + let fid = acting_frontend(lua, &cc); + let target = match target { + Some(raw) => lookup_window(&cc, fid, raw)?, + None => cc + .borrow() + .views + .get(&fid) + .map(|view| view.active) + .ok_or_else(|| { + mlua::Error::runtime("pmacs.window.quit: no acting frontend view") + })?, + }; + let outcome = cc + .borrow_mut() + .quit_window(fid, target) + .map_err(mlua::Error::runtime)?; + match outcome { + QuitOutcome::Deleted { focus } => { + reconcile_panel_layout(lua, &cc, fid); + if let Some(focus) = focus { + cc.borrow_mut().focus_window(fid, focus); + } + } + QuitOutcome::Restored { target, .. } => { + // Restoring is an ordinary presentation change: + // fire the switch hook so store-backed overlays + // reattach to the reinstated buffer. + cc.borrow_mut().focus_window(fid, target); + run_hook_if_defined(lua, "buffer.after-switch", mlua::MultiValue::new()); + reconcile_panel_layout(lua, &cc, fid); + if visible(&cc, fid, target) { + cc.borrow_mut().focus_window(fid, target); + } + } + } + Ok(()) + })?, + )?; + } + + { + // Read-only diagnostics over `WindowParams` (Q#BP2c). + let cc = core.clone(); + win.set( + "params", + lua.create_function(move |lua, target: Option| -> mlua::Result
{ + let fid = acting_frontend(lua, &cc); + let id = match target { + Some(raw) => lookup_window(&cc, fid, raw)?, + None => cc.borrow().active_window_id(), + }; + let core = cc.borrow(); + let window = core + .windows + .get(&id) + .ok_or_else(|| mlua::Error::runtime("pmacs.window.params: window not live"))?; + let table = lua.create_table()?; + table.set("window", id.raw())?; + table.set("side", window.params.side.map(Side::name))?; + table.set("fixed_rows", window.params.fixed_rows)?; + table.set("dedicated", window.params.dedicated)?; + table.set( + "origin_document", + window.params.origin_document().map(WindowId::raw), + )?; + table.set( + "quit_action", + window.params.quit_action().map(|action| match action { + crate::window::QuitAction::Delete => "delete", + crate::window::QuitAction::Restore { .. } => "restore", + }), + )?; + table.set( + "quit_depth", + window + .params + .quit_action() + .map_or(0, crate::window::QuitAction::depth), + )?; + table.set( + "hidden", + window.is_side() && core.panel_hidden_for(fid), + )?; + Ok(table) + })?, + )?; + } + + { + // Only `fixed_rows` and `dedicated` are writable (Q#BP2c). + let cc = core.clone(); + win.set( + "set_params", + lua.create_function(move |lua, (target, opts): (u64, Table)| -> mlua::Result<()> { + let fid = acting_frontend(lua, &cc); + let id = lookup_window(&cc, fid, target)?; + for key in ["side", "origin_document", "quit_action"] { + if opts.get::(key)? != Value::Nil { + return Err(mlua::Error::runtime(format!( + "pmacs.window.set_params: `{key}` is not settable" + ))); + } + } + let height = match opts.get::>("fixed_rows")? { + Some(rows) => Some( + crate::editor_core::EditorCore::clamp_panel_rows(rows) + .map_err(mlua::Error::runtime)?, + ), + None => None, + }; + let dedicated = opts.get::>("dedicated")?; + { + let mut core = cc.borrow_mut(); + let window = core.windows.get_mut(&id).ok_or_else(|| { + mlua::Error::runtime("pmacs.window.set_params: window not live") + })?; + if let Some(rows) = height { + // Inert on an ordinary window by construction: + // the fixed map is built from side windows only. + window.params.fixed_rows = Some(rows); + } + if let Some(dedicated) = dedicated { + window.params.dedicated = dedicated; + } + } + reconcile_panel_layout(lua, &cc, fid); + Ok(()) + })?, + )?; + } + + { + // Q#BP5b — `resize(win, delta_rows)` resolves from the SUPPLIED + // window; the `window.enlarge` / `window.shrink` commands are + // implicitly active. + let cc = core.clone(); + win.set( + "resize", + lua.create_function( + move |lua, (target, delta): (Option, i32)| -> mlua::Result<()> { + let fid = acting_frontend(lua, &cc); + let id = match target { + Some(raw) => lookup_window(&cc, fid, raw)?, + None => cc.borrow().active_window_id(), + }; + let area_rows = cc.borrow().frontend_area_rows(fid).ok_or_else(|| { + mlua::Error::runtime( + "pmacs.window.resize: this frontend has not declared its geometry yet", + ) + })?; + let minima: std::collections::HashMap = { + let core = cc.borrow(); + core.views + .get(&fid) + .map(|view| { + view.layout + .iter_ids() + .into_iter() + .map(|id| { + let buffer_id = + core.windows.get(&id).map(|w| w.buffer_id); + ( + id, + config_u32( + lua, + "window.min-height", + buffer_id, + MIN_WINDOW_OUTER_ROWS, + ) + .max(MIN_WINDOW_OUTER_ROWS), + ) + }) + .collect() + }) + .unwrap_or_default() + }; + cc.borrow_mut() + .resize_boundary(fid, id, delta, area_rows, &|id| { + minima.get(&id).copied().unwrap_or(MIN_WINDOW_OUTER_ROWS) + }) + .map_err(mlua::Error::runtime)?; + reconcile_panel_layout(lua, &cc, fid); + Ok(()) + }, + )?, + )?; + } + + Ok(()) +} diff --git a/src/overlay_paint.rs b/src/overlay_paint.rs index b16daae..b32195a 100644 --- a/src/overlay_paint.rs +++ b/src/overlay_paint.rs @@ -109,7 +109,12 @@ pub fn paint_other_frontend_overlays( return; } let text_area = Rect::new(0, 0, text_rows, term_size.cols); - let placements = core.active_layout().compute(text_area); + // Bottom-panel arc (R5-B1): this pass derives its own text-area + // `Rect` instead of reusing `window_placements`, so it must ask for + // the same fixed extents — otherwise every peer cursor paints at the + // row it would occupy with no panel open. + let fixed = core.panel_fixed_rows(core.active_frontend_key(), text_rows); + let placements = core.active_layout().compute(text_area, &fixed); let registry = core.registry.clone(); let reg = registry.borrow(); diff --git a/src/window.rs b/src/window.rs index e1162c0..2d8afff 100644 --- a/src/window.rs +++ b/src/window.rs @@ -154,6 +154,201 @@ pub fn decimal_digits(mut n: usize) -> u32 { d } +// --------------------------------------------------------------------------- +// Window parameters (bottom-panel arc, Q#BP2) +// --------------------------------------------------------------------------- + +/// Which edge of the frame a *side window* is pinned to. +/// +/// Stage 1 of the bottom-panel arc ships exactly one side. Left / right / +/// top are named deferrals, so the enum stays closed rather than +/// accepting a value no allocator honors: a Lua caller asking for an +/// unsupported side gets a pointed error at the boundary instead of a +/// silently ordinary window. +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] +pub enum Side { + /// Pinned to the bottom of the frame (the panel slot). + Bottom, +} + +impl Side { + /// Parse the Lua-facing spelling. `None` for every unsupported value. + #[must_use] + pub fn from_name(name: &str) -> Option { + match name { + "bottom" => Some(Self::Bottom), + _ => None, + } + } + + /// The Lua-facing spelling. + #[must_use] + pub fn name(self) -> &'static str { + match self { + Self::Bottom => "bottom", + } + } +} + +/// Structural floor for a window's **outer** row extent: one text row +/// plus its mode line (`content = outer - 1`). +/// +/// Every programmatic source of `fixed_rows` clamps a nonzero request up +/// to this floor; a request of `0` is rejected rather than being an +/// invisible "open" (Q#BP2). This is *not* a promise that the layout can +/// never produce a smaller rect — [`Layout::compute`] has always been +/// allowed to hand out zero extents on an intrinsically tiny frame. The +/// bounded promise is narrower: the panel allocator never makes an +/// otherwise satisfiable document tree unsatisfiable. +pub const MIN_WINDOW_OUTER_ROWS: u32 = 2; + +/// Default `window.panel-height`: outer rows a freshly created panel +/// takes when the caller supplies no explicit `height` (Q#BP11). +pub const DEFAULT_PANEL_ROWS: u32 = 12; + +/// How far back [`QuitAction::Restore`] chains may be retained before the +/// oldest retained presentation is truncated to [`QuitAction::Delete`] +/// (Q#BP2c, R4-B6). Repeated panel replacement would otherwise grow the +/// recursive history without bound. +pub const MAX_PANEL_QUIT_DEPTH: usize = 64; + +/// What `window.quit` does to a side window (Q#BP2c). +/// +/// Present only on a side window; ordinary windows and every capability +/// fallback carry `None`. Replacing a side presentation captures the +/// outgoing one in `Restore` so `C → B → A → delete` restores the actual +/// presentations rather than forgetting `A` or leaking `C`'s height and +/// dedication into it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum QuitAction { + /// Close the side window and collapse its wrapper. + Delete, + /// Reinstate a previously displayed presentation, then fall back to + /// `then` on the next quit. + Restore { + /// Buffer that was displayed. Revalidated at quit time: a killed + /// buffer degrades the whole entry to [`QuitAction::Delete`]. + buffer_id: BufferId, + /// Requested outer rows of that presentation. + fixed_rows: u32, + /// Whether that presentation was dedicated. + dedicated: bool, + /// Saved cursor, clamped against the buffer's current contents. + cursor: Position, + /// Saved first visible line. + view_top: usize, + /// Saved sticky goal column. + goal_col: Option, + /// Saved region, if one was active. + selection: Option, + /// The action that was in force *before* this presentation + /// replaced its predecessor. + then: Box, + }, +} + +impl QuitAction { + /// Number of retained presentations in this chain, counted + /// iteratively so a long history can never blow the stack. + #[must_use] + pub fn depth(&self) -> usize { + let mut depth = 0usize; + let mut cursor = self; + while let Self::Restore { then, .. } = cursor { + depth += 1; + cursor = then; + } + depth + } + + /// Truncate the oldest retained `Restore` to [`QuitAction::Delete`] + /// so the chain holds at most `cap` presentations. Iterative, like + /// [`Self::depth`]. + pub fn truncate_to(&mut self, cap: usize) { + if cap == 0 { + *self = Self::Delete; + return; + } + let mut kept = 0usize; + let mut cursor = self; + loop { + match cursor { + Self::Delete => return, + Self::Restore { then, .. } => { + kept += 1; + if kept >= cap { + **then = Self::Delete; + return; + } + cursor = then; + } + } + } + } +} + +/// Per-window display-policy parameters (Q#BP2). +/// +/// `side` is immutable after placement; `quit_action` and +/// `origin_document` are implementation-owned bookkeeping that the Lua +/// `set_params` surface refuses to write (Q#BP2c), so Lua cannot forge a +/// window id, a buffer restore chain, or stale cursor state. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct WindowParams { + /// Side this window is pinned to, or `None` for an ordinary + /// document window. Immutable after placement (Q#BP2a). + pub side: Option, + /// Requested **outer** rows (including the mode line) when this is a + /// side window. Inert on any other window — the fixed map is built + /// from side windows only. + pub fixed_rows: Option, + /// Whether `display_buffer` may replace this window's buffer. + /// + /// Binds the **policy layer only**: raw `pmacs.window.switch_buffer` + /// and `switch_active_buffer_for` deliberately ignore it, because + /// they are the low-level escape hatch and every existing caller + /// predates this arc (Q#BP2c). + pub dedicated: bool, + /// See [`WindowParams::quit_action`]. + quit_action: Option, + /// See [`WindowParams::origin_document`]. + origin_document: Option, +} + +impl WindowParams { + /// What `window.quit` does here, if anything. + #[must_use] + pub fn quit_action(&self) -> Option<&QuitAction> { + self.quit_action.as_ref() + } + + /// Install (or clear) the quit action. Rust-internal: no Lua path + /// reaches this. + pub fn set_quit_action(&mut self, action: Option) { + self.quit_action = action; + } + + /// The remembered document window this side window was entered + /// from (Q#BP2c). Recorded at panel creation, refreshed on every + /// focus transition from a non-side window into the panel, and + /// revalidated on every use. + #[must_use] + pub fn origin_document(&self) -> Option { + self.origin_document + } + + /// Record (or clear) the remembered document window. Rust-internal. + pub fn set_origin_document(&mut self, origin: Option) { + self.origin_document = origin; + } + + /// True iff this window is pinned to a side. + #[must_use] + pub fn is_side(&self) -> bool { + self.side.is_some() + } +} + /// One leaf of the window tree: a buffer plus per-window state. pub struct Window { /// Unique identifier. @@ -186,6 +381,9 @@ pub struct Window { /// Line-number gutter mode for this window (UX gutter arc). `Off` by /// default → no gutter, no coordinate change. pub line_numbers: LineNumberMode, + /// Display-policy parameters (bottom-panel arc, Q#BP2). Default for + /// every ordinary window: no side, no fixed extent, undedicated. + pub params: WindowParams, } impl Window { @@ -204,9 +402,16 @@ impl Window { goal_col: None, last_visible_rows: 0, line_numbers: LineNumberMode::Off, + params: WindowParams::default(), } } + /// True iff this window is pinned to a side (bottom-panel arc). + #[must_use] + pub fn is_side(&self) -> bool { + self.params.is_side() + } + /// Width in cells this window's line-number gutter occupies, or `0` /// when disabled (UX gutter arc, Q#UX3). `digits(line_count) + PAD`; /// the renderer caps this against the window width and applies it as a @@ -305,6 +510,23 @@ pub struct Layout { pub root: LayoutNode, } +/// A frontend's last authoritative cell-equivalent frame capacity +/// (Q#BP2b / Q#BP15a). +/// +/// `geometry_epoch` is a monotonically increasing declaration id owned by +/// the frontend. Grid / `LOCAL` views cache their real attach and resize +/// sizes here with an internal epoch; a semantic view stays `None` — +/// **unknown**, never `24×80` — until Stage 2's authenticated +/// `FrontendCellGeometry` fills it. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct DeclaredFrameGeometry { + /// Monotonic declaration id. A lower or repeated epoch carrying + /// different data is stale. + pub geometry_epoch: u64, + /// Whole-frame capacity in cells, including the one global status row. + pub total: CellSize, +} + /// T M10.8 — one attached frontend's view of the editor. /// /// Per-frontend state for multi-frontend operation: the split tree @@ -346,6 +568,32 @@ pub struct FrontendView { /// explicitly, so the projection is never inferred from a /// `FrontendId` (**Bet B8**). pub fold_projection: bool, + /// Whether this frontend can *render* a side window (bottom-panel + /// arc, Q#BP13). + /// + /// `true` for [`FrontendId::LOCAL`](crate::protocol::FrontendId) and + /// every grid session. Stage 1 sets `false` for every semantic + /// session — the GPU band is Stage 2 — so a `display` carrying a + /// `side` falls back to the non-side target and **discards every + /// side-specific parameter** rather than pinning a document window it + /// could not show. Like `fold_projection`, deliberately has no + /// `Default`: every construction site chooses explicitly. + pub panel_capable: bool, + /// This frontend's last authoritative frame capacity, or `None` while + /// it is **unknown** (Q#BP2b). + /// + /// The panel allocator is the only consumer, and it must never guess: + /// a panel requested before a real declaration stays non-presentable + /// rather than being sized against the GPU attach request's permanent + /// `24×80` placeholder. + pub frame_geometry: Option, + /// Cached derived layout state: the side window exists but cannot be + /// satisfied on the current frame (Q#BP2b). + /// + /// Recomputed from authoritative geometry by + /// `EditorState::reconcile_panel_layout`; never persisted, never set + /// from Lua, and never `true` while no side window exists. + pub panel_hidden: bool, } impl Layout { @@ -359,17 +607,75 @@ impl Layout { /// Walk the tree and assign each leaf a viewport rectangle. /// - /// Splits divide proportionally according to their weights. If a - /// child's allocated extent is `0` (terminal too small for the + /// Splits divide proportionally according to their weights, except + /// that a leaf listed in `fixed` takes exactly that many **rows** out + /// of a horizontal split before the remainder is divided (Q#BP2). + /// The map is the *effective* allocation, not the stored request: a + /// hidden panel is passed as `0`, which gives it an empty rect and + /// hands every reclaimed row back to the document subtree. + /// + /// `fixed` is interpreted only on leaves of a **horizontal** split — + /// a vertical split divides columns, where a row count means nothing + /// — and the last flexible child still takes the remainder, so a tree + /// with no fixed leaves computes byte-identically to before this arc. + /// If a child's allocated extent is `0` (terminal too small for the /// split), that child receives an empty rect, and renderers must /// skip it. #[must_use] - pub fn compute(&self, area: Rect) -> HashMap { + pub fn compute(&self, area: Rect, fixed: &HashMap) -> HashMap { let mut out = HashMap::new(); - compute_node(&self.root, area, &mut out); + compute_node(&self.root, area, fixed, &mut out); out } + /// The single side leaf among `sides`, if this layout holds one. + /// + /// `sides` answers "is this window pinned to a side"; the caller owns + /// the `Window` table, so the predicate is injected rather than + /// duplicated here. At most one bottom side leaf exists per + /// `FrontendView` (Q#BP2a). + #[must_use] + pub fn side_leaf(&self, sides: impl Fn(WindowId) -> bool) -> Option { + self.iter_ids().into_iter().find(|id| sides(*id)) + } + + /// The document subtree beneath the root-level panel wrapper. + /// + /// A side window is installed as the final child of a horizontal + /// split wrapping the entire prior root (Q#BP2a), so the document + /// subtree is that wrapper's first child. Returns `None` when the + /// tree does not have that exact shape. + #[must_use] + pub fn document_subtree(&self, side: WindowId) -> Option<&LayoutNode> { + match &self.root { + LayoutNode::Split { + orientation: Orientation::Horizontal, + children, + .. + } if children.len() == 2 + && matches!(children[1], LayoutNode::Leaf(id) if id == side) => + { + Some(&children[0]) + } + _ => None, + } + } + + /// Wrap the entire current root in a horizontal split whose final + /// child is `side` (Q#BP2a). + /// + /// `fixed_rows` makes the panel's weight inert, so the prior root + /// keeps the flexible remainder and its **structure** — nodes, + /// weights, order, ids — is untouched (Bet B6). + pub fn install_side_leaf(&mut self, side: WindowId) { + let prior = std::mem::replace(&mut self.root, LayoutNode::Leaf(side)); + self.root = LayoutNode::Split { + orientation: Orientation::Horizontal, + weights: vec![1, 1], + children: vec![prior, LayoutNode::Leaf(side)], + }; + } + /// All [`WindowId`]s in left→right / top→bottom order. #[must_use] pub fn iter_ids(&self) -> Vec { @@ -414,25 +720,211 @@ impl Layout { /// if the layout has only one window. #[must_use] pub fn focus_next(&self, current: WindowId) -> WindowId { - let ids = self.iter_ids(); - match ids.iter().position(|&id| id == current) { - Some(i) => ids[(i + 1) % ids.len()], - None => *ids.first().unwrap_or(¤t), - } + self.focus_step(current, true, &|_| true) } /// Step focus to the previous window. #[must_use] pub fn focus_prev(&self, current: WindowId) -> WindowId { + self.focus_step(current, false, &|_| true) + } + + /// [`Self::focus_next`] / [`Self::focus_prev`] restricted to windows + /// `eligible` accepts (Q#BP6: a hidden panel is never a focus + /// destination, though it becomes one again as soon as it reappears). + /// + /// A currently focused ineligible window can always leave, so the + /// caller can never strand focus: `current` itself is not filtered. + #[must_use] + pub fn focus_step( + &self, + current: WindowId, + forward: bool, + eligible: &impl Fn(WindowId) -> bool, + ) -> WindowId { let ids = self.iter_ids(); - match ids.iter().position(|&id| id == current) { - Some(i) => ids[(i + ids.len() - 1) % ids.len()], - None => *ids.first().unwrap_or(¤t), + if ids.is_empty() { + return current; + } + let Some(start) = ids.iter().position(|&id| id == current) else { + return ids + .iter() + .copied() + .find(|id| eligible(*id)) + .unwrap_or_else(|| *ids.first().unwrap_or(¤t)); + }; + let n = ids.len(); + for step in 1..=n { + let i = if forward { + (start + step) % n + } else { + (start + n - (step % n)) % n + }; + if eligible(ids[i]) { + return ids[i]; + } + } + current + } + + /// Index path from the root to `target`'s leaf, or `None` when the + /// layout does not hold it. + #[must_use] + pub fn path_to(&self, target: WindowId) -> Option> { + let mut path = Vec::new(); + path_to_node(&self.root, target, &mut path).then_some(path) + } + + /// The node at `path`, or `None` when the path does not resolve. + #[must_use] + pub fn node_at(&self, path: &[usize]) -> Option<&LayoutNode> { + let mut node = &self.root; + for &i in path { + match node { + LayoutNode::Split { children, .. } => node = children.get(i)?, + LayoutNode::Leaf(_) => return None, + } + } + Some(node) + } + + /// Mutable [`Self::node_at`]. + pub fn node_at_mut(&mut self, path: &[usize]) -> Option<&mut LayoutNode> { + let mut node = &mut self.root; + for &i in path { + match node { + LayoutNode::Split { children, .. } => node = children.get_mut(i)?, + LayoutNode::Leaf(_) => return None, + } + } + Some(node) + } + + /// The horizontal boundary immediately **below** `target` (Q#BP5b + /// rule 2), or `None` when there is none. + /// + /// Walk up from the leaf to the nearest horizontal-split ancestor at + /// which the path child has a **following sibling**. "Nearest + /// horizontal ancestor" alone is wrong: when the subtree is that + /// ancestor's *final* child there is no boundary below it there, and + /// the real one is further up. This is also the boundary a drag on + /// `target`'s bottom mode-line row moves, so keyboard resize and drag + /// are the same operation (acceptance 31). + #[must_use] + pub fn boundary_below(&self, target: WindowId) -> Option { + let path = self.path_to(target)?; + for depth in (0..path.len()).rev() { + let parent_path = &path[..depth]; + let child_index = path[depth]; + let LayoutNode::Split { + orientation: Orientation::Horizontal, + children, + .. + } = self.node_at(parent_path)? + else { + continue; + }; + if child_index + 1 < children.len() { + return Some(SplitBoundary { + path: parent_path.to_vec(), + upper: child_index, + }); + } + } + None + } +} + +/// One horizontal split boundary: the split node plus the index of the +/// child immediately **above** the dividing line (Q#BP5). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SplitBoundary { + /// Index path from the root to the horizontal split node. + pub path: Vec, + /// Index of the child above the boundary; `upper + 1` is below it. + pub upper: usize, +} + +fn path_to_node(node: &LayoutNode, target: WindowId, path: &mut Vec) -> bool { + match node { + LayoutNode::Leaf(id) => *id == target, + LayoutNode::Split { children, .. } => { + for (i, child) in children.iter().enumerate() { + path.push(i); + if path_to_node(child, target, path) { + return true; + } + path.pop(); + } + false } } } -fn compute_node(node: &LayoutNode, area: Rect, out: &mut HashMap) { +/// Minimum **outer** rows a subtree needs for every one of its leaves to +/// clear [`MIN_WINDOW_OUTER_ROWS`] (Q#BP2). +/// +/// The recursion is the point: "leave the document tree two rows" is +/// wrong, because two rows at the root does not give each nested leaf two +/// rows. Horizontal splits stack rows, so minima add; vertical splits +/// share rows, so the tallest child governs. +#[must_use] +pub fn subtree_min_rows(node: &LayoutNode) -> u32 { + match node { + LayoutNode::Leaf(_) => MIN_WINDOW_OUTER_ROWS, + LayoutNode::Split { + orientation: Orientation::Horizontal, + children, + .. + } => children.iter().map(subtree_min_rows).sum(), + LayoutNode::Split { + orientation: Orientation::Vertical, + children, + .. + } => children.iter().map(subtree_min_rows).max().unwrap_or(0), + } +} + +/// The same sum/max recursion over the user's `window.min-height` +/// *preference* (Q#BP2). +/// +/// `per_leaf` resolves the setting against that window's own buffer +/// (buffer-local override → global → default) and is snapshotted once per +/// gesture, before any geometry changes. Only **interactive** resize — +/// drag, keyboard, and the Stage 2 `PanelResizeRows` — consults this; the +/// ordinary layout pass and frame-resize reconciliation use +/// [`subtree_min_rows`] alone, so changing a preference can never +/// invalidate an existing layout. +#[must_use] +pub fn interactive_min_rows(node: &LayoutNode, per_leaf: &impl Fn(WindowId) -> u32) -> u32 { + match node { + LayoutNode::Leaf(id) => per_leaf(*id), + LayoutNode::Split { + orientation: Orientation::Horizontal, + children, + .. + } => children + .iter() + .map(|child| interactive_min_rows(child, per_leaf)) + .sum(), + LayoutNode::Split { + orientation: Orientation::Vertical, + children, + .. + } => children + .iter() + .map(|child| interactive_min_rows(child, per_leaf)) + .max() + .unwrap_or(0), + } +} + +fn compute_node( + node: &LayoutNode, + area: Rect, + fixed: &HashMap, + out: &mut HashMap, +) { match node { LayoutNode::Leaf(id) => { out.insert(*id, area); @@ -442,18 +934,61 @@ fn compute_node(node: &LayoutNode, area: Rect, out: &mut HashMap weights, children, } => { - let total: u32 = weights.iter().map(|w| (*w).max(1)).sum(); let primary = match orientation { Orientation::Horizontal => area.size.rows, Orientation::Vertical => area.size.cols, }; + // Pass 1 — subtract the fixed children. Only a horizontal + // split divides rows, so `fixed` is inert anywhere else. + let mut extents: Vec> = vec![None; children.len()]; + let mut fixed_total: u32 = 0; + if matches!(orientation, Orientation::Horizontal) { + for (i, child) in children.iter().enumerate() { + if let LayoutNode::Leaf(id) = child + && let Some(rows) = fixed.get(id).copied() + { + // Saturating: a request larger than the frame + // takes what is left rather than wrapping. The + // caller has already clamped against the document + // minimum; this is the last-resort floor. + let take = rows.min(primary.saturating_sub(fixed_total)); + extents[i] = Some(take); + fixed_total += take; + } + } + } + // Pass 2 — divide the remainder by weight among the flexible + // children, preserving last-flexible-takes-the-remainder. + let remainder = primary.saturating_sub(fixed_total); + let total: u32 = children + .iter() + .enumerate() + .filter(|(i, _)| extents[*i].is_none()) + .map(|(i, _)| weights.get(i).copied().unwrap_or(1).max(1)) + .sum(); + let last_flexible = children + .iter() + .enumerate() + .rev() + .find(|(i, _)| extents[*i].is_none()) + .map(|(i, _)| i); + let mut flexible_used: u32 = 0; let mut cursor: u32 = 0; for (i, child) in children.iter().enumerate() { - let w = weights.get(i).copied().unwrap_or(1).max(1); - let extent = if i + 1 == children.len() { - primary - cursor - } else { - primary * w / total + let extent = match extents[i] { + Some(rows) => rows, + None => { + let w = weights.get(i).copied().unwrap_or(1).max(1); + let e = if Some(i) == last_flexible { + remainder - flexible_used + } else if total == 0 { + 0 + } else { + remainder * w / total + }; + flexible_used += e; + e + } }; let child_area = match orientation { Orientation::Horizontal => Rect { @@ -465,13 +1000,21 @@ fn compute_node(node: &LayoutNode, area: Rect, out: &mut HashMap size: CellSize::new(area.size.rows, extent), }, }; - compute_node(child, child_area, out); + compute_node(child, child_area, fixed, out); cursor += extent; } } } } +/// Every [`WindowId`] beneath `node`, in layout order. +#[must_use] +pub fn node_ids(node: &LayoutNode) -> Vec { + let mut out = Vec::new(); + collect_ids(node, &mut out); + out +} + fn collect_ids(node: &LayoutNode, out: &mut Vec) { match node { LayoutNode::Leaf(id) => out.push(*id), @@ -598,7 +1141,7 @@ mod tests { fn single_window_takes_full_area() { let w = id(); let layout = Layout::single(w); - let placements = layout.compute(rect_24x80()); + let placements = layout.compute(rect_24x80(), &HashMap::new()); assert_eq!(placements.get(&w), Some(&rect_24x80())); } @@ -608,7 +1151,7 @@ mod tests { let b = id(); let mut layout = Layout::single(a); assert!(layout.split_window(a, Orientation::Vertical, b)); - let placements = layout.compute(rect_24x80()); + let placements = layout.compute(rect_24x80(), &HashMap::new()); let ra = placements[&a]; let rb = placements[&b]; assert_eq!(ra.size.rows, 24); @@ -624,7 +1167,7 @@ mod tests { let b = id(); let mut layout = Layout::single(a); assert!(layout.split_window(a, Orientation::Horizontal, b)); - let placements = layout.compute(rect_24x80()); + let placements = layout.compute(rect_24x80(), &HashMap::new()); let ra = placements[&a]; let rb = placements[&b]; assert_eq!(ra.size.cols, 80); @@ -644,15 +1187,15 @@ mod tests { } else { panic!("expected split"); } - let p1 = layout.compute(Rect::new(0, 0, 24, 90)); + let p1 = layout.compute(Rect::new(0, 0, 24, 90), &HashMap::new()); assert_eq!(p1[&a].size.cols, 60); assert_eq!(p1[&b].size.cols, 30); // Resize down by 1/3. - let p2 = layout.compute(Rect::new(0, 0, 24, 60)); + let p2 = layout.compute(Rect::new(0, 0, 24, 60), &HashMap::new()); assert_eq!(p2[&a].size.cols, 40); assert_eq!(p2[&b].size.cols, 20); // Resize wide. - let p3 = layout.compute(Rect::new(0, 0, 24, 300)); + let p3 = layout.compute(Rect::new(0, 0, 24, 300), &HashMap::new()); assert_eq!(p3[&a].size.cols, 200); assert_eq!(p3[&b].size.cols, 100); } @@ -681,7 +1224,7 @@ mod tests { } leaves.extend(more); assert_eq!(leaves.len(), 8); - let placements = layout.compute(rect_24x80()); + let placements = layout.compute(rect_24x80(), &HashMap::new()); assert_eq!(placements.len(), 8); // Every rect must be non-empty (terminal large enough). for id in &leaves { diff --git a/tests/folding_stage2_acceptance.rs b/tests/folding_stage2_acceptance.rs index cbef8cd..79b41c8 100644 --- a/tests/folding_stage2_acceptance.rs +++ b/tests/folding_stage2_acceptance.rs @@ -1516,6 +1516,9 @@ fn attach_frontend(s: &EditorState, fid: FrontendId, fold_projection: bool) -> W layout: Layout::single(win_id), active: win_id, fold_projection, + panel_capable: true, + frame_geometry: None, + panel_hidden: false, }, ); win_id diff --git a/tests/statusline_segments_acceptance.rs b/tests/statusline_segments_acceptance.rs index 6a08c41..120ca23 100644 --- a/tests/statusline_segments_acceptance.rs +++ b/tests/statusline_segments_acceptance.rs @@ -465,6 +465,9 @@ fn a05_08_evaluator_latches_reentrancy_contexts_and_mutation_guards() { layout: pmacs::window::Layout::single(window_id), active: window_id, fold_projection: true, + panel_capable: true, + frame_geometry: None, + panel_hidden: false, }, ); } diff --git a/tests/vterm_stage3_acceptance.rs b/tests/vterm_stage3_acceptance.rs index 8bf44a5..04b2c27 100644 --- a/tests/vterm_stage3_acceptance.rs +++ b/tests/vterm_stage3_acceptance.rs @@ -82,6 +82,9 @@ fn attach_view( layout: Layout::single(window_id), active: window_id, fold_projection: true, + panel_capable: true, + frame_geometry: None, + panel_hidden: false, }, ); window_id From 683c9b86aa1e94dc3c6360e025e852fa89127734 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 24 Jul 2026 14:10:45 -0400 Subject: [PATCH 03/22] feat(window): adopter placement opt-in and the Stage 1 acceptance suite - `listview.open`, `compile.run`, and `pmacs.terminal.open` all take the same strict `display = "current" | "panel"`, validated before any buffer, session, process, or wrapper exists. Omission keeps today's behavior; Stage 3 flips the default. - `listview.quit` / `compile.quit` delegate to `window.quit` only when the buffer really is in a side window, so the presentation is deleted or restored instead of leaving a source buffer stranded in the slot. - LSP `visit_location`, LSP go-to-definition, and compile `visit_error` route through `display_file`, so a visit from a panel lands in the document target and fires its hook with that window active. - `window.quit`'s Delete arm focuses the revalidated remembered origin. - Capability fallback discards an accompanying `height` rather than rejecting the call. - `window.min-height` clamps a below-floor value on read instead of refusing the write. - `tests/bottom_panel_stage1_acceptance.rs`: 42 tests over the framing's Stage 1 criteria, including the two production `Layout::compute` callers, the recursive minima, hide/reappear, the final-focus matrix, quit chains at the depth cap, per-frontend jump origins, the divider, and a real-PTY pin of Bet B1. Co-Authored-By: Claude Opus 5 (1M context) --- builtin/runtime/compile.lua | 34 +- builtin/runtime/listview.lua | 29 +- builtin/runtime/lsp.lua | 10 +- builtin/runtime/window.lua | 7 +- src/daemon.rs | 4 +- src/editor.rs | 72 +- src/editor_core.rs | 137 +- src/lua_bindings/mod.rs | 59 +- src/lua_bindings/window_panel.rs | 212 ++- src/window.rs | 29 +- tests/bottom_panel_stage1_acceptance.rs | 2224 +++++++++++++++++++++++ 11 files changed, 2646 insertions(+), 171 deletions(-) create mode 100644 tests/bottom_panel_stage1_acceptance.rs diff --git a/builtin/runtime/compile.lua b/builtin/runtime/compile.lua index e364de2..2c00731 100644 --- a/builtin/runtime/compile.lua +++ b/builtin/runtime/compile.lua @@ -734,6 +734,16 @@ end) -- has its own worker path. local function start_run(slot, cmdline, opts) opts = opts or {} + -- Bottom-panel arc (Q#BP11b): validate placement BEFORE the run + -- supersedes anything, rewrites the buffer, or spawns a process, so + -- an unknown value leaves no half-started run behind. In Stages 1-2 + -- omission means "current"; Stage 3 flips the default. + local display = opts.display + if display ~= nil and display ~= "current" and display ~= "panel" then + error(string.format( + "compile.run: unknown display %q (expected \"current\" or \"panel\")", + tostring(display))) + end -- q-target discipline (Q#CM11): capture only when coming from a -- non-generated buffer, so `g` reruns don't re-capture and -- compile → g → q restores the original buffer. @@ -805,7 +815,16 @@ local function start_run(slot, cmdline, opts) -- attach here stacked a duplicate render view per run (round-5 -- finding 1; translation itself is buffer-level and unaffected by -- attachment count). - pmacs.window.switch_buffer(slot.buf) + -- The FIRST display of this run is the side-affine one (Q#BP3): a + -- persistent *compilation* already visible in a document window must + -- not preempt the requested panel. Compile output is passive, so it + -- takes `select = false` explicitly; a recompile simply reuses the + -- panel it is already in. + if display == "panel" then + pmacs.window.display(slot.buf, { side = "bottom", select = false }) + else + pmacs.window.switch_buffer(slot.buf) + end if not ok then emit_text_raw(slot, string.format("[%s spawn failed: %s]\n", slot.label, tostring(proc))) slot.expected_rev = buf:revision() @@ -866,7 +885,9 @@ local function visit_error(slot, idx) if not e then return end local path = resolve_error_path(slot, e.file) pmacs.editor.push_jump() - local ok, err = pcall(pmacs.buffer.find_or_open, path) + -- Bottom-panel arc (Q#BP11b): RET from a compilation PANEL opens the + -- source in the document target, leaving the panel where it is. + local ok, err = pcall(pmacs.window.display_file, path, { select = true }) if not ok then pmacs.editor.jump_back() pmacs.editor.set_status(slot.label .. ": failed to open " .. path .. ": " .. tostring(err)) @@ -995,6 +1016,15 @@ pmacs.command.define { fn = function() local slot = slot_for_buffer(pmacs.window.buffer()) if not slot then return end + -- Bottom-panel arc (Q#BP11b): in a side window, `q` deletes or + -- restores the PRESENTATION rather than leaving a source buffer + -- stranded in the panel slot. Capability fallback and pre-arc + -- placement keep today's previous-buffer restore below. + local params = pmacs.window.params() + if params and params.side and params.quit_action then + pmacs.window.quit() + return + end local target = slot.prev if not (target and target:is_valid()) then target = buffer_named("*scratch*") or pmacs.buffer.create("*scratch*") diff --git a/builtin/runtime/listview.lua b/builtin/runtime/listview.lua index 4be4faa..6081587 100644 --- a/builtin/runtime/listview.lua +++ b/builtin/runtime/listview.lua @@ -123,7 +123,25 @@ function pmacs.listview.open(spec) p.prev = active end render(p, spec.rows or {}) - pmacs.window.switch_buffer(p.buffer) + -- Bottom-panel arc (Q#BP11b): the placement opt-in. `seat_cursor` and + -- `listview.refresh` are active-window-only, so an interactive panel + -- MUST take `select = true` or it would silently seat the wrong + -- window. In Stages 1-2 omitting `display` keeps today's raw switch; + -- Stage 3 flips the default. An unknown value errors before anything + -- is displayed. + local display = spec.display + if display ~= nil and display ~= "current" and display ~= "panel" then + error(string.format( + "listview.open: unknown display %q (expected \"current\" or \"panel\")", + tostring(display))) + end + if display == "panel" then + p.side = true + pmacs.window.display(p.buffer, { side = "bottom", select = true }) + else + p.side = false + pmacs.window.switch_buffer(p.buffer) + end seat_cursor(p, 1) end @@ -160,6 +178,15 @@ pmacs.command.define { fn = function() local p = panel_for_current_buffer() if not p then return end + -- Bottom-panel arc (Q#BP11b): `q` keeps its name and its + -- user-visible behavior, delegating to `window.quit` only when the + -- listview really is in a side window. Capability fallback (and any + -- pre-arc placement) keeps the previous-buffer switch below. + local params = pmacs.window.params() + if params and params.side and params.quit_action then + pmacs.window.quit() + return + end local target = p.prev if not (target and target:is_valid()) then target = find_buffer_by_name("*scratch*") or pmacs.buffer.create("*scratch*") diff --git a/builtin/runtime/lsp.lua b/builtin/runtime/lsp.lua index d7ab9d9..4181156 100644 --- a/builtin/runtime/lsp.lua +++ b/builtin/runtime/lsp.lua @@ -1565,7 +1565,11 @@ function pmacs.lsp.go_to_definition() return end pmacs.editor.push_jump() - local ok2, oerr = pcall(pmacs.buffer.find_or_open, path) + -- Bottom-panel arc (Q#BP11b): the target-aware load. `find_or_open` + -- switches the ACTIVE window, which would replace a focused panel; + -- `display_file` resolves the DOCUMENT target first and fires the + -- load/switch hook with that window active. + local ok2, oerr = pcall(pmacs.window.display_file, path, { select = true }) if not ok2 then -- Open failed: drop the origin we just pushed so M-, isn't -- left pointing at a jump that never happened. @@ -1601,7 +1605,9 @@ local function visit_location(loc) return end pmacs.editor.push_jump() - local ok, err = pcall(pmacs.buffer.find_or_open, path) + -- Bottom-panel arc (Q#BP11b): a visit FROM a panel must land in the + -- document target and leave the panel intact. + local ok, err = pcall(pmacs.window.display_file, path, { select = true }) if not ok then -- Open failed: drop the origin we just pushed so M-, isn't left -- pointing at a jump that never happened. diff --git a/builtin/runtime/window.lua b/builtin/runtime/window.lua index cf63443..37b459f 100644 --- a/builtin/runtime/window.lua +++ b/builtin/runtime/window.lua @@ -27,12 +27,17 @@ pmacs.config.define { -- (drag and the commands below) and is deliberately ignored by the -- ordinary layout pass and by frame-resize reconciliation, so raising it -- can never invalidate a layout that already exists. +-- +-- The registry floor is 1 rather than 2 on purpose: a value below the +-- STRUCTURAL floor is clamped when it is read, not rejected when it is +-- written, so a user who asks for a smaller minimum simply gets the +-- smallest one the layout can actually honor. pmacs.config.define { name = "window.min-height", description = "Smallest outer rows interactive resize will leave a window.", type = "integer", default = 2, - min = 2, + min = 1, mutability = "live", } diff --git a/src/daemon.rs b/src/daemon.rs index cb82c3b..44d4b61 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -905,7 +905,7 @@ fn peer_declared_terminal_support( /// from the daemon's own negotiated state, and Stage 2 turns the version /// arm on (`semantic_render && negotiated_protocol_version >= /// PANEL_MIN_VERSION`). -fn peer_declared_panel_support(session_state: &crate::presence::SessionState) -> bool { +fn peer_declared_panel_support(session_state: crate::presence::SessionState) -> bool { !session_state.negotiated_capabilities.semantic_render } @@ -1808,7 +1808,7 @@ fn handle_session_established( let fresh_view = build_fresh_frontend_view( editor, !session_state.negotiated_capabilities.semantic_render, - peer_declared_panel_support(&session_state), + peer_declared_panel_support(session_state), ); { let mut core = editor.core.borrow_mut(); diff --git a/src/editor.rs b/src/editor.rs index d6c6a51..2d2a5e8 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -841,14 +841,13 @@ impl EditorState { .get(&window_id) .map(|window| window.buffer_id); if let Some(buffer_id) = buffer_id { - let _ = self - .terminal_manager - .borrow_mut() - .release_controller(crate::terminal::TerminalViewKey { + let _ = self.terminal_manager.borrow_mut().release_controller( + crate::terminal::TerminalViewKey { frontend_id, window_id, buffer_id, - }); + }, + ); } } outcome.changed @@ -1931,7 +1930,8 @@ impl EditorState { MouseEventKind::Drag(MouseButton::Left) => { self.drag_window_boundary(frontend_id, cell_row, term_size); } - MouseEventKind::Up(MouseButton::Left) => self.window_drag = None, + // Any other event — release, a different button, a + // wheel notch — ends the gesture. _ => self.window_drag = None, } return; @@ -2062,9 +2062,12 @@ impl EditorState { /// Arm a divider drag if `owner`'s bottom row really is an exposed /// segment of a horizontal boundary (Q#BP5). fn arm_window_drag(&mut self, frontend_id: FrontendId, owner: WindowId, cell_row: u32) { - let is_divider = self.core.borrow().views.get(&frontend_id).is_some_and(|view| { - view.layout.boundary_below(owner).is_some() - }); + let is_divider = self + .core + .borrow() + .views + .get(&frontend_id) + .is_some_and(|view| view.layout.boundary_below(owner).is_some()); self.window_drag = is_divider.then_some(WindowDragState { frontend_id, owner, @@ -2078,14 +2081,22 @@ impl EditorState { /// layout mutation mid-drag cannot move a boundary that no longer /// exists. Motion is applied incrementally and re-anchored each /// event, so the clamp absorbs over-travel instead of accumulating it. - fn drag_window_boundary(&mut self, frontend_id: FrontendId, cell_row: u32, term_size: CellSize) { + fn drag_window_boundary( + &mut self, + frontend_id: FrontendId, + cell_row: u32, + term_size: CellSize, + ) { let Some(drag) = self.window_drag else { return; }; if drag.frontend_id != frontend_id { return; } - self.window_drag = Some(WindowDragState { last_row: cell_row, ..drag }); + self.window_drag = Some(WindowDragState { + last_row: cell_row, + ..drag + }); let delta = i64::from(cell_row) - i64::from(drag.last_row); let Ok(delta) = i32::try_from(delta) else { return; @@ -3126,13 +3137,14 @@ pub fn paint_frame( // upper child is a nested subtree exposes SEVERAL leaf segments along // the same edge, so the root panel divider is full width even when // the document subtree ends in several columns. - let divider_windows: Vec = core.views.get(&frontend_id).map_or_else(Vec::new, |view| { - view.layout - .iter_ids() - .into_iter() - .filter(|id| view.layout.boundary_below(*id).is_some()) - .collect() - }); + let divider_windows: Vec = + core.views.get(&frontend_id).map_or_else(Vec::new, |view| { + view.layout + .iter_ids() + .into_iter() + .filter(|id| view.layout.boundary_below(*id).is_some()) + .collect() + }); let divider_style = theme.face("ui.divider"); // Clear the whole grid first so windows that shrink on resize @@ -7073,22 +7085,14 @@ mod tests { } else { panic!("expected split"); } - let p1 = s - .core - .borrow() - .active_layout() - .compute( - crate::window::Rect::new(0, 0, 24, 90), - &std::collections::HashMap::new(), - ); - let p2 = s - .core - .borrow() - .active_layout() - .compute( - crate::window::Rect::new(0, 0, 24, 60), - &std::collections::HashMap::new(), - ); + let p1 = s.core.borrow().active_layout().compute( + crate::window::Rect::new(0, 0, 24, 90), + &std::collections::HashMap::new(), + ); + let p2 = s.core.borrow().active_layout().compute( + crate::window::Rect::new(0, 0, 24, 60), + &std::collections::HashMap::new(), + ); // Both should preserve the 2:1 ratio. Find the two windows // and verify the larger:smaller ratio is 2:1 in both. let wider1 = p1.values().map(|r| r.size.cols).max().unwrap(); diff --git a/src/editor_core.rs b/src/editor_core.rs index 9ab4bca..039ee43 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -882,10 +882,7 @@ impl EditorCore { /// /// # Errors /// Any load failure other than `NotFound`. - pub fn resolve_target_buffer( - &mut self, - path: &Path, - ) -> Result<(BufferId, HookKind), String> { + pub fn resolve_target_buffer(&mut self, path: &Path) -> Result<(BufferId, HookKind), String> { match self.get_or_load_buffer(path) { Ok((buffer_id, true)) => Ok((buffer_id, HookKind::AfterLoad)), Ok((buffer_id, false)) => Ok((buffer_id, HookKind::AfterSwitch)), @@ -1060,11 +1057,7 @@ impl EditorCore { pub fn jump_back(&mut self) -> bool { let fid = self.active_frontend; loop { - let Some(entry) = self - .jump_ring - .get_mut(&fid) - .and_then(std::vec::Vec::pop) - else { + let Some(entry) = self.jump_ring.get_mut(&fid).and_then(std::vec::Vec::pop) else { return false; }; if !self.registry.borrow().contains(entry.buffer_id) { @@ -1081,12 +1074,20 @@ impl EditorCore { && !self.side_window_is_hidden(fid, entry.window_id); if origin_valid { self.set_active_window_id(entry.window_id); - } else if entry.side_origin { - continue; - } else if self.active_buffer_id() != entry.buffer_id - && self.switch_active_buffer(entry.buffer_id).is_err() - { - continue; + } else { + // A stale SIDE origin is skipped outright: switching a + // panel's buffer into the document window is exactly the + // duplicate-presentation corruption this design removes. + // A stale non-side origin keeps today's active-window + // fallback. + if entry.side_origin { + continue; + } + if self.active_buffer_id() != entry.buffer_id + && self.switch_active_buffer(entry.buffer_id).is_err() + { + continue; + } } let clamped = entry.position.min(self.active_buffer_len()); let aw = self.active_window_mut(); @@ -2713,10 +2714,8 @@ impl EditorCore { } self.active_layout_mut().close_window(target); self.windows.remove(&target); - if target_is_side { - if let Some(view) = self.views.get_mut(&fid) { - view.panel_hidden = false; - } + if target_is_side && let Some(view) = self.views.get_mut(&fid) { + view.panel_hidden = false; } // Pick an adjacent window as the new focus, preferring a document. let ids = self.active_layout().iter_ids(); @@ -3002,10 +3001,9 @@ impl EditorCore { self.windows.remove(&side); if was_active && let Ok(target) = self.non_side_target(fid) + && let Some(view) = self.views.get_mut(&fid) { - if let Some(view) = self.views.get_mut(&fid) { - view.active = target; - } + view.active = target; } // A remembered origin pointing at a now-dead window is cleared by // `non_side_target`'s revalidation on next use; nothing else here @@ -3050,14 +3048,34 @@ impl EditorCore { }; match action { QuitAction::Delete => { - let saved_active = self.views.get(&fid).map(|view| view.active); + // Capture the remembered origin BEFORE the window dies: + // executing `Delete` focuses the revalidated origin, not + // merely whatever leaf the wrapper collapse surfaced + // (Q#BP11b). Entering the panel from document window B + // must therefore return focus to B, not to the window + // that happened to create the panel. + let origin = self + .windows + .get(&target) + .and_then(|window| window.params.origin_document()); self.remove_side_window(fid, target); - Ok(QuitOutcome::Deleted { - focus: self - .views + let origin_valid = origin.is_some_and(|origin| { + self.views .get(&fid) - .map(|view| view.active) - .or(saved_active), + .is_some_and(|view| view.layout.iter_ids().contains(&origin)) + && !self + .windows + .get(&origin) + .is_some_and(crate::window::Window::is_side) + }); + if origin_valid + && let Some(origin) = origin + && let Some(view) = self.views.get_mut(&fid) + { + view.active = origin; + } + Ok(QuitOutcome::Deleted { + focus: self.views.get(&fid).map(|view| view.active), }) } QuitAction::Restore { @@ -3073,7 +3091,7 @@ impl EditorCore { self.install_buffer_in_window(target, buffer_id)?; let len = { let reg = self.registry.borrow(); - reg.get(buffer_id).map(Buffer::len).unwrap_or(0) + reg.get(buffer_id).map_or(0, Buffer::len) }; let window = self .windows @@ -3091,10 +3109,7 @@ impl EditorCore { window.view_top = view_top; window.goal_col = goal_col; window.selection = selection.filter(|sel| sel.anchor <= len); - Ok(QuitOutcome::Restored { - target, - buffer_id, - }) + Ok(QuitOutcome::Restored { target, buffer_id }) } } } @@ -3117,8 +3132,7 @@ impl EditorCore { #[must_use] pub fn frontend_area_rows(&self, fid: FrontendId) -> Option { let geometry = self.views.get(&fid)?.frame_geometry?; - (geometry.total.rows >= 2 && geometry.total.cols > 0) - .then(|| geometry.total.rows - 1) + (geometry.total.rows >= 2 && geometry.total.cols > 0).then(|| geometry.total.rows - 1) } /// Cache a frontend's authoritative frame capacity (Q#BP2b). @@ -3207,6 +3221,10 @@ impl EditorCore { /// # Errors /// When `win` is not live in `fid`'s layout, when the panel is /// hidden, or when no adjustable horizontal boundary exists. + #[allow( + clippy::too_many_lines, + reason = "one boundary-resize transaction: resolve, snapshot minima, clamp, write back" + )] pub fn resize_boundary( &mut self, fid: FrontendId, @@ -3260,9 +3278,9 @@ impl EditorCore { ) } else { ( - view.layout - .boundary_below(win) - .ok_or_else(|| "window.resize: no adjustable horizontal boundary".to_string())?, + view.layout.boundary_below(win).ok_or_else(|| { + "window.resize: no adjustable horizontal boundary".to_string() + })?, false, ) }; @@ -3423,13 +3441,7 @@ impl EditorCore { target: placement.target, saved_active, select, - created_side: matches!( - placement.kind, - PlacementKind::Side { - created: true, - .. - } - ), + created_side: matches!(placement.kind, PlacementKind::Side { created: true, .. }), }) } @@ -3494,7 +3506,12 @@ impl EditorCore { if let Ok(preferred) = self.non_side_target(fid) { candidates.push(preferred); } - candidates.extend(view.layout.iter_ids().into_iter().filter(|id| !is_side(*id))); + candidates.extend( + view.layout + .iter_ids() + .into_iter() + .filter(|id| !is_side(*id)), + ); candidates .into_iter() .find(|id| eligible(*id)) @@ -3506,6 +3523,10 @@ impl EditorCore { /// otherwise a persistent `*compilation*` buffer already visible in a /// document window makes `{side = "bottom"}` silently ignore its /// requested placement. + #[allow( + clippy::too_many_lines, + reason = "Q#BP3's precedence ladder reads as one ordered policy" + )] fn resolve_placement( &self, fid: FrontendId, @@ -3594,7 +3615,12 @@ impl EditorCore { }); } } - } else if request.height.is_some() { + } else if request.side.is_none() && request.height.is_some() { + // A freestanding `height` with no side request is a mistake. + // A `height` that arrived WITH a side request and fell + // through (not panel-capable, or the one slot is dedicated + // elsewhere) is discarded, not rejected — capability + // fallback must not turn into an error (Q#BP2c). return Err("display: `height` requires a side window".into()); } @@ -3624,11 +3650,17 @@ impl EditorCore { if let Ok(preferred) = self.non_side_target(fid) { candidates.push(preferred); } - candidates.extend(view.layout.iter_ids().into_iter().filter(|id| !is_side(*id))); + candidates.extend( + view.layout + .iter_ids() + .into_iter() + .filter(|id| !is_side(*id)), + ); for candidate in candidates { - let eligible = self.windows.get(&candidate).is_some_and(|w| { - !w.params.dedicated || w.buffer_id == request.buffer_id - }); + let eligible = self + .windows + .get(&candidate) + .is_some_and(|w| !w.params.dedicated || w.buffer_id == request.buffer_id); if eligible { return Ok(Placement { target: candidate, @@ -3681,7 +3713,8 @@ impl EditorCore { let requested_side = request.side.unwrap_or(Side::Bottom); if created { - let rows = Self::clamp_panel_rows(request.height.unwrap_or(request.default_panel_rows))?; + let rows = + Self::clamp_panel_rows(request.height.unwrap_or(request.default_panel_rows))?; let origin = self.non_side_target(fid).ok(); let text_view = { let reg = self.registry.borrow(); diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index a1ea3fc..3d8a4e9 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -8501,8 +8501,8 @@ fn install_terminal( let supervisor = supervisor.clone(); terminal.set( "_open", - lua.create_function(move |lua, spec: Table| -> mlua::Result { - let spec = parse_terminal_spec(&spec)?; + lua.create_function(move |lua, spec_table: Table| -> mlua::Result { + let spec = parse_terminal_spec(&spec_table)?; let core = lua .app_data_ref::() .map(|core| core.clone()) @@ -8515,37 +8515,54 @@ fn install_terminal( "pmacs.terminal.open: target frontend has no active window", )); } + // Bottom-panel arc (Q#BP11b): parse placement BEFORE the + // session, process, buffer, or wrapper exists, so an + // unknown `display` value creates nothing to roll back. + let placement = window_panel::parse_adopter_placement( + &core, + frontend_id, + "pmacs.terminal.open", + spec_table.get::>("display")?.as_deref(), + spec_table.get::>("window")?, + )?; let buffer_id = { let mut manager = manager.borrow_mut(); manager .open(spec, &mut core.borrow_mut(), &mut supervisor.borrow_mut()) .map_err(mlua::Error::external)? }; - let key = { - let mut core = core.borrow_mut(); - if let Err(error) = core.switch_active_buffer_for(frontend_id, buffer_id) { + let outcome = match window_panel::place_adopter_buffer( + lua, + &core, + frontend_id, + buffer_id, + &placement, + true, + ) { + Ok(outcome) => outcome, + Err(error) => { + let mut core = core.borrow_mut(); let _ = core.registry.borrow_mut().remove(buffer_id); manager .borrow_mut() .prune(&mut core, &mut supervisor.borrow_mut()); - return Err(mlua::Error::external(format!( - "pmacs.terminal.open: active-window switch failed: {error}" - ))); + return Err(error); } - crate::terminal::TerminalViewKey::new( - frontend_id, - core.views - .get(&frontend_id) - .expect("checked frontend has active view") - .active, - buffer_id, - ) }; + let key = + crate::terminal::TerminalViewKey::new(frontend_id, outcome.target, buffer_id); let claimed = { let mut manager = manager.borrow_mut(); manager.register_view(key) && manager.claim_controller(key) }; if !claimed { + // Placement failure removes any side wrapper this + // transaction created, BEFORE the existing + // session/buffer rollback completes (Q#BP11b). + if outcome.created_side { + core.borrow_mut() + .remove_side_window(frontend_id, outcome.target); + } let mut core = core.borrow_mut(); let _ = core.registry.borrow_mut().remove(buffer_id); manager @@ -8555,7 +8572,7 @@ fn install_terminal( "pmacs.terminal.open: failed to claim the new terminal view", )); } - run_hook_if_defined(lua, "buffer.after-switch", mlua::MultiValue::new()); + window_panel::finish_adopter_placement(lua, &core, frontend_id, outcome)?; Ok(BufferIdLua(buffer_id)) })?, )?; @@ -8717,6 +8734,10 @@ fn parse_terminal_spec(table: &Table) -> mlua::Result mlua::Result
{ win.set( "close_others", lua.create_function(move |_, ()| { - cc.borrow_mut() - .close_others() - .map_err(mlua::Error::runtime) + cc.borrow_mut().close_others().map_err(mlua::Error::runtime) })?, )?; } diff --git a/src/lua_bindings/window_panel.rs b/src/lua_bindings/window_panel.rs index a28add0..d0ee6c0 100644 --- a/src/lua_bindings/window_panel.rs +++ b/src/lua_bindings/window_panel.rs @@ -120,10 +120,11 @@ fn complete_display( let target_ok = visible(core, fid, outcome.target); let saved_ok = visible(core, fid, outcome.saved_active); let final_focus = match (outcome.select, target_ok, saved_ok) { - (true, true, _) => Some(outcome.target), - (true, false, true) => Some(outcome.saved_active), - (false, _, true) => Some(outcome.saved_active), - (false, true, false) => Some(outcome.target), + // `select = true` KEEPS the target selected. + (true, true, _) | (false, true, false) => Some(outcome.target), + // `select = false` restores the saved window even when it is the + // panel — a passive display from a focused panel must not blur it. + (true, false, true) | (false, _, true) => Some(outcome.saved_active), // Both ids died with the hook: fall back to the non-side target // rule rather than leaving focus on a dead window. _ => None, @@ -202,8 +203,135 @@ fn lookup_window(core: &SharedCore, fid: FrontendId, raw: u64) -> mlua::Result, + window: Option, +) -> mlua::Result { + let display = match display { + None | Some("current") => AdopterPlacement::Current, + Some("panel") => AdopterPlacement::Panel, + Some(other) => { + return Err(mlua::Error::runtime(format!( + "{operation}: unknown display {other:?} (expected \"current\" or \"panel\")" + ))); + } + }; + match (window, &display) { + (Some(_), AdopterPlacement::Panel) => Err(mlua::Error::runtime(format!( + "{operation}: `window` and `display = \"panel\"` are mutually exclusive" + ))), + (Some(raw), _) => Ok(AdopterPlacement::Window(lookup_window(core, fid, raw)?)), + (None, _) => Ok(display), + } +} + +/// Install `buffer_id` per `placement`, returning Phase 1's outcome +/// (Q#BP11b). +/// +/// `Current` keeps the pre-arc raw switch: it is the deliberate escape +/// hatch every existing adopter caller already relies on, and it does not +/// consult display-policy dedication. +/// +/// # Errors +/// Any placement failure. The caller owns its own session/buffer +/// rollback, and inspects `created_side` to remove a wrapper this +/// transaction created. +pub(crate) fn place_adopter_buffer( + lua: &Lua, + core: &SharedCore, + fid: FrontendId, + buffer_id: crate::buffer::BufferId, + placement: &AdopterPlacement, + select: bool, +) -> mlua::Result { + if matches!(placement, AdopterPlacement::Current) { + let mut borrowed = core.borrow_mut(); + borrowed + .switch_active_buffer_for(fid, buffer_id) + .map_err(mlua::Error::runtime)?; + let target = borrowed + .views + .get(&fid) + .map(|view| view.active) + .ok_or_else(|| { + mlua::Error::runtime("adopter placement: acting frontend has no active window") + })?; + return Ok(DisplayOutcome { + target, + saved_active: target, + select: true, + created_side: false, + }); + } + let mut request = DisplayRequest::new(buffer_id); + match placement { + AdopterPlacement::Panel => request.side = Some(Side::Bottom), + AdopterPlacement::Window(window) => request.window = Some(*window), + AdopterPlacement::Current => unreachable!("handled above"), + } + request.select = Some(select); + request.default_panel_rows = config_u32( + lua, + "window.panel-height", + Some(buffer_id), + DEFAULT_PANEL_ROWS, + ) + .max(MIN_WINDOW_OUTER_ROWS); + core.borrow_mut() + .display_buffer(fid, &request) + .map_err(mlua::Error::runtime) +} + +/// Phase 2 for an adopter that had to interleave its own work (claiming a +/// terminal controller, seating a cursor) between placement and the hook. +/// +/// # Errors +/// Propagates the final-focus resolution error when both window ids died +/// inside the hook. +pub(crate) fn finish_adopter_placement( + lua: &Lua, + core: &SharedCore, + fid: FrontendId, + outcome: DisplayOutcome, +) -> mlua::Result<()> { + complete_display(lua, core, fid, outcome, HookKind::AfterSwitch) +} + /// Install the bottom-panel surface onto the existing `pmacs.window` /// table. +#[allow( + clippy::too_many_lines, + reason = "one flat list of bindings, each following the same \ + acting-frontend / Rc-borrow shape; splitting them fragments \ + a coherent surface" +)] pub(crate) fn install(lua: &Lua, core: &SharedCore, win: &Table) -> mlua::Result<()> { { let cc = core.clone(); @@ -386,10 +514,7 @@ pub(crate) fn install(lua: &Lua, core: &SharedCore, win: &Table) -> mlua::Result .quit_action() .map_or(0, crate::window::QuitAction::depth), )?; - table.set( - "hidden", - window.is_side() && core.panel_hidden_for(fid), - )?; + table.set("hidden", window.is_side() && core.panel_hidden_for(fid))?; Ok(table) })?, )?; @@ -400,41 +525,43 @@ pub(crate) fn install(lua: &Lua, core: &SharedCore, win: &Table) -> mlua::Result let cc = core.clone(); win.set( "set_params", - lua.create_function(move |lua, (target, opts): (u64, Table)| -> mlua::Result<()> { - let fid = acting_frontend(lua, &cc); - let id = lookup_window(&cc, fid, target)?; - for key in ["side", "origin_document", "quit_action"] { - if opts.get::(key)? != Value::Nil { - return Err(mlua::Error::runtime(format!( - "pmacs.window.set_params: `{key}` is not settable" - ))); + lua.create_function( + move |lua, (target, opts): (u64, Table)| -> mlua::Result<()> { + let fid = acting_frontend(lua, &cc); + let id = lookup_window(&cc, fid, target)?; + for key in ["side", "origin_document", "quit_action"] { + if opts.get::(key)? != Value::Nil { + return Err(mlua::Error::runtime(format!( + "pmacs.window.set_params: `{key}` is not settable" + ))); + } } - } - let height = match opts.get::>("fixed_rows")? { - Some(rows) => Some( - crate::editor_core::EditorCore::clamp_panel_rows(rows) - .map_err(mlua::Error::runtime)?, - ), - None => None, - }; - let dedicated = opts.get::>("dedicated")?; - { - let mut core = cc.borrow_mut(); - let window = core.windows.get_mut(&id).ok_or_else(|| { - mlua::Error::runtime("pmacs.window.set_params: window not live") - })?; - if let Some(rows) = height { - // Inert on an ordinary window by construction: - // the fixed map is built from side windows only. - window.params.fixed_rows = Some(rows); + let height = match opts.get::>("fixed_rows")? { + Some(rows) => Some( + crate::editor_core::EditorCore::clamp_panel_rows(rows) + .map_err(mlua::Error::runtime)?, + ), + None => None, + }; + let dedicated = opts.get::>("dedicated")?; + { + let mut core = cc.borrow_mut(); + let window = core.windows.get_mut(&id).ok_or_else(|| { + mlua::Error::runtime("pmacs.window.set_params: window not live") + })?; + if let Some(rows) = height { + // Inert on an ordinary window by construction: + // the fixed map is built from side windows only. + window.params.fixed_rows = Some(rows); + } + if let Some(dedicated) = dedicated { + window.params.dedicated = dedicated; + } } - if let Some(dedicated) = dedicated { - window.params.dedicated = dedicated; - } - } - reconcile_panel_layout(lua, &cc, fid); - Ok(()) - })?, + reconcile_panel_layout(lua, &cc, fid); + Ok(()) + }, + )?, )?; } @@ -466,8 +593,7 @@ pub(crate) fn install(lua: &Lua, core: &SharedCore, win: &Table) -> mlua::Result .iter_ids() .into_iter() .map(|id| { - let buffer_id = - core.windows.get(&id).map(|w| w.buffer_id); + let buffer_id = core.windows.get(&id).map(|w| w.buffer_id); ( id, config_u32( diff --git a/src/window.rs b/src/window.rs index 2d8afff..f38cf53 100644 --- a/src/window.rs +++ b/src/window.rs @@ -975,20 +975,21 @@ fn compute_node( let mut flexible_used: u32 = 0; let mut cursor: u32 = 0; for (i, child) in children.iter().enumerate() { - let extent = match extents[i] { - Some(rows) => rows, - None => { - let w = weights.get(i).copied().unwrap_or(1).max(1); - let e = if Some(i) == last_flexible { - remainder - flexible_used - } else if total == 0 { - 0 - } else { - remainder * w / total - }; - flexible_used += e; - e - } + let extent = if let Some(rows) = extents[i] { + rows + } else { + let w = weights.get(i).copied().unwrap_or(1).max(1); + let e = if Some(i) == last_flexible { + remainder - flexible_used + } else { + remainder + .checked_mul(w) + .unwrap_or(remainder) + .checked_div(total) + .unwrap_or(0) + }; + flexible_used += e; + e }; let child_area = match orientation { Orientation::Horizontal => Rect { diff --git a/tests/bottom_panel_stage1_acceptance.rs b/tests/bottom_panel_stage1_acceptance.rs new file mode 100644 index 0000000..b89ca15 --- /dev/null +++ b/tests/bottom_panel_stage1_acceptance.rs @@ -0,0 +1,2224 @@ +// bottom_panel_stage1_acceptance.rs --- bottom-panel Stage 1 acceptance +// (docs/bottom-panel-framing.md, acceptance items 1-35). + +//! Window placement + TUI side windows. No wire change. +//! +//! Every claim about geometry is asserted through a **production** +//! caller: `window_placements` (via the real `paint_frame`) or the +//! peer-presence overlay pass, never against `Layout::compute` in +//! isolation — the whole point of R5-B1 is that a second caller derives +//! its own rect and would otherwise keep computing unfixed geometry. +//! Placement, quit, and visit claims run through the real Lua surface +//! and the real adopter entry points. + +use std::collections::HashMap; +use std::time::Duration; + +use crossterm::event::{ + KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers, MouseButton, MouseEvent, + MouseEventKind, +}; +use pmacs::buffer::BufferId; +use pmacs::cell::{CellCoord, CellGrid, CellSize, Glyph}; +use pmacs::editor::EditorState; +use pmacs::editor_core::{DisplayRequest, EditorCore}; +use pmacs::protocol::FrontendId; +use pmacs::window::{ + FrontendView, Layout, LayoutNode, MAX_PANEL_QUIT_DEPTH, MIN_WINDOW_OUTER_ROWS, Orientation, + QuitAction, Rect, Side, Window, WindowId, subtree_min_rows, +}; + +// --------------------------------------------------------------------------- +// Harness +// --------------------------------------------------------------------------- + +/// Terminal geometry. `paint_frame` reserves the last row for the status +/// line, so the window area is `ROWS - 1`. +const ROWS: u32 = 24; +const COLS: u32 = 60; +const AREA_ROWS: u32 = ROWS - 1; + +fn editor() -> EditorState { + let s = EditorState::new(); + exec(&s, "pmacs.lsp.config = {}"); + // Geometry is authoritative state, and a grid frontend's real frame + // size IS its declaration. Every test that does not render declares + // it here, before any input. + s.sync_frame_geometry(FrontendId::LOCAL, CellSize::new(ROWS, COLS)); + s +} + +fn exec(s: &EditorState, src: &str) { + s.lua_host.lua().load(src.to_string()).exec().unwrap(); +} + +fn try_exec(s: &EditorState, src: &str) -> Result<(), String> { + s.lua_host + .lua() + .load(src.to_string()) + .exec() + .map_err(|e| e.to_string()) +} + +fn eval(s: &EditorState, src: &str) -> T { + s.lua_host.lua().load(src.to_string()).eval().unwrap() +} + +/// Render one real frame and return the per-window outer rects keyed by +/// window id, as `window_placements` computed them. +fn render(s: &EditorState) -> HashMap { + render_at(s, CellSize::new(ROWS, COLS)) +} + +fn render_at(s: &EditorState, size: CellSize) -> HashMap { + let mut cells = vec![pmacs::cell::Cell::default(); (size.rows * size.cols) as usize]; + let mut grid = CellGrid { + cells: &mut cells, + stride: size.cols, + size, + }; + pmacs::editor::paint_frame(s, FrontendId::LOCAL, &HashMap::new(), &mut grid, size); + placements(s, size) +} + +/// The production placement pass, at `size`. +fn placements(s: &EditorState, size: CellSize) -> HashMap { + let core = s.core.borrow(); + let view = core.views.get(&FrontendId::LOCAL).expect("LOCAL view"); + let area = Rect::new(0, 0, size.rows - 1, size.cols); + let fixed = core.panel_fixed_rows(FrontendId::LOCAL, area.size.rows); + view.layout.compute(area, &fixed) +} + +/// Paint one frame and hand back the grid text, row by row. +fn painted_rows(s: &EditorState, size: CellSize) -> Vec { + let mut cells = vec![pmacs::cell::Cell::default(); (size.rows * size.cols) as usize]; + let mut grid = CellGrid { + cells: &mut cells, + stride: size.cols, + size, + }; + pmacs::editor::paint_frame(s, FrontendId::LOCAL, &HashMap::new(), &mut grid, size); + (0..size.rows) + .map(|row| { + (0..size.cols) + .map(|col| match &cells[(row * size.cols + col) as usize].glyph { + Glyph::Char(ch) => *ch, + Glyph::Cluster(_) => '?', + Glyph::Continuation => ' ', + }) + .collect() + }) + .collect() +} + +fn side_window(s: &EditorState) -> Option { + s.core.borrow().side_window_for(FrontendId::LOCAL) +} + +fn active_window(s: &EditorState) -> WindowId { + s.core.borrow().active_window_id() +} + +fn fixed_rows_of(s: &EditorState, win: WindowId) -> Option { + s.core.borrow().windows.get(&win)?.params.fixed_rows +} + +fn layout_root(s: &EditorState) -> LayoutNode { + s.core + .borrow() + .views + .get(&FrontendId::LOCAL) + .expect("LOCAL view") + .layout + .root + .clone() +} + +/// Structural fingerprint: node shape, weights, order, and ids — what +/// Bet B6 promises stays byte-identical when a panel opens. +fn structure(node: &LayoutNode) -> String { + match node { + LayoutNode::Leaf(id) => format!("L{}", id.raw()), + LayoutNode::Split { + orientation, + weights, + children, + } => format!( + "S{}{weights:?}({})", + match orientation { + Orientation::Horizontal => "H", + Orientation::Vertical => "V", + }, + children.iter().map(structure).collect::>().join(",") + ), + } +} + +/// Create a panel showing a fresh generated buffer, through the real Lua +/// display surface. +fn open_panel(s: &EditorState, name: &str, height: u32) -> WindowId { + exec( + s, + &format!( + "PANEL_BUF = pmacs.buffer.create({name:?}) + PANEL_WIN = pmacs.window.display(PANEL_BUF, \ + {{ side = \"bottom\", height = {height} }})" + ), + ); + side_window(s).expect("panel exists") +} + +fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent { + KeyEvent { + code, + modifiers: mods, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + } +} + +fn mouse(kind: MouseEventKind, row: u16, column: u16) -> MouseEvent { + MouseEvent { + kind, + column, + row, + modifiers: KeyModifiers::NONE, + } +} + +/// Register a second frontend with its own single-window layout. +fn attach_frontend(s: &EditorState, fid: FrontendId, panel_capable: bool) -> WindowId { + let mut core = s.core.borrow_mut(); + let buffer_id = core.active_buffer_id(); + let text_view = { + let reg = core.registry.borrow(); + pmacs::text_view::TextView::new(reg.get(buffer_id).expect("buffer")) + }; + let win = WindowId::next(); + core.windows + .insert(win, Window::new(win, buffer_id, text_view)); + core.register_frontend_view( + fid, + FrontendView { + layout: Layout::single(win), + active: win, + fold_projection: true, + panel_capable, + frame_geometry: None, + panel_hidden: false, + }, + ); + drop(core); + if panel_capable { + s.sync_frame_geometry(fid, CellSize::new(ROWS, COLS)); + } + win +} + +// --------------------------------------------------------------------------- +// 1 — fixed extents reach BOTH production callers +// --------------------------------------------------------------------------- + +#[test] +fn acc1_fixed_extent_reaches_both_production_callers() { + let s = editor(); + let document = active_window(&s); + let before = render(&s); + assert_eq!( + before[&document].size.rows, AREA_ROWS, + "one window takes the whole area" + ); + + let panel = open_panel(&s, "*panel*", 6); + let after = render(&s); + assert_eq!( + after[&panel].size.rows, 6, + "the side child gets exactly N rows" + ); + assert_eq!( + after[&document].size.rows, + AREA_ROWS - 6, + "the sibling divides the remainder" + ); + + // The second production caller (`overlay_paint`) derives its OWN + // text-area rect and never routes through `window_placements`. Paint + // a peer cursor into the document window and assert it lands on the + // row the fixed geometry says — the assertion that fails if that + // caller keeps computing unfixed geometry. + let document_buffer = s.core.borrow().windows[&document].buffer_id; + let row_with_panel = peer_cursor_row(&s, document_buffer, 0); + s.core + .borrow_mut() + .remove_side_window(FrontendId::LOCAL, panel); + let row_without_panel = peer_cursor_row(&s, document_buffer, 0); + assert_eq!( + row_with_panel, row_without_panel, + "a peer cursor in the document window paints at the same row \ + whether or not a panel is open" + ); +} + +/// Paint the peer-presence overlay pass and report the grid row the peer +/// cursor landed on. +fn peer_cursor_row(s: &EditorState, buffer_id: BufferId, position: u64) -> u32 { + let size = CellSize::new(ROWS, COLS); + let mut cells = vec![pmacs::cell::Cell::default(); (size.rows * size.cols) as usize]; + let mut grid = CellGrid { + cells: &mut cells, + stride: size.cols, + size, + }; + let presence = pmacs::overlay_paint::OtherPresence { + frontend_id: FrontendId(7), + color_slot: 0, + snapshot: pmacs::presence::PresenceSnapshot { + buffer_id, + cursor: position, + selection: None, + }, + }; + pmacs::overlay_paint::paint_other_frontend_overlays(s, &mut grid, size, &[presence]); + for row in 0..size.rows { + for col in 0..size.cols { + if cells[(row * size.cols + col) as usize].style.reverse { + return row; + } + } + } + panic!("peer cursor was not painted anywhere"); +} + +// --------------------------------------------------------------------------- +// 2 — opening a panel preserves the document subtree's STRUCTURE (B6) +// --------------------------------------------------------------------------- + +#[test] +fn acc2_opening_a_panel_preserves_document_structure() { + let s = editor(); + exec( + &s, + "pmacs.window.split_horizontal(); pmacs.window.split_vertical()", + ); + let before = layout_root(&s); + let before_rects = render(&s); + + open_panel(&s, "*panel*", 5); + let after = layout_root(&s); + let LayoutNode::Split { children, .. } = &after else { + panic!("the panel wrapper is a split"); + }; + assert_eq!( + structure(&before), + structure(&children[0]), + "nodes, weights, order and ids of the document subtree are identical" + ); + let after_rects = render(&s); + assert!( + before_rects + .keys() + .any(|id| before_rects[id] != after_rects[id]), + "…while the rectangles necessarily change, being recomputed \ + inside the smaller flexible remainder" + ); +} + +// --------------------------------------------------------------------------- +// 3 — the minimum is RECURSIVE +// --------------------------------------------------------------------------- + +#[test] +fn acc3_subtree_minimum_is_recursive_and_clamps_the_panel() { + // Horizontal inside vertical inside horizontal: four leaves, of + // which three stack rows. + let leaf_a = WindowId::next(); + let leaf_b = WindowId::next(); + let leaf_c = WindowId::next(); + let leaf_d = WindowId::next(); + let nested = LayoutNode::Split { + orientation: Orientation::Horizontal, + weights: vec![1, 1], + children: vec![ + LayoutNode::Leaf(leaf_a), + LayoutNode::Split { + orientation: Orientation::Vertical, + weights: vec![1, 1], + children: vec![ + LayoutNode::Leaf(leaf_b), + LayoutNode::Split { + orientation: Orientation::Horizontal, + weights: vec![1, 1], + children: vec![LayoutNode::Leaf(leaf_c), LayoutNode::Leaf(leaf_d)], + }, + ], + }, + ], + }; + // Rows add across a horizontal split and the tallest child governs a + // vertical one: 2 + max(2, 2 + 2) = 6. A flat "two rows at the root" + // reading would answer 2. + assert_eq!(subtree_min_rows(&nested), 6); + + // In a live layout the PANEL is clamped, never the document. + let s = editor(); + exec( + &s, + "pmacs.window.split_horizontal(); pmacs.window.split_vertical(); \ + pmacs.window.split_horizontal()", + ); + let document_min = { + let core = s.core.borrow(); + subtree_min_rows(&core.views[&FrontendId::LOCAL].layout.root) + }; + let panel = open_panel(&s, "*panel*", AREA_ROWS); + let rects = render(&s); + assert_eq!( + rects[&panel].size.rows, + AREA_ROWS - document_min, + "the panel takes min(requested, area - subtree_min_rows(document))" + ); +} + +// --------------------------------------------------------------------------- +// 4 — clamping, rejection, and saturating arithmetic +// --------------------------------------------------------------------------- + +#[test] +fn acc4_height_requests_clamp_to_the_floor_and_reject_zero() { + let s = editor(); + let panel = open_panel(&s, "*panel*", 1); + assert_eq!( + fixed_rows_of(&s, panel), + Some(MIN_WINDOW_OUTER_ROWS), + "a one-row request clamps up to the structural floor" + ); + assert_eq!(render(&s)[&panel].size.rows, MIN_WINDOW_OUTER_ROWS); + + let zero = try_exec( + &s, + "pmacs.window.display(pmacs.buffer.create(\"*z*\"), \ + { side = \"bottom\", height = 0 })", + ); + assert!( + zero.is_err(), + "a request of zero is rejected, not an invisible open" + ); + assert!( + try_exec( + &s, + &format!( + "pmacs.window.set_params({}, {{ fixed_rows = 0 }})", + panel.raw() + ) + ) + .is_err(), + "set_params rejects zero too" + ); + exec( + &s, + &format!( + "pmacs.window.set_params({}, {{ fixed_rows = 1 }})", + panel.raw() + ), + ); + assert_eq!(fixed_rows_of(&s, panel), Some(MIN_WINDOW_OUTER_ROWS)); + + // `window.panel-height` is the creation default, clamped the same way. + exec(&s, "pmacs.config.set(\"window.panel-height\", 2)"); + s.core + .borrow_mut() + .remove_side_window(FrontendId::LOCAL, panel); + exec( + &s, + "pmacs.window.display(pmacs.buffer.create(\"*p2*\"), { side = \"bottom\" })", + ); + let panel = side_window(&s).expect("panel"); + assert_eq!(fixed_rows_of(&s, panel), Some(2)); + + // An intrinsically tiny frame saturates and hides rather than + // underflowing; a zero-column frame is never presentable. + s.sync_frame_geometry(FrontendId::LOCAL, CellSize::new(3, COLS)); + assert!(s.core.borrow().panel_hidden_for(FrontendId::LOCAL)); + s.sync_frame_geometry(FrontendId::LOCAL, CellSize::new(ROWS, 0)); + assert!(s.core.borrow().panel_hidden_for(FrontendId::LOCAL)); +} + +// --------------------------------------------------------------------------- +// 5 — absolute height vs proportional ratio, in ONE layout +// --------------------------------------------------------------------------- + +#[test] +fn acc5_resize_preserves_absolute_panel_height_and_flexible_ratio() { + let s = editor(); + exec(&s, "pmacs.window.split_horizontal()"); + let panel = open_panel(&s, "*panel*", 6); + let ids: Vec = { + let core = s.core.borrow(); + core.views[&FrontendId::LOCAL] + .layout + .iter_ids() + .into_iter() + .filter(|id| *id != panel) + .collect() + }; + let wide = render_at(&s, CellSize::new(ROWS, COLS)); + assert_eq!(wide[&panel].size.rows, 6); + let ratio_before = f64::from(wide[&ids[0]].size.rows) / f64::from(wide[&ids[1]].size.rows); + + s.sync_frame_geometry(FrontendId::LOCAL, CellSize::new(ROWS + 10, COLS)); + let tall = render_at(&s, CellSize::new(ROWS + 10, COLS)); + assert_eq!( + tall[&panel].size.rows, 6, + "the side window keeps its ABSOLUTE height" + ); + let ratio_after = f64::from(tall[&ids[0]].size.rows) / f64::from(tall[&ids[1]].size.rows); + assert!( + (ratio_before - ratio_after).abs() < 0.35, + "the flexible pair keeps its RATIO ({ratio_before} vs {ratio_after})" + ); +} + +// --------------------------------------------------------------------------- +// 6 / 7 / 8 — hiding is a durable transition +// --------------------------------------------------------------------------- + +#[test] +fn acc6_reconciliation_hides_moves_focus_and_releases_before_the_next_key() { + let s = editor(); + let document = active_window(&s); + let panel = open_panel(&s, "*panel*", 8); + exec(&s, "pmacs.window.focus_next()"); + assert_eq!(active_window(&s), panel, "the panel is focused"); + + // Shrink the frame to something that cannot satisfy the panel, then + // dispatch a key in the same burst. + s.sync_frame_geometry(FrontendId::LOCAL, CellSize::new(4, COLS)); + assert!(s.core.borrow().panel_hidden_for(FrontendId::LOCAL)); + assert_eq!( + active_window(&s), + document, + "focus moved out of the invisible panel" + ); + let rects = placements(&s, CellSize::new(4, COLS)); + assert_eq!( + rects[&panel].size.rows, 0, + "a hidden panel has an empty rect" + ); + assert_eq!( + rects[&document].size.rows, 3, + "the document subtree receives every reclaimed row" + ); + assert_eq!( + fixed_rows_of(&s, panel), + Some(8), + "the stored request survives hiding" + ); +} + +#[test] +fn acc7_reappearing_restores_the_request_but_not_focus() { + let s = editor(); + let document = active_window(&s); + let panel = open_panel(&s, "*panel*", 8); + exec(&s, "pmacs.window.focus_next()"); + assert_eq!(active_window(&s), panel); + + let before = layout_root(&s); + s.sync_frame_geometry(FrontendId::LOCAL, CellSize::new(4, COLS)); + assert_eq!( + structure(&before), + structure(&layout_root(&s)), + "wrapper, ids, weights and order survive hiding" + ); + // While hidden the panel is not a focus destination. + exec(&s, "pmacs.window.focus_next()"); + assert_eq!( + active_window(&s), + document, + "focus_next skips a hidden panel" + ); + + s.sync_frame_geometry(FrontendId::LOCAL, CellSize::new(ROWS, COLS)); + assert!(!s.core.borrow().panel_hidden_for(FrontendId::LOCAL)); + assert_eq!( + render(&s)[&panel].size.rows, + 8, + "restored at the exact request" + ); + assert_eq!( + active_window(&s), + document, + "focus is NOT auto-restored — the user moved on" + ); + exec(&s, "pmacs.window.focus_next()"); + assert_eq!(active_window(&s), panel, "…but C-x o reaches it again"); +} + +#[test] +fn acc8_keys_while_hidden_reach_the_document_window() { + let mut s = editor(); + let document = active_window(&s); + open_panel(&s, "*panel*", 8); + exec(&s, "pmacs.window.focus_next()"); + s.sync_frame_geometry(FrontendId::LOCAL, CellSize::new(4, COLS)); + + s.dispatch_key( + FrontendId::LOCAL, + key(KeyCode::Char('x'), KeyModifiers::NONE), + ); + let document_buffer = s.core.borrow().windows[&document].buffer_id; + let text: String = { + let core = s.core.borrow(); + let reg = core.registry.borrow(); + let buf = reg.get(document_buffer).unwrap(); + let mut bytes = vec![0u8; buf.len() as usize]; + buf.snapshot_rope().slice(0, buf.len(), &mut bytes); + String::from_utf8_lossy(&bytes).into_owned() + }; + assert!( + text.contains('x'), + "the keystroke landed in the document buffer, not the invisible panel" + ); +} + +// --------------------------------------------------------------------------- +// 9 — window.min-height is an INTERACTIVE preference only +// --------------------------------------------------------------------------- + +#[test] +fn acc9_min_height_constrains_interactive_resize_only() { + let s = editor(); + exec(&s, "pmacs.config.set(\"window.min-height\", 1)"); + let panel = open_panel(&s, "*panel*", 6); + // Below the structural floor: the resolver clamps it back up. + assert_eq!(s.window_min_height(None), MIN_WINDOW_OUTER_ROWS); + + // A value materially above the floor constrains resize recursively + // across a nested document tree. + exec( + &s, + "pmacs.config.set(\"window.min-height\", 5) + pmacs.window.split_horizontal()", + ); + let document = s + .core + .borrow() + .non_side_target(FrontendId::LOCAL) + .expect("document target"); + // Two document leaves at 5 rows each = 10; the frame area is 23, so + // the panel can never grow past 13. + let _ = s.resize_window_boundary(FrontendId::LOCAL, panel, 100, AREA_ROWS); + assert!( + fixed_rows_of(&s, panel).expect("panel rows") <= AREA_ROWS - 10, + "the recursive interactive minimum bounds the panel" + ); + // Frame-resize layout ignores the preference entirely: an area that + // only satisfies the STRUCTURAL floor still lays out. + s.sync_frame_geometry(FrontendId::LOCAL, CellSize::new(8, COLS)); + let rects = placements(&s, CellSize::new(8, COLS)); + assert!( + rects[&document].size.rows > 0, + "changing a preference never invalidates an existing layout" + ); +} + +// --------------------------------------------------------------------------- +// 10 — closing collapses the wrapper +// --------------------------------------------------------------------------- + +#[test] +fn acc10_closing_the_panel_restores_the_prior_root_exactly() { + let s = editor(); + exec( + &s, + "pmacs.window.split_horizontal(); pmacs.window.split_vertical()", + ); + let before = structure(&layout_root(&s)); + let panel = open_panel(&s, "*panel*", 5); + s.core + .borrow_mut() + .remove_side_window(FrontendId::LOCAL, panel); + assert_eq!( + before, + structure(&layout_root(&s)), + "the wrapper collapses and the prior root returns unchanged" + ); +} + +// --------------------------------------------------------------------------- +// 11 — parameter write discipline +// --------------------------------------------------------------------------- + +#[test] +fn acc11_parameter_writes_are_restricted_and_ids_are_frontend_scoped() { + let s = editor(); + let document = active_window(&s); + let panel = open_panel(&s, "*panel*", 5); + for forbidden in [ + "side = \"bottom\"", + "origin_document = 1", + "quit_action = \"delete\"", + ] { + assert!( + try_exec( + &s, + &format!( + "pmacs.window.set_params({}, {{ {forbidden} }})", + panel.raw() + ) + ) + .is_err(), + "set_params must reject `{forbidden}`" + ); + } + // `params` may REPORT the implementation-owned bookkeeping. + exec(&s, "pmacs.window.focus_next()"); + let origin: Option = eval( + &s, + &format!( + "return pmacs.window.params({}).origin_document", + panel.raw() + ), + ); + assert_eq!(origin, Some(document.raw())); + + // A stray `fixed_rows` on a non-side window is inert. + exec( + &s, + &format!( + "pmacs.window.set_params({}, {{ fixed_rows = 4 }})", + document.raw() + ), + ); + let rects = render(&s); + assert_eq!( + rects[&document].size.rows, + AREA_ROWS - 5, + "the fixed map is built from side windows only" + ); + + // Every WindowId-taking operation rejects a live id owned by another + // frontend. + let foreign = attach_frontend(&s, FrontendId(9), true); + for call in [ + format!("pmacs.window.params({})", foreign.raw()), + format!( + "pmacs.window.set_params({}, {{ dedicated = true }})", + foreign.raw() + ), + format!("pmacs.window.resize({}, 1)", foreign.raw()), + format!("pmacs.window.quit({})", foreign.raw()), + format!( + "pmacs.window.display(pmacs.buffer.create(\"*f*\"), {{ window = {} }})", + foreign.raw() + ), + ] { + assert!( + try_exec(&s, &call).is_err(), + "a cross-frontend id must be a pointed error: {call}" + ); + } +} + +// --------------------------------------------------------------------------- +// 12 — dedication binds the POLICY layer only +// --------------------------------------------------------------------------- + +#[test] +fn acc12_dedication_binds_display_policy_not_the_raw_switch() { + let s = editor(); + let document = active_window(&s); + exec( + &s, + &format!( + "OTHER = pmacs.buffer.create(\"*other*\") + pmacs.window.set_params({}, {{ dedicated = true }})", + document.raw() + ), + ); + let pinned_buffer = s.core.borrow().windows[&document].buffer_id; + + // The raw escape hatch ignores dedication. + exec(&s, "pmacs.window.switch_buffer(OTHER)"); + assert_ne!( + s.core.borrow().windows[&document].buffer_id, + pinned_buffer, + "raw switch_buffer ignores `dedicated`" + ); + + // The policy layer honors it on every candidate. + exec( + &s, + &format!( + "pmacs.window.switch_buffer(pmacs.buffer.list()[1]) + pmacs.window.set_params({}, {{ dedicated = true }})", + document.raw() + ), + ); + assert!( + try_exec(&s, "pmacs.window.display(OTHER)").is_err(), + "display_buffer refuses to overwrite a dedicated window with no alternative" + ); + assert!( + try_exec( + &s, + &format!( + "pmacs.window.display(OTHER, {{ window = {} }})", + document.raw() + ) + ) + .is_err(), + "…and refuses a dedicated EXACT target too" + ); + + // An ordinary display never reuses a matching side window. + exec( + &s, + &format!( + "pmacs.window.set_params({}, {{ dedicated = false }})", + document.raw() + ), + ); + let panel = open_panel(&s, "*shared*", 5); + let panel_buffer = s.core.borrow().windows[&panel].buffer_id; + let target: u64 = eval(&s, "return pmacs.window.display(PANEL_BUF)"); + assert_ne!( + target, + panel.raw(), + "an ordinary display never selects the panel by coincidence" + ); + assert_eq!( + s.core.borrow().windows[&panel].buffer_id, + panel_buffer, + "…and leaves the panel's own presentation alone" + ); +} + +// --------------------------------------------------------------------------- +// 13 — side placement affinity + option-valued height/dedication +// --------------------------------------------------------------------------- + +#[test] +fn acc13_side_placement_is_affinity_aware_and_option_valued() { + let s = editor(); + let document = active_window(&s); + // A buffer already visible in a DOCUMENT window must not preempt a + // requested usable side slot. + exec( + &s, + "SHARED = pmacs.buffer.create(\"*shared*\"); pmacs.window.switch_buffer(SHARED)", + ); + let target: u64 = eval( + &s, + "return pmacs.window.display(SHARED, { side = \"bottom\", height = 7 })", + ); + let panel = side_window(&s).expect("panel created"); + assert_eq!(target, panel.raw(), "the requested side placement wins"); + assert_eq!( + s.core.borrow().windows[&document].buffer_id, + s.core.borrow().windows[&panel].buffer_id + ); + + // Same-buffer redisplay preserves an omitted height, dedication, and + // quit action. + exec( + &s, + &format!( + "pmacs.window.set_params({}, {{ dedicated = true }})", + panel.raw() + ), + ); + exec(&s, "pmacs.window.display(SHARED, { side = \"bottom\" })"); + assert_eq!(fixed_rows_of(&s, panel), Some(7)); + assert!(s.core.borrow().windows[&panel].params.dedicated); + + // A dedicated side slot never spawns a second one: the request falls + // back after discarding height/dedication/quit state. + exec(&s, "OTHER = pmacs.buffer.create(\"*other*\")"); + let fallback: u64 = eval( + &s, + "return pmacs.window.display(OTHER, { side = \"bottom\", height = 9, dedicated = true })", + ); + assert_ne!(fallback, panel.raw()); + assert_eq!(side_window(&s), Some(panel), "still exactly one side slot"); + { + let core = s.core.borrow(); + let fell_back = core + .windows + .values() + .find(|w| w.id.raw() == fallback) + .expect("fallback window"); + assert!( + !fell_back.params.dedicated, + "a failed request may not dedicate" + ); + assert!(fell_back.params.fixed_rows.is_none(), "…nor pin"); + assert!( + fell_back.params.quit_action().is_none(), + "…nor leave quit state" + ); + } + + // Replacement preserves an omitted (user-resized) height but starts + // undedicated. + exec( + &s, + &format!( + "pmacs.window.set_params({}, {{ dedicated = false }})", + panel.raw() + ), + ); + exec(&s, "pmacs.window.display(OTHER, { side = \"bottom\" })"); + assert_eq!( + fixed_rows_of(&s, panel), + Some(7), + "the resized height survives" + ); + assert!(!s.core.borrow().windows[&panel].params.dedicated); + + // Mutual exclusion and a freestanding height are pointed errors. + assert!( + try_exec( + &s, + &format!( + "pmacs.window.display(OTHER, {{ side = \"bottom\", window = {} }})", + document.raw() + ) + ) + .is_err() + ); + assert!(try_exec(&s, "pmacs.window.display(OTHER, { height = 4 })").is_err()); + assert!( + try_exec(&s, "pmacs.window.display(OTHER, { side = \"left\" })").is_err(), + "Stage 1 ships only the bottom side" + ); + + // An explicit `dedicated = false` cannot clear-and-bypass an existing + // dedication in the same call. + exec( + &s, + &format!( + "pmacs.window.set_params({}, {{ dedicated = true }})", + panel.raw() + ), + ); + exec(&s, "THIRD = pmacs.buffer.create(\"*third*\")"); + let bypass: u64 = eval( + &s, + "return pmacs.window.display(THIRD, { side = \"bottom\", dedicated = false })", + ); + assert_ne!( + bypass, + panel.raw(), + "eligibility is checked before the new dedication" + ); +} + +// --------------------------------------------------------------------------- +// 14 — capability fallback +// --------------------------------------------------------------------------- + +#[test] +fn acc14_capability_fallback_discards_every_side_parameter() { + let s = editor(); + let fid = FrontendId(11); + let document = attach_frontend(&s, fid, false); + let buffer = s.core.borrow_mut().registry.borrow_mut().create("*panel*"); + let mut request = DisplayRequest::new(buffer); + request.side = Some(Side::Bottom); + request.height = Some(9); + request.dedicated = Some(true); + let outcome = s + .core + .borrow_mut() + .display_buffer(fid, &request) + .expect("fallback succeeds"); + assert_eq!(outcome.target, document, "fell back to the document target"); + assert!( + s.core.borrow().side_window_for(fid).is_none(), + "no side window was created" + ); + let core = s.core.borrow(); + let window = &core.windows[&document]; + assert!( + !window.params.dedicated, + "the document target is left undedicated" + ); + assert!(window.params.fixed_rows.is_none(), "…and unpinned"); + assert!(window.params.side.is_none()); + assert!(window.params.quit_action().is_none()); +} + +// --------------------------------------------------------------------------- +// 15 / 16 — the final-focus matrix and the hook-failure arms +// --------------------------------------------------------------------------- + +#[test] +fn acc15_final_focus_matrix_all_six_rows() { + // Row 1 — select = true, target live: the target stays selected. + let s = editor(); + let document = active_window(&s); + exec( + &s, + "P = pmacs.buffer.create(\"*p*\") + pmacs.window.display(P, { side = \"bottom\", height = 5, select = true })", + ); + assert_eq!(active_window(&s), side_window(&s).unwrap()); + + // Row 4 — select = false with a live saved window that IS the panel: + // a passive display invoked from a focused panel must not blur it. + let panel = side_window(&s).unwrap(); + exec( + &s, + "Q = pmacs.buffer.create(\"*q*\") + pmacs.window.display(Q, { select = false })", + ); + assert_eq!( + active_window(&s), + panel, + "select = false restores a SIDE saved_active" + ); + assert_eq!( + s.core.borrow().windows[&document].buffer_id, + eval::(&s, "return Q").0, + "…while the buffer really did land in the document window" + ); + + // Row 5 — select = false, saved window died in the hook, target live. + let s = editor(); + exec( + &s, + "pmacs.window.split_horizontal() + SAVED = pmacs.window.list()[1] + pmacs.hook.add(\"buffer.after-switch\", function() + if KILL_SAVED then KILL_SAVED = nil; pmacs.window.focus_next(); pmacs.window.close() end + end)", + ); + exec(&s, "R = pmacs.buffer.create(\"*r*\")"); + let saved = active_window(&s); + exec(&s, "KILL_SAVED = true"); + let target: u64 = eval(&s, "return pmacs.window.display(R, { select = false })"); + assert!( + !s.core.borrow().windows.contains_key(&saved) || active_window(&s).raw() == target, + "focus falls to the live target when the saved window dies" + ); + + // Rows 2/3/6 — the target dies in the hook. + let s = editor(); + exec( + &s, + "pmacs.window.split_horizontal() + pmacs.hook.add(\"buffer.after-switch\", function() + if KILL_TARGET then KILL_TARGET = nil; pmacs.window.close() end + end) + T = pmacs.buffer.create(\"*t*\") + KILL_TARGET = true", + ); + let before = active_window(&s); + exec(&s, "pmacs.window.display(T, { select = true })"); + assert!( + s.core.borrow().views[&FrontendId::LOCAL] + .layout + .iter_ids() + .contains(&active_window(&s)), + "focus always lands on a live window" + ); + let _ = before; +} + +#[test] +fn acc16_hook_failure_arms_are_covered_in_both_select_modes() { + for select in ["true", "false"] { + // The hook switches the target's buffer out from under us. + let s = editor(); + exec( + &s, + "pmacs.hook.add(\"buffer.after-switch\", function() + if SWAP then SWAP = nil; pmacs.window.switch_buffer(pmacs.buffer.create(\"*swap*\")) end + end) + X = pmacs.buffer.create(\"*x*\") + SWAP = true", + ); + exec( + &s, + &format!("pmacs.window.display(X, {{ select = {select} }})"), + ); + assert!( + s.core.borrow().views[&FrontendId::LOCAL] + .layout + .iter_ids() + .contains(&active_window(&s)), + "select = {select}: focus stays on a live window after a buffer-switching hook" + ); + + // The hook closes the target. + let s = editor(); + exec( + &s, + "pmacs.window.split_horizontal() + pmacs.hook.add(\"buffer.after-switch\", function() + if CLOSE then CLOSE = nil; pmacs.window.close() end + end) + Y = pmacs.buffer.create(\"*y*\") + CLOSE = true", + ); + exec( + &s, + &format!("pmacs.window.display(Y, {{ select = {select} }})"), + ); + assert!( + s.core.borrow().views[&FrontendId::LOCAL] + .layout + .iter_ids() + .contains(&active_window(&s)), + "select = {select}: focus stays live after a target-closing hook" + ); + } +} + +// --------------------------------------------------------------------------- +// 17 — a passive display re-attaches overlays +// --------------------------------------------------------------------------- + +#[test] +fn acc17_passive_display_reattaches_overlays() { + let s = editor(); + exec( + &s, + "pmacs.hook.add(\"buffer.after-switch\", function() + SEEN_ACTIVE = pmacs.window.list_active and 1 or 1 + HOOK_WINDOW = pmacs.window.current() + end) + Z = pmacs.buffer.create(\"*z*\")", + ); + let target: u64 = eval( + &s, + "return pmacs.window.display(Z, { side = \"bottom\", height = 5 })", + ); + let hook_window: u64 = eval(&s, "return HOOK_WINDOW"); + assert_eq!( + hook_window, target, + "the switch hook observes the TARGET window as active, which is \ + what re-attaches store-backed overlays on a passive display" + ); + assert_ne!( + active_window(&s).raw(), + target, + "…while the passive display leaves focus where it was" + ); +} + +// --------------------------------------------------------------------------- +// 18 — display_file +// --------------------------------------------------------------------------- + +#[test] +fn acc18_display_file_targets_the_document_from_a_focused_panel() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("visit.txt"); + std::fs::write(&file, b"hello\n").unwrap(); + let path = file.display().to_string(); + + let s = editor(); + let document = active_window(&s); + let panel = open_panel(&s, "*panel*", 5); + exec(&s, "pmacs.window.focus_next()"); + assert_eq!(active_window(&s), panel); + exec( + &s, + "pmacs.hook.add(\"buffer.after-load\", function() + LOAD_WINDOW = pmacs.window.current() + end)", + ); + let target: u64 = eval( + &s, + &format!("return pmacs.window.display_file({path:?}, {{ select = true }})"), + ); + assert_eq!( + target, + document.raw(), + "the visit lands in the document target" + ); + assert_eq!( + eval::(&s, "return LOAD_WINDOW"), + document.raw(), + "buffer.after-load fires with the DOCUMENT TARGET active" + ); + assert_eq!(side_window(&s), Some(panel), "the panel is intact"); + + // A dedicated exact target fails WITHOUT loading. + let unopened = dir.path().join("unopened.txt"); + std::fs::write(&unopened, b"nope\n").unwrap(); + let unopened_path = unopened.display().to_string(); + exec( + &s, + &format!( + "pmacs.window.set_params({}, {{ dedicated = true }})", + document.raw() + ), + ); + assert!( + try_exec( + &s, + &format!( + "pmacs.window.display_file({unopened_path:?}, {{ window = {} }})", + document.raw() + ) + ) + .is_err() + ); + let opened_names: Vec = eval( + &s, + "local out = {} + for _, b in ipairs(pmacs.buffer.list()) do out[#out+1] = b:name() end + return out", + ); + assert!( + !opened_names.iter().any(|n| n.contains("unopened")), + "the file must not be loaded when the destination is ineligible" + ); + + // An omitted target skips a dedicated remembered origin and chooses + // the next eligible non-side window — before I/O. + exec(&s, "pmacs.window.split_horizontal()"); + exec(&s, &format!("pmacs.window.display_file({unopened_path:?})")); + assert!( + eval::>( + &s, + "local out = {} + for _, b in ipairs(pmacs.buffer.list()) do out[#out+1] = b:name() end + return out" + ) + .iter() + .any(|n| n.contains("unopened")), + "…and succeeds once another eligible window exists" + ); + + // A NotFound path creates a path-backed buffer and fires NO hook. + let s = editor(); + exec( + &s, + "LOADS = 0 + pmacs.hook.add(\"buffer.after-load\", function() LOADS = LOADS + 1 end) + SWITCHES = 0 + pmacs.hook.add(\"buffer.after-switch\", function() SWITCHES = SWITCHES + 1 end)", + ); + let missing = dir.path().join("brand-new.txt").display().to_string(); + exec(&s, &format!("pmacs.window.display_file({missing:?})")); + assert_eq!(eval::(&s, "return LOADS"), 0); + assert_eq!(eval::(&s, "return SWITCHES"), 0); + assert_eq!( + eval::(&s, "return pmacs.window.buffer():path()"), + missing, + "the new buffer is path-backed" + ); +} + +// --------------------------------------------------------------------------- +// 19 — adopters place through their REAL entry points +// --------------------------------------------------------------------------- + +#[test] +fn acc19_adopters_place_side_affinely_through_real_entry_points() { + // listview: pre-seed the persistent panel buffer in a DOCUMENT window + // first, so side-affine placement cannot be vacuous. + let s = editor(); + exec( + &s, + "pmacs.listview.open { name = \"*outline*\", rows = { { text = \"row\" } } }", + ); + let seeded = active_window(&s); + assert!( + side_window(&s).is_none(), + "the default placement is unchanged" + ); + exec( + &s, + "pmacs.listview.open { name = \"*outline*\", rows = { { text = \"row\" } }, \ + display = \"panel\" }", + ); + let panel = side_window(&s).expect("listview opened a panel"); + assert_eq!( + active_window(&s), + panel, + "an interactive listview takes select = true" + ); + assert_ne!(panel, seeded); + assert!( + try_exec( + &s, + "pmacs.listview.open { name = \"*bogus*\", rows = {}, display = \"sideways\" }" + ) + .is_err(), + "an unknown display value is a pointed error" + ); + + // compile: same shape, but passive (`select = false`). + let s = editor(); + exec(&s, "pmacs.compile.run(\"true\")"); + assert!(side_window(&s).is_none()); + let document = active_window(&s); + exec(&s, "pmacs.compile.run(\"true\", { display = \"panel\" })"); + let panel = side_window(&s).expect("compile opened a panel"); + assert_eq!( + active_window(&s), + document, + "compile output is passive: select = false" + ); + assert_ne!(panel, document); + let before = s.core.borrow().registry.borrow().ids().len(); + assert!( + try_exec(&s, "pmacs.compile.run(\"true\", { display = \"nope\" })").is_err(), + "an unknown display value fails BEFORE the run starts" + ); + assert_eq!( + s.core.borrow().registry.borrow().ids().len(), + before, + "…and creates no buffer" + ); + + // terminal: the panel opt-in uses select = true. + let s = editor(); + let document = active_window(&s); + let before = s.core.borrow().registry.borrow().ids().len(); + assert!( + try_exec( + &s, + "pmacs.terminal.open { command = \"/bin/sh\", display = \"elsewhere\" }" + ) + .is_err(), + "unknown display fails before session/process/buffer creation" + ); + assert_eq!(s.core.borrow().registry.borrow().ids().len(), before); + exec( + &s, + "TERM_BUF = pmacs.terminal.open { command = \"/bin/sh\", display = \"panel\" }", + ); + let panel = side_window(&s).expect("terminal opened a panel"); + assert_eq!(active_window(&s), panel); + assert_ne!(panel, document); +} + +// --------------------------------------------------------------------------- +// 20 / 23 — quit: delete, restore chains, revalidation, and the cap +// --------------------------------------------------------------------------- + +#[test] +fn acc20_quit_deletes_then_restores_each_saved_presentation() { + let s = editor(); + let document = active_window(&s); + exec( + &s, + "A = pmacs.buffer.create(\"*A*\") + B = pmacs.buffer.create(\"*B*\") + C = pmacs.buffer.create(\"*C*\") + pmacs.window.display(A, { side = \"bottom\", height = 6, select = true })", + ); + let panel = side_window(&s).expect("panel"); + exec( + &s, + &format!( + "pmacs.window.set_params({}, {{ fixed_rows = 9 }})", + panel.raw() + ), + ); + exec( + &s, + "pmacs.window.display(B, { side = \"bottom\", select = true })", + ); + exec( + &s, + "pmacs.window.display(C, { side = \"bottom\", select = true })", + ); + + // C -> B -> A -> delete. + exec(&s, "pmacs.window.quit()"); + assert_eq!( + s.core.borrow().windows[&panel].buffer_id, + eval::(&s, "return B").0 + ); + exec(&s, "pmacs.window.quit()"); + assert_eq!( + s.core.borrow().windows[&panel].buffer_id, + eval::(&s, "return A").0 + ); + assert_eq!( + fixed_rows_of(&s, panel), + Some(9), + "the saved (user-resized) height is restored with its presentation" + ); + exec(&s, "pmacs.window.quit()"); + assert!(side_window(&s).is_none(), "the last quit deletes the slot"); + assert_eq!(active_window(&s), document); + + // A window with no quit action is a pointed error that changes nothing. + let before = structure(&layout_root(&s)); + assert!(try_exec(&s, "pmacs.window.quit()").is_err()); + assert_eq!(before, structure(&layout_root(&s))); +} + +#[test] +fn acc20b_quit_history_is_bounded_at_max_panel_quit_depth() { + let s = editor(); + exec(&s, "P0 = pmacs.buffer.create(\"*p0*\")"); + exec( + &s, + "pmacs.window.display(P0, { side = \"bottom\", height = 4 })", + ); + let panel = side_window(&s).expect("panel"); + for i in 1..=(MAX_PANEL_QUIT_DEPTH + 20) { + exec( + &s, + &format!( + "pmacs.window.display(pmacs.buffer.create(\"*p{i}*\"), {{ side = \"bottom\" }})" + ), + ); + let depth: usize = eval( + &s, + &format!("return pmacs.window.params({}).quit_depth", panel.raw()), + ); + assert!( + depth <= MAX_PANEL_QUIT_DEPTH, + "depth never grows beyond the cap (saw {depth} at replacement {i})" + ); + } + let depth: usize = eval( + &s, + &format!("return pmacs.window.params({}).quit_depth", panel.raw()), + ); + assert_eq!( + depth, MAX_PANEL_QUIT_DEPTH, + "exactly the newest 64 are retained" + ); + for _ in 0..MAX_PANEL_QUIT_DEPTH { + exec(&s, &format!("pmacs.window.quit({})", panel.raw())); + } + exec(&s, &format!("pmacs.window.quit({})", panel.raw())); + assert!(side_window(&s).is_none(), "the chain terminates in Delete"); +} + +#[test] +fn acc23_quit_revalidates_a_killed_restore_target() { + let s = editor(); + exec( + &s, + "A = pmacs.buffer.create(\"*A*\") + B = pmacs.buffer.create(\"*B*\") + pmacs.window.display(A, { side = \"bottom\", height = 5 }) + pmacs.window.display(B, { side = \"bottom\" })", + ); + let panel = side_window(&s).expect("panel"); + exec(&s, "pmacs.buffer.kill(A)"); + exec(&s, &format!("pmacs.window.quit({})", panel.raw())); + assert!( + side_window(&s).is_none(), + "a killed restore target degrades the whole chain to Delete" + ); +} + +// --------------------------------------------------------------------------- +// 21 / 22 — the jump ring +// --------------------------------------------------------------------------- + +#[test] +fn acc21_panel_visit_and_jump_back_returns_to_the_panel() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("src.txt"); + std::fs::write(&file, b"one\ntwo\nthree\n").unwrap(); + let path = file.display().to_string(); + + let s = editor(); + let document = active_window(&s); + let panel = open_panel(&s, "*outline*", 6); + exec(&s, "pmacs.window.focus_next()"); + assert_eq!(active_window(&s), panel); + // Move the panel cursor so the restored row is observable. + s.core.borrow_mut().windows.get_mut(&panel).unwrap().cursor = 0; + + exec(&s, "pmacs.editor.push_jump()"); + exec( + &s, + &format!("pmacs.window.display_file({path:?}, {{ select = true }})"), + ); + assert_eq!( + active_window(&s), + document, + "RET visited the document window" + ); + + let jumped: bool = eval(&s, "return pmacs.editor.jump_back()"); + assert!(jumped); + assert_eq!( + active_window(&s), + panel, + "M-, returns focus to the EXISTING panel, not a duplicate" + ); + assert_eq!( + s.core.borrow().views[&FrontendId::LOCAL] + .layout + .iter_ids() + .len(), + 2, + "no duplicate presentation was created" + ); +} + +#[test] +fn acc22_jump_histories_are_per_frontend_and_skip_stale_side_origins() { + let s = editor(); + let fid = FrontendId(21); + let foreign = attach_frontend(&s, fid, true); + + // LOCAL pushes; the foreign frontend must not be able to pop it. + exec(&s, "pmacs.editor.push_jump()"); + s.core.borrow_mut().active_frontend = fid; + assert!( + !s.core.borrow_mut().jump_back(), + "one frontend cannot consume another's navigation trail" + ); + s.core.borrow_mut().active_frontend = FrontendId::LOCAL; + assert!( + s.core.borrow_mut().jump_back(), + "LOCAL's own entry survives" + ); + let _ = foreign; + + // A SIDE origin whose buffer was replaced is skipped, not resurrected. + let s = editor(); + let panel = open_panel(&s, "*panel*", 5); + exec(&s, "pmacs.window.focus_next()"); + exec(&s, "pmacs.editor.push_jump()"); + exec( + &s, + "pmacs.window.display(pmacs.buffer.create(\"*new*\"), { side = \"bottom\" })", + ); + let panel_buffer = s.core.borrow().windows[&panel].buffer_id; + assert!( + !s.core.borrow_mut().jump_back(), + "a replaced side origin is skipped rather than duplicated into the document" + ); + assert_eq!( + s.core.borrow().windows[&panel].buffer_id, + panel_buffer, + "…and the panel keeps its current presentation" + ); +} + +// --------------------------------------------------------------------------- +// 24 / 25 / 26 / 27 — the window guards +// --------------------------------------------------------------------------- + +#[test] +fn acc24_killing_a_panel_buffer_closes_the_side_window() { + let s = editor(); + let panel = open_panel(&s, "*panel*", 5); + let panel_buffer = s.core.borrow().windows[&panel].buffer_id; + exec(&s, "pmacs.buffer.kill(PANEL_BUF)"); + assert!(side_window(&s).is_none(), "the side window closed"); + assert!( + !s.core.borrow().windows.contains_key(&panel), + "…rather than being redirected to *scratch*" + ); + assert!(!s.core.borrow().registry.borrow().contains(panel_buffer)); +} + +#[test] +fn acc25_close_active_refuses_only_the_last_document_window() { + let s = editor(); + let document = active_window(&s); + let panel = open_panel(&s, "*panel*", 5); + // A document window with only the panel beside it still cannot close. + assert!( + !s.core.borrow_mut().close_active(), + "the last document window is protected" + ); + // The panel itself always may — even as the only other window. + exec(&s, "pmacs.window.focus_next()"); + assert_eq!(active_window(&s), panel); + assert!( + s.core.borrow_mut().close_active(), + "closing the side window is always legal" + ); + assert!(side_window(&s).is_none()); + assert_eq!(active_window(&s), document); +} + +#[test] +fn acc26_close_others_and_split_respect_the_side_window() { + let s = editor(); + exec(&s, "pmacs.window.split_horizontal()"); + let panel = open_panel(&s, "*panel*", 5); + // From a side window both are pointed errors. + exec(&s, "pmacs.window.focus_next()"); + while active_window(&s) != panel { + exec(&s, "pmacs.window.focus_next()"); + } + assert!(s.core.borrow_mut().close_others().is_err()); + assert!( + s.core + .borrow_mut() + .try_split_active(Orientation::Horizontal, true) + .is_err() + ); + assert!(side_window(&s).is_some(), "nothing was mutated"); + + // From a document window, close_others deletes the panel too. + exec(&s, "pmacs.window.focus_next()"); + assert_ne!(active_window(&s), panel); + s.core + .borrow_mut() + .close_others() + .expect("document may close others"); + assert!(side_window(&s).is_none()); + assert_eq!( + s.core.borrow().views[&FrontendId::LOCAL] + .layout + .iter_ids() + .len(), + 1 + ); +} + +#[test] +fn acc27_traversal_refreshes_the_remembered_document_origin() { + let s = editor(); + let a = active_window(&s); + exec(&s, "pmacs.window.split_horizontal()"); + let b = s.core.borrow().views[&FrontendId::LOCAL] + .layout + .iter_ids() + .into_iter() + .find(|id| *id != a) + .expect("second document window"); + // Create the panel from A. + s.core.borrow_mut().focus_window(FrontendId::LOCAL, a); + let panel = open_panel(&s, "*panel*", 5); + assert_eq!( + s.core.borrow().windows[&panel].params.origin_document(), + Some(a) + ); + // Enter the panel from B: the memory retargets. + s.core.borrow_mut().focus_window(FrontendId::LOCAL, b); + s.core.borrow_mut().focus_window(FrontendId::LOCAL, panel); + assert_eq!( + s.core.borrow().windows[&panel].params.origin_document(), + Some(b), + "entering the panel from B retargets the remembered origin" + ); + assert_eq!( + eval::(&s, "return pmacs.window.display_target()"), + b.raw(), + "display_target follows it" + ); + // A Delete-form quit focuses B, not the creation-time window. + exec(&s, "pmacs.window.quit()"); + assert_eq!(active_window(&s), b); +} + +// --------------------------------------------------------------------------- +// 29 — optimistic input is gated per WINDOW, not per buffer +// --------------------------------------------------------------------------- + +#[test] +fn acc29_focused_side_window_gates_dispatch_idle_without_marking_the_buffer() { + let s = editor(); + let panel = open_panel(&s, "*panel*", 5); + let panel_buffer = s.core.borrow().windows[&panel].buffer_id; + assert!( + s.dispatch_idle_for(FrontendId::LOCAL), + "a document window is idle" + ); + exec(&s, "pmacs.window.focus_next()"); + assert_eq!(active_window(&s), panel); + assert!( + !s.dispatch_idle_for(FrontendId::LOCAL), + "a focused side window turns optimistic apply off" + ); + assert!( + !s.core.borrow().buffer_round_trips(panel_buffer), + "…WITHOUT marking the buffer round-trip" + ); + + // Another frontend showing that same buffer as its DOCUMENT keeps + // optimistic apply. + let other = FrontendId(29); + let other_window = attach_frontend(&s, other, true); + s.core + .borrow_mut() + .install_buffer_in_window(other_window, panel_buffer) + .expect("install"); + assert!( + s.dispatch_idle_for(other), + "the buffer-global set is untouched, so the peer stays optimistic" + ); +} + +// --------------------------------------------------------------------------- +// 30 / 31 — the divider +// --------------------------------------------------------------------------- + +#[test] +fn acc30_divider_drag_writes_fixed_rows_and_weights_and_creates_no_selection() { + let s0 = editor(); + let mut s = s0; + let panel = open_panel(&s, "*panel*", 6); + let document = s + .core + .borrow() + .non_side_target(FrontendId::LOCAL) + .expect("document"); + let rects = render(&s); + let divider_row = u16::try_from(rects[&document].origin.row + rects[&document].size.rows - 1) + .expect("row fits"); + + s.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::Down(MouseButton::Left), divider_row, 3), + CellSize::new(ROWS, COLS), + ); + assert!( + s.core.borrow().active_window().selection.is_none(), + "a press on the reserved row creates no selection" + ); + s.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::Drag(MouseButton::Left), divider_row + 2, 3), + CellSize::new(ROWS, COLS), + ); + s.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::Up(MouseButton::Left), divider_row + 2, 3), + CellSize::new(ROWS, COLS), + ); + assert_eq!( + fixed_rows_of(&s, panel), + Some(4), + "dragging the divider DOWN shrinks the side window's fixed rows" + ); + + // A flexible pair writes weights instead. + let mut s = editor(); + exec(&s, "pmacs.window.split_horizontal()"); + let top = s.core.borrow().views[&FrontendId::LOCAL].layout.iter_ids()[0]; + let rects = render(&s); + let divider_row = + u16::try_from(rects[&top].origin.row + rects[&top].size.rows - 1).expect("row fits"); + let before = rects[&top].size.rows; + s.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::Down(MouseButton::Left), divider_row, 3), + CellSize::new(ROWS, COLS), + ); + s.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::Drag(MouseButton::Left), divider_row + 3, 3), + CellSize::new(ROWS, COLS), + ); + let after = render(&s)[&top].size.rows; + assert_eq!( + after, + before + 3, + "the flexible boundary moved by the drag delta" + ); + // …and the ratio survives a frame resize, which is the whole point of + // writing weights rather than a fixed extent. + s.sync_frame_geometry(FrontendId::LOCAL, CellSize::new(ROWS * 2, COLS)); + let doubled = render_at(&s, CellSize::new(ROWS * 2, COLS))[&top].size.rows; + assert!(doubled > after, "the ratio scales with the frame"); +} + +#[test] +fn acc30b_ui_divider_face_resolves_and_paints_every_exposed_segment() { + let s = editor(); + // A boundary whose upper child is a VERTICAL split exposes several + // leaf mode-line segments along the same edge. + exec(&s, "pmacs.window.split_vertical()"); + open_panel(&s, "*panel*", 5); + exec( + &s, + "pmacs.theme.set { [\"ui.divider\"] = { fg = { 255, 0, 255 } } }", + ); + let rows = painted_rows(&s, CellSize::new(ROWS, COLS)); + let boundary_rows: Vec = rows + .iter() + .enumerate() + .filter(|(_, line)| line.contains('⇕')) + .map(|(i, _)| i) + .collect(); + assert_eq!( + boundary_rows.len(), + 1, + "both exposed segments sit on the SAME boundary row" + ); + + // Dragging either segment resolves the same boundary. + let core = s.core.borrow(); + let ids = core.views[&FrontendId::LOCAL].layout.iter_ids(); + let leaves: Vec = ids + .into_iter() + .filter(|id| !core.windows[id].is_side()) + .collect(); + let layout = core.views[&FrontendId::LOCAL].layout.clone(); + drop(core); + assert_eq!(leaves.len(), 2); + assert_eq!( + layout.boundary_below(leaves[0]), + layout.boundary_below(leaves[1]), + "every leaf segment touching the same bottom edge resolves to one boundary" + ); +} + +#[test] +fn acc31_keyboard_resize_matches_the_equivalent_drag_in_a_nested_layout() { + // Build H[ H[A, C], B ] — A's nearest horizontal ancestor is the + // inner split; C's is that same split, but C is its FINAL child, so + // C's boundary is the outer one. The naive "nearest horizontal + // ancestor" reading picks the wrong split for C. + let s = editor(); + exec(&s, "pmacs.window.split_horizontal()"); + let a = s.core.borrow().views[&FrontendId::LOCAL].layout.iter_ids()[0]; + s.core.borrow_mut().focus_window(FrontendId::LOCAL, a); + exec(&s, "pmacs.window.split_horizontal()"); + let ids = s.core.borrow().views[&FrontendId::LOCAL].layout.iter_ids(); + assert_eq!(ids.len(), 3); + let (a, c, b) = (ids[0], ids[1], ids[2]); + + let layout = s.core.borrow().views[&FrontendId::LOCAL].layout.clone(); + assert_ne!( + layout.boundary_below(a), + layout.boundary_below(c), + "A owns the INNER boundary; C, as that split's final child, \ + resolves upward to the outer one — the naive \"nearest \ + horizontal ancestor\" reading picks the wrong split for C" + ); + assert_eq!( + layout.boundary_below(c).expect("C has a boundary").path, + Vec::::new(), + "C's boundary is the ROOT split, not its own parent" + ); + assert!( + layout.boundary_below(b).is_none(), + "the last child owns no boundary" + ); + + // The keyboard resize and the equivalent DRAG move the same boundary + // to the same place. `resize(win, delta)` resolves from the SUPPLIED + // window (the Lua entry point is explicit). + let before = render(&s); + exec(&s, &format!("pmacs.window.resize({}, 2)", c.raw())); + let by_command: HashMap = render(&s) + .iter() + .map(|(id, rect)| (*id, rect.size.rows)) + .collect(); + assert!( + by_command[&c] > before[&c].size.rows, + "C grew: {} -> {}", + before[&c].size.rows, + by_command[&c] + ); + + let mut dragged = editor(); + exec(&dragged, "pmacs.window.split_horizontal()"); + let da = dragged.core.borrow().views[&FrontendId::LOCAL] + .layout + .iter_ids()[0]; + dragged + .core + .borrow_mut() + .focus_window(FrontendId::LOCAL, da); + exec(&dragged, "pmacs.window.split_horizontal()"); + let dids = dragged.core.borrow().views[&FrontendId::LOCAL] + .layout + .iter_ids(); + let dc = dids[1]; + let rects = render(&dragged); + let divider_row = + u16::try_from(rects[&dc].origin.row + rects[&dc].size.rows - 1).expect("row fits"); + dragged.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::Down(MouseButton::Left), divider_row, 3), + CellSize::new(ROWS, COLS), + ); + dragged.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::Drag(MouseButton::Left), divider_row + 2, 3), + CellSize::new(ROWS, COLS), + ); + let by_drag = render(&dragged); + assert_eq!( + by_command[&c], by_drag[&dc].size.rows, + "keyboard resize equals the equivalent drag on that window's \ + bottom mode-line row" + ); + + // The no-adjustable-boundary case reports and no-ops. + let before = structure(&layout_root(&s)); + assert!(try_exec(&s, &format!("pmacs.window.resize({}, 1)", b.raw())).is_err()); + assert_eq!(before, structure(&layout_root(&s))); + + // The commands act on the ACTIVE window and equal the same move. + let s = editor(); + exec(&s, "pmacs.window.split_horizontal()"); + let top = s.core.borrow().views[&FrontendId::LOCAL].layout.iter_ids()[0]; + s.core.borrow_mut().focus_window(FrontendId::LOCAL, top); + let before = render(&s)[&top].size.rows; + exec(&s, "pmacs.command.invoke(\"window.enlarge\")"); + assert_eq!(render(&s)[&top].size.rows, before + 1); + exec(&s, "pmacs.command.invoke(\"window.shrink\")"); + assert_eq!(render(&s)[&top].size.rows, before); +} + +// --------------------------------------------------------------------------- +// 32 / 33 / 34 — a terminal panel's height changes +// --------------------------------------------------------------------------- + +#[test] +fn acc32_terminal_panel_height_change_is_a_viewport_change() { + let mut s = editor(); + exec( + &s, + "TERM_BUF = pmacs.terminal.open { command = \"/bin/sh\", \ + args = { \"-c\", \"for i in $(seq 1 200); do echo line$i; done; sleep 30\" }, \ + display = \"panel\" }", + ); + let panel = side_window(&s).expect("terminal panel"); + let buffer: pmacs::lua_bindings::BufferIdLua = eval(&s, "return TERM_BUF"); + render(&s); + + // Wait for output. + let deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + s.tick_processes(); + let has_output = s + .terminal_manager + .borrow() + .snapshot(buffer.0) + .is_some_and(|snap| !snap.cells.is_empty()); + if has_output || std::time::Instant::now() > deadline { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + + // Scroll back, then change the panel height. `top` is preserved + // verbatim: a height change is a viewport change, never a scroll one. + exec(&s, "pmacs.window.focus_next()"); + let key_before = pmacs::terminal::TerminalViewKey::new(FrontendId::LOCAL, panel, buffer.0); + s.terminal_manager + .borrow_mut() + .scroll_view(key_before, CellSize::new(6, COLS), 5); + let offset_before = s + .terminal_manager + .borrow_mut() + .view_status(key_before) + .map(|status| status.scroll_offset); + exec( + &s, + &format!( + "pmacs.window.set_params({}, {{ fixed_rows = 10 }})", + panel.raw() + ), + ); + render(&s); + s.sync_terminal_layout(FrontendId::LOCAL, CellSize::new(ROWS, COLS)); + let offset_after = s + .terminal_manager + .borrow_mut() + .view_status(key_before) + .map(|status| status.scroll_offset); + assert_eq!( + offset_before, offset_after, + "a scrolled-back terminal panel keeps its top across a height change" + ); + exec(&s, "pmacs.terminal.terminate(TERM_BUF)"); +} + +/// **Bet B1 pin.** Panel-as-window means the terminal controller, the +/// fixed `C-c` escape, and release-on-blur need zero new code: the +/// controller is keyed `(frontend_id, window_id)` and `view.active` +/// already answers "which window", whether or not that window is a side +/// window. +#[test] +fn acc28_child_input_and_the_c_c_escape_work_unchanged_in_a_panel() { + let temp = tempfile::TempDir::new().expect("tempdir"); + let ready_path = temp.path().join("ready"); + let input_path = temp.path().join("input"); + let probe = format!( + concat!( + "import os, tty\n", + "tty.setraw(0)\n", + "open({:?}, 'wb').write(b'1')\n", + "data = b''\n", + "while len(data) < 5: data += os.read(0, 5 - len(data))\n", + "open({:?}, 'wb').write(data)\n", + ), + ready_path.to_str().expect("UTF-8 ready path"), + input_path.to_str().expect("UTF-8 input path") + ); + let mut s = editor(); + exec( + &s, + &format!( + "TERM_BUF = pmacs.terminal.open {{ + command = \"/usr/bin/python3\", + args = {{ \"-c\", {} }}, + rows = 4, cols = 20, + display = \"panel\", + }}", + format_args!("{probe:?}") + ), + ); + let panel = side_window(&s).expect("terminal panel"); + assert_eq!( + active_window(&s), + panel, + "the panel opt-in selects the panel" + ); + assert_eq!( + wait_for_file(&ready_path, Duration::from_secs(5)), + b"1", + "the child in the PANEL reached raw mode" + ); + + // Exactly the Stage 2 vterm contract, unchanged: unescaped bound keys + // reach the child, `C-c` escapes for one key, `C-c C-c` sends one + // literal interrupt. + for ev in [ + key(KeyCode::Char('v'), KeyModifiers::ALT), + key(KeyCode::Char('c'), KeyModifiers::CONTROL), + key(KeyCode::Char('c'), KeyModifiers::CONTROL), + key(KeyCode::Char('w'), KeyModifiers::ALT), + ] { + s.dispatch_key(FrontendId::LOCAL, ev); + } + assert_eq!( + wait_for_file(&input_path, Duration::from_secs(5)), + b"\x1bv\x03\x1bw", + "child input routing through a SIDE window is byte-identical" + ); + + // Release-on-blur still works: leaving the panel drops the controller. + exec(&s, "pmacs.window.focus_next()"); + assert_ne!(active_window(&s), panel); + s.sync_terminal_layout(FrontendId::LOCAL, CellSize::new(ROWS, COLS)); + assert!( + s.terminal_manager + .borrow() + .controller_view_for_frontend(FrontendId::LOCAL) + .is_none(), + "the controller is released when focus leaves the panel" + ); + exec(&s, "pmacs.terminal.terminate(TERM_BUF)"); +} + +fn wait_for_file(path: &std::path::Path, timeout: Duration) -> Vec { + let deadline = std::time::Instant::now() + timeout; + loop { + if let Ok(bytes) = std::fs::read(path) + && !bytes.is_empty() + { + return bytes; + } + assert!( + std::time::Instant::now() < deadline, + "timed out waiting for {}", + path.display() + ); + std::thread::sleep(Duration::from_millis(20)); + } +} + +#[test] +fn acc33_growth_with_a_historical_selection_keeps_the_anchor_frozen() { + let mut s = editor(); + exec( + &s, + "TERM_BUF = pmacs.terminal.open { command = \"/bin/sh\", \ + args = { \"-c\", \"i=0; while [ $i -lt 60 ]; do printf 'row%02d\\\\n' $i; \ + i=$((i+1)); done; sleep 30\" }, \ + display = \"panel\" }", + ); + let panel = side_window(&s).expect("terminal panel"); + let buffer: pmacs::lua_bindings::BufferIdLua = eval(&s, "return TERM_BUF"); + let key_id = pmacs::terminal::TerminalViewKey::new(FrontendId::LOCAL, panel, buffer.0); + let view_size = CellSize::new(5, COLS); + + let deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + s.tick_processes(); + let seen = s + .terminal_manager + .borrow_mut() + .snapshot_for_view(key_id, view_size) + .is_some(); + if seen || std::time::Instant::now() > deadline { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + + // Scroll back into history and start a selection there. + { + let mut manager = s.terminal_manager.borrow_mut(); + assert!(manager.scroll_view(key_id, view_size, 10)); + assert!(manager.begin_selection(key_id, view_size, CellCoord::new(0, 0))); + } + let before = s.terminal_manager.borrow_mut().view_status(key_id); + + // Grow the panel enough that it would otherwise reach the live tail. + exec( + &s, + &format!( + "pmacs.window.set_params({}, {{ fixed_rows = 20 }})", + panel.raw() + ), + ); + render(&s); + s.sync_terminal_layout(FrontendId::LOCAL, CellSize::new(ROWS, COLS)); + let after = s.terminal_manager.borrow_mut().view_status(key_id); + assert_eq!( + before.map(|status| (status.scroll_offset, status.selection)), + after.map(|status| (status.scroll_offset, status.selection)), + "a historical selection freezes the anchor across a height change" + ); + exec(&s, "pmacs.terminal.terminate(TERM_BUF)"); +} + +#[test] +fn acc34_only_the_controller_resizes_the_pty() { + let mut s = editor(); + exec( + &s, + "TERM_BUF = pmacs.terminal.open { command = \"/bin/sh\", \ + args = { \"-c\", \"sleep 30\" }, display = \"panel\" }", + ); + let panel = side_window(&s).expect("terminal panel"); + let buffer: pmacs::lua_bindings::BufferIdLua = eval(&s, "return TERM_BUF"); + render(&s); + s.sync_terminal_layout(FrontendId::LOCAL, CellSize::new(ROWS, COLS)); + let controlled = s.terminal_manager.borrow().screen_size(buffer.0); + + // A second frontend that does NOT control the session may hold its + // own panel height without resizing the child. + let other = FrontendId(34); + attach_frontend(&s, other, true); + s.sync_terminal_layout(other, CellSize::new(ROWS, COLS)); + assert_eq!( + s.terminal_manager.borrow().screen_size(buffer.0), + controlled, + "only the controller's height change resizes the PTY" + ); + let _ = panel; + exec(&s, "pmacs.terminal.terminate(TERM_BUF)"); +} + +// --------------------------------------------------------------------------- +// 35 — the desktop never persists a side window +// --------------------------------------------------------------------------- + +#[test] +fn acc35_desktop_round_trip_omits_the_side_leaf_and_its_wrapper() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("saved.txt"); + std::fs::write(&file, b"content\n").unwrap(); + let path = file.display().to_string(); + + let s = editor(); + exec(&s, &format!("pmacs.buffer.find_or_open({path:?})")); + let document_structure = structure(&layout_root(&s)); + exec( + &s, + &format!( + "PANEL_BUF = pmacs.buffer.find_or_open({path:?}) + pmacs.window.display(PANEL_BUF, {{ side = \"bottom\", height = 6 }})" + ), + ); + assert!(side_window(&s).is_some()); + + let snapshot = + pmacs::desktop::snapshot(&s.core.borrow(), "test".into()).expect("a file window survives"); + assert_eq!( + snapshot.version, + pmacs::desktop::DESKTOP_VERSION, + "the desktop format version does not change" + ); + assert!( + matches!(snapshot.root, pmacs::desktop::SavedNode::Leaf(_)), + "neither the side leaf nor its root wrapper is persisted \ + (saw {:?})", + snapshot.root + ); + let _ = document_structure; +} + +// --------------------------------------------------------------------------- +// Core-level invariants that back the above +// --------------------------------------------------------------------------- + +#[test] +fn panel_hidden_never_describes_a_panel_that_no_longer_exists() { + let s = editor(); + let panel = open_panel(&s, "*panel*", 8); + s.sync_frame_geometry(FrontendId::LOCAL, CellSize::new(4, COLS)); + assert!(s.core.borrow().panel_hidden_for(FrontendId::LOCAL)); + s.core + .borrow_mut() + .remove_side_window(FrontendId::LOCAL, panel); + s.reconcile_panel_layout(FrontendId::LOCAL); + assert!( + !s.core.borrow().views[&FrontendId::LOCAL].panel_hidden, + "reconciliation clears the flag once the window is gone" + ); +} + +#[test] +fn unknown_geometry_is_not_twenty_four_by_eighty() { + let s = EditorState::new(); + exec(&s, "pmacs.lsp.config = {}"); + let fid = FrontendId(77); + attach_frontend(&s, fid, false); + assert!( + s.core.borrow().frontend_area_rows(fid).is_none(), + "a semantic view's geometry is UNKNOWN, never the attach placeholder" + ); + let buffer = s.core.borrow_mut().registry.borrow_mut().create("*p*"); + let mut request = DisplayRequest::new(buffer); + request.side = Some(Side::Bottom); + let _ = s.core.borrow_mut().display_buffer(fid, &request); + // Not panel-capable in Stage 1, so it fell back; and even a capable + // view with unknown geometry would follow the hidden arm. + assert!(s.core.borrow().side_window_for(fid).is_none()); +} + +#[test] +fn quit_action_truncation_is_iterative_and_bounded() { + let mut action = QuitAction::Delete; + for _ in 0..(MAX_PANEL_QUIT_DEPTH * 3) { + action = QuitAction::Restore { + buffer_id: BufferId::from_raw(1), + fixed_rows: 4, + dedicated: false, + cursor: 0, + view_top: 0, + goal_col: None, + selection: None, + then: Box::new(action), + }; + action.truncate_to(MAX_PANEL_QUIT_DEPTH); + assert!(action.depth() <= MAX_PANEL_QUIT_DEPTH); + } +} + +#[test] +fn clamp_panel_rows_rejects_zero_and_lifts_to_the_floor() { + assert!(EditorCore::clamp_panel_rows(0).is_err()); + assert_eq!(EditorCore::clamp_panel_rows(1), Ok(MIN_WINDOW_OUTER_ROWS)); + assert_eq!(EditorCore::clamp_panel_rows(30), Ok(30)); +} + +#[test] +fn cell_coord_helper_is_used() { + // Keeps the CellCoord import honest for grid assertions above. + assert_eq!(CellCoord::new(1, 2).row, 1); +} From 2a9c11461c73ec3ea7368e297689ce10798abd10 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 24 Jul 2026 14:20:16 -0400 Subject: [PATCH 04/22] docs: record the bottom-panel Stage 1 lane Adds the lane to docs/active-work.md: branch, base, what Stage 1 implemented, the verification run, and the two known local-only test caveats (the parallel-load GPU flake and compile_mode_acceptance's single-thread requirement). The durable handoff snapshot stays untouched until the PR merges, per its own update protocol. Co-Authored-By: Claude Opus 5 (1M context) --- docs/active-work.md | 49 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/docs/active-work.md b/docs/active-work.md index 2b258bd..fe1fb19 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -54,6 +54,55 @@ git status --short --branch The `git log` command must expose `0dd16a5` or a newer intentional main. If it does not, stop and repair the remote/fetch configuration. +## Bottom-panel lane (window placement + side windows) — Stage 1 IN REVIEW + +- Portable branch: `githubsucks/bottom-panel`, worktree + `../pmacs-bottom-panel`, based on `githubsucks/main` @ `ddaa80d`. +- Approved framing: `docs/bottom-panel-framing.md` revision 4, committed + as the branch's first commit (`c27f75a`). +- **Stage 1 implemented; no wire change (protocol stays v20).** What + landed on the branch: + - `src/window.rs`: `WindowParams` (`side` / `fixed_rows` / `dedicated` + + implementation-owned `quit_action` and `origin_document`), `Side`, + a depth-bounded `QuitAction`, `MIN_WINDOW_OUTER_ROWS = 2`, + `Layout::compute(area, fixed)`, the `subtree_min_rows` / + `interactive_min_rows` recursions, `boundary_below`, and the three + new `FrontendView` fields (`panel_capable`, `frame_geometry`, + `panel_hidden`). + - `src/editor_core.rs`: `primary_document_window`, the non-side target + rule, `display_buffer` + the Q#BP3 placement policy, `quit_window`, + `reconcile_panel_layout_core`, `resize_boundary`, per-frontend + `JumpEntry`s, and the shared `resolve_target_buffer` seam that the + #148 initial-target bootstrap now routes through as well. + - `src/editor.rs`: the reconciliation transaction, geometry + declaration, the side-window `dispatch_idle_for` gate, the divider + paint, and the divider drag. + - `src/lua_bindings/window_panel.rs`: the whole `pmacs.window` panel + surface plus the shared adopter-placement helpers; + `builtin/runtime/window.lua` owns `window.panel-height` / + `window.min-height` and the resize commands. + - Adopters: `listview.open`, `compile.run`, `pmacs.terminal.open` all + take `display = "current" | "panel"` (Stage 1 default `"current"`); + LSP/compile visits route through `display_file`. +- Verification on this branch: `cargo fmt --check` clean; strict + workspace Clippy clean; 1,815 default + 1,992 CRDT library tests; + `bottom_panel_stage1_acceptance` 42/42; M4 121; required GPU 152; + `gpu_initial_target_acceptance` 1 default + 14 CRDT; vterm Stage 2 4 / + Stage 3 5; folding Stage 2 48; statusline 7; listview 6; desktop 11; + workspace sweep 3,103 passed across 86 suites; `git diff --check` + clean. + - **The sweep's only red was the known parallel-load GPU flake**: + `font_facts_out_of_range_sizes_fail_closed`, + `built_in_only_overwide_readout_…`, and + `statusline_wire_validation_is_atomic_…` fail under a loaded + workspace run (wgpu device contention) and pass both isolated and in + the dedicated `PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu` gate. + - `compile_mode_acceptance` likewise needs `--test-threads=1` locally; + it is 67/67 there. +- Stage 2 (the GPU panel band, next available protocol version) has its + own re-framing obligation before implementation; Stage 3 is the default + placement flip. + ## Folding lane (Arc 6) — Stages 1 and 2 MERGED; Stage 3 (GPU) is next Both shipped stages are on `main`; nothing in this arc is in flight. Stage 3 From 90fc7a913e0c44fd6986668dc5341cb75e4a4cd7 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 24 Jul 2026 15:07:35 -0400 Subject: [PATCH 05/22] fix(window): wire the side-window split guard and scope the divider drag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #155 review round 1. Finding 1 (must fix): `try_split_active` had no production caller — `pmacs.window.split_horizontal` / `split_vertical`, and therefore `C-x 2` / `C-x 3`, still went through plain `split_active`. Splitting a focused panel made the root wrapper's final child a split rather than `Leaf(side)`, which both `Layout::compute`'s fixed pass and `document_subtree` key on: the panel band reverted to 1:1 weight division and an ordinary window ended up living inside it. Both bindings now route through the guard, and acc26 asserts through the real Lua path — a direct core call passes with the guard unwired, which is how it survived the first round. Finding 2: the armed-drag early return now checks the arming frontend, so one frontend's in-flight gesture cannot cancel or swallow another's mouse events. New acc30c. Finding 3: `paint_mode_line_graphemes`'s doc block was left heading `paint_divider_segment`; moved back. Finding 4: a recompile carries no `display`, so it took the raw switch and duplicated a panel-placed `*compilation*` into the document window. `start_run` now detects that the buffer already owns the panel slot. `pmacs.window.buffer` gained an optional window argument so an adopter can ask without selecting the panel first. New acc19b. Stage-2 hazard pins the review asked for, both in `src/daemon.rs`: a fresh attach while LOCAL is focused in a panel inherits LOCAL's document buffer, and an initial-target bootstrap whose `after-load` hook creates and selects a panel still reasserts into a document window. Minor: dropped listview's dead `p.side`; documented `focus_window`'s caller-validates contract; `jump_back` restores through `focus_window` so the "every focus change" contract holds; `params` / `resize` default to the acting frontend's selected window rather than the ambient one; widened the flexible-division math to u64 intermediates. Co-Authored-By: Claude Opus 5 (1M context) --- builtin/runtime/compile.lua | 22 +++- builtin/runtime/listview.lua | 2 - src/daemon.rs | 129 ++++++++++++++++++++++++ src/editor.rs | 16 ++- src/editor_core.rs | 13 ++- src/lua_bindings/mod.rs | 53 ++++++++-- src/lua_bindings/window_panel.rs | 17 +++- src/window.rs | 12 ++- tests/bottom_panel_stage1_acceptance.rs | 127 ++++++++++++++++++++--- 9 files changed, 354 insertions(+), 37 deletions(-) diff --git a/builtin/runtime/compile.lua b/builtin/runtime/compile.lua index 2c00731..2e894d2 100644 --- a/builtin/runtime/compile.lua +++ b/builtin/runtime/compile.lua @@ -732,6 +732,17 @@ end) -- Start a run in `slot`. Shared by compile and shell-command; grep -- has its own worker path. +-- Whether `buf` is currently the acting frontend's side-window buffer +-- (bottom-panel arc). Used so a recompile re-displays into the panel it +-- is already in rather than duplicating itself into the document window. +local function already_in_panel(buf) + if not buf then return false end + local panel = pmacs.window.panel() + if not panel then return false end + local ok, shown = pcall(pmacs.window.buffer, panel) + return ok and shown == buf +end + local function start_run(slot, cmdline, opts) opts = opts or {} -- Bottom-panel arc (Q#BP11b): validate placement BEFORE the run @@ -818,9 +829,14 @@ local function start_run(slot, cmdline, opts) -- The FIRST display of this run is the side-affine one (Q#BP3): a -- persistent *compilation* already visible in a document window must -- not preempt the requested panel. Compile output is passive, so it - -- takes `select = false` explicitly; a recompile simply reuses the - -- panel it is already in. - if display == "panel" then + -- takes `select = false` explicitly. + -- + -- A recompile reaches here with NO `display` (only cmdline/cwd are + -- stored in `_last`), so the raw switch below would put this buffer in + -- the selected DOCUMENT window while the panel still shows it — the + -- duplicate presentation this arc removes elsewhere. Detect that the + -- buffer already owns the panel slot and keep it there. + if display == "panel" or already_in_panel(slot.buf) then pmacs.window.display(slot.buf, { side = "bottom", select = false }) else pmacs.window.switch_buffer(slot.buf) diff --git a/builtin/runtime/listview.lua b/builtin/runtime/listview.lua index 6081587..6a6d717 100644 --- a/builtin/runtime/listview.lua +++ b/builtin/runtime/listview.lua @@ -136,10 +136,8 @@ function pmacs.listview.open(spec) tostring(display))) end if display == "panel" then - p.side = true pmacs.window.display(p.buffer, { side = "bottom", select = true }) else - p.side = false pmacs.window.switch_buffer(p.buffer) end seat_cursor(p, 1) diff --git a/src/daemon.rs b/src/daemon.rs index 44d4b61..5af71d0 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -4190,4 +4190,133 @@ mod tests { "key must self-insert into the displayed buffer, not the attach-time scratch" ); } + + /// Bottom-panel arc, §1.3 #22 (framing acceptance 51's Stage-1 half). + /// + /// A fresh no-target attach clones `LOCAL`'s **primary document** + /// buffer, not `local_view.active`. Stage 1 makes a TUI panel a real + /// focus target, so `LOCAL` can legitimately own focus in a panel at + /// attach time — and panel content must never become a newly attached + /// frontend's full-window document. + #[test] + fn fresh_attach_inherits_locals_document_buffer_not_its_focused_panel() { + let mut editor = EditorState::new(); + let document_buffer = editor.core.borrow().active_buffer_id(); + let panel_buffer = editor.core.borrow().registry.borrow_mut().create("*panel*"); + // Open a bottom panel on LOCAL and focus it. + let panel = { + let mut core = editor.core.borrow_mut(); + let mut request = crate::editor_core::DisplayRequest::new(panel_buffer); + request.side = Some(crate::window::Side::Bottom); + request.height = Some(5); + request.select = Some(true); + let outcome = core + .display_buffer(FrontendId::LOCAL, &request) + .expect("panel placement"); + core.focus_window(FrontendId::LOCAL, outcome.target); + outcome.target + }; + assert_eq!( + editor.core.borrow().views[&FrontendId::LOCAL].active, + panel, + "LOCAL really is focused in the panel" + ); + + let fid = FrontendId(123); + let view = build_fresh_frontend_view(&mut editor, false, false); + editor.core.borrow_mut().register_frontend_view(fid, view); + + assert_eq!( + editor + .core + .borrow() + .active_window_for(fid) + .expect("fresh view window") + .buffer_id, + document_buffer, + "the new frontend inherited LOCAL's DOCUMENT buffer; inheriting \ + `local_view.active` would have made the panel its document" + ); + assert_ne!(document_buffer, panel_buffer); + } + + /// Bottom-panel arc, Q#BP11b / R4-B4 (framing acceptance 55's + /// Stage-1 half). + /// + /// Stage 1 lets a startup hook create and select a side window. The + /// initial-target bootstrap must still reassert the requested buffer + /// in — and activate — a **non-side** document window, rather than + /// overwriting the panel merely because it became `view.active`. + #[test] + fn initial_target_reasserts_a_document_window_when_a_hook_selects_a_panel() { + use std::os::unix::ffi::OsStrExt as _; + + let dir = tempfile::tempdir().expect("tempdir"); + let target = dir.path().join("target.txt"); + std::fs::write(&target, b"target contents\n").expect("write target"); + + let mut editor = EditorState::new(); + editor + .lua_host + .lua() + .load( + r#" + pmacs.lsp.config = {} + pmacs.hook.add("buffer.after-load", function() + if HOOK_RAN then return end + HOOK_RAN = true + HOOK_PANEL = pmacs.window.display( + pmacs.buffer.create("*hook-panel*"), + { side = "bottom", height = 4, select = true }) + end) + "#, + ) + .exec() + .expect("install hook"); + + // A GRID session (panel-capable), which is the realistic shape + // for a hook-created panel in Stage 1 — and real geometry, so + // the panel is genuinely VISIBLE and focused when the reassert + // runs. Without the declaration, reconciliation would hide the + // panel and move focus out on its own, and the assertions below + // would pass without exercising the reassert at all. + let fid = FrontendId(124); + let view = build_fresh_frontend_view(&mut editor, true, true); + editor.core.borrow_mut().register_frontend_view(fid, view); + editor.sync_frame_geometry(fid, CellSize::new(24, 80)); + + let opened = open_initial_target( + &mut editor, + fid, + InitialTarget { + path: target.as_os_str().as_bytes().to_vec(), + cwd: dir.path().as_os_str().as_bytes().to_vec(), + }, + ) + .expect("bootstrap succeeds despite the panel-creating hook"); + + let core = editor.core.borrow(); + assert!( + !core.views[&fid].panel_hidden, + "the hook's panel is visible, so focus really was on it when \ + the reassert ran" + ); + let active = core.views[&fid].active; + let active_window = core.windows.get(&active).expect("active window live"); + assert!( + !active_window.is_side(), + "bootstrap activated a DOCUMENT window, not the hook's panel" + ); + assert_eq!( + active_window.buffer_id, opened.buffer_id, + "…showing the requested target" + ); + let panel = core + .side_window_for(fid) + .expect("the hook's panel survived"); + assert_ne!( + core.windows[&panel].buffer_id, opened.buffer_id, + "the panel was not overwritten with the target" + ); + } } diff --git a/src/editor.rs b/src/editor.rs index 2d2a5e8..246a575 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -1925,7 +1925,15 @@ impl EditorState { // pointer for the whole gesture, INCLUDING rows outside any // window — otherwise tracking would stop the moment the pointer // crossed the frame's status row. - if self.window_drag.is_some() { + // + // Scoped to the ARMING frontend. The daemon routes every attached + // grid frontend through this same dispatcher, so an unscoped + // check would let one frontend's in-flight drag cancel and + // swallow another frontend's clicks. + if self + .window_drag + .is_some_and(|drag| drag.frontend_id == frontend_id) + { match ev.kind { MouseEventKind::Drag(MouseButton::Left) => { self.drag_window_boundary(frontend_id, cell_row, term_size); @@ -3860,9 +3868,6 @@ fn mode_line_grapheme_width(graphemes: &[ModeLineGrapheme]) -> u32 { graphemes.iter().map(|grapheme| grapheme.width).sum() } -/// Paint complete graphemes at a logical signed origin. A grapheme that -/// straddles either clip edge is omitted wholesale, so a wide glyph can never -/// leave a dangling half-cell at a window or left/right collision boundary. /// Restyle one exposed segment of a horizontal split boundary and stamp /// its grip (Q#BP5a). /// @@ -3890,6 +3895,9 @@ fn paint_divider_segment( cell.glyph = crate::cell::Glyph::Char(DIVIDER_HANDLE_GLYPH); } +/// Paint complete graphemes at a logical signed origin. A grapheme that +/// straddles either clip edge is omitted wholesale, so a wide glyph can never +/// leave a dangling half-cell at a window or left/right collision boundary. fn paint_mode_line_graphemes( grid: &mut crate::cell::CellGrid<'_>, rect: &crate::window::Rect, diff --git a/src/editor_core.rs b/src/editor_core.rs index 039ee43..cfb2bcb 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -1073,7 +1073,12 @@ impl EditorCore { .is_some_and(|window| window.buffer_id == entry.buffer_id) && !self.side_window_is_hidden(fid, entry.window_id); if origin_valid { - self.set_active_window_id(entry.window_id); + // Through `focus_window`, not `set_active_window_id`: + // returning INTO a panel from a document window is a + // focus transition like any other, so it refreshes + // `origin_document` and a later `window.quit` returns to + // the window the jump came from. + self.focus_window(fid, entry.window_id); } else { // A stale SIDE origin is skipped outright: switching a // panel's buffer into the document window is exactly the @@ -2667,6 +2672,12 @@ impl EditorCore { /// Focus an explicit window in the acting frontend, refreshing the /// panel's remembered document origin on the way (Q#BP2c). + /// + /// **The caller must have validated `target`** — that it is live and + /// belongs to `fid`'s layout. Every Lua path does so through + /// `lookup_window` or the display transaction's own revalidation; + /// this function only `debug_assert!`s it, so a release-mode caller + /// passing a foreign or dead id would leave `view.active` dangling. pub fn focus_window(&mut self, fid: FrontendId, target: WindowId) { let Some(view) = self.views.get_mut(&fid) else { return; diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index 3d8a4e9..959edbe 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -12218,14 +12218,20 @@ fn install_window_module(lua: &Lua, core: &SharedCore) -> mlua::Result
{ window_panel::install(lua, core, &win)?; { + // Bottom-panel arc (Q#BP6): `try_split_active` refuses a side + // window. This binding is what `C-x 2` reaches, so the refusal + // has to live on THIS path — splitting the panel leaf would make + // the root wrapper's final child a split rather than + // `Leaf(side)`, and both `Layout::compute`'s fixed pass and + // `document_subtree` key on exactly that shape. let cc = core.clone(); win.set( "split_horizontal", lua.create_function(move |_, ()| { - let new_id = cc - .borrow_mut() - .split_active(crate::window::Orientation::Horizontal, true); - Ok(new_id.raw()) + cc.borrow_mut() + .try_split_active(crate::window::Orientation::Horizontal, true) + .map(crate::window::WindowId::raw) + .map_err(mlua::Error::runtime) })?, )?; } @@ -12235,10 +12241,10 @@ fn install_window_module(lua: &Lua, core: &SharedCore) -> mlua::Result
{ win.set( "split_vertical", lua.create_function(move |_, ()| { - let new_id = cc - .borrow_mut() - .split_active(crate::window::Orientation::Vertical, true); - Ok(new_id.raw()) + cc.borrow_mut() + .try_split_active(crate::window::Orientation::Vertical, true) + .map(crate::window::WindowId::raw) + .map_err(mlua::Error::runtime) })?, )?; } @@ -12351,9 +12357,38 @@ fn install_window_module(lua: &Lua, core: &SharedCore) -> mlua::Result
{ { let cc = core.clone(); + // With no argument: the selected window's buffer (unchanged). + // With an explicit window id: that window's buffer, validated + // against the acting frontend's layout like every other + // `WindowId`-taking operation (bottom-panel arc, Q#BP11) — an + // adopter has to be able to ask "is my buffer the one in the + // panel" without first selecting the panel. win.set( "buffer", - lua.create_function(move |_, ()| Ok(BufferIdLua(cc.borrow().active_buffer_id())))?, + lua.create_function(move |lua, target: Option| -> mlua::Result { + let Some(raw) = target else { + return Ok(BufferIdLua(cc.borrow().active_buffer_id())); + }; + let fid = window_panel::acting_frontend(lua, &cc); + let core = cc.borrow(); + let view = core.views.get(&fid).ok_or_else(|| { + mlua::Error::runtime("pmacs.window.buffer: acting frontend has no layout") + })?; + let id = view + .layout + .iter_ids() + .into_iter() + .find(|id| id.raw() == raw) + .ok_or_else(|| { + mlua::Error::runtime(format!( + "pmacs.window.buffer: window {raw} is not live in this frontend's layout" + )) + })?; + core.windows + .get(&id) + .map(|window| BufferIdLua(window.buffer_id)) + .ok_or_else(|| mlua::Error::runtime("pmacs.window.buffer: window not live")) + })?, )?; } diff --git a/src/lua_bindings/window_panel.rs b/src/lua_bindings/window_panel.rs index d0ee6c0..068f6c8 100644 --- a/src/lua_bindings/window_panel.rs +++ b/src/lua_bindings/window_panel.rs @@ -184,6 +184,19 @@ fn parse_request( Ok(request) } +/// The ACTING frontend's selected window. +/// +/// Not `active_window_id()`, which resolves through the ambient active +/// frontend: every other id in this module is `fid`-scoped, and the two +/// only coincide because dispatch happens to set `active_frontend` first. +fn selected_window(core: &SharedCore, fid: FrontendId) -> mlua::Result { + core.borrow() + .views + .get(&fid) + .map(|view| view.active) + .ok_or_else(|| mlua::Error::runtime("pmacs.window: acting frontend has no layout")) +} + /// Resolve a raw Lua window id, refusing one that is not live in the /// acting frontend's layout (Q#BP11). fn lookup_window(core: &SharedCore, fid: FrontendId, raw: u64) -> mlua::Result { @@ -484,7 +497,7 @@ pub(crate) fn install(lua: &Lua, core: &SharedCore, win: &Table) -> mlua::Result let fid = acting_frontend(lua, &cc); let id = match target { Some(raw) => lookup_window(&cc, fid, raw)?, - None => cc.borrow().active_window_id(), + None => selected_window(&cc, fid)?, }; let core = cc.borrow(); let window = core @@ -577,7 +590,7 @@ pub(crate) fn install(lua: &Lua, core: &SharedCore, win: &Table) -> mlua::Result let fid = acting_frontend(lua, &cc); let id = match target { Some(raw) => lookup_window(&cc, fid, raw)?, - None => cc.borrow().active_window_id(), + None => selected_window(&cc, fid)?, }; let area_rows = cc.borrow().frontend_area_rows(fid).ok_or_else(|| { mlua::Error::runtime( diff --git a/src/window.rs b/src/window.rs index f38cf53..b66499e 100644 --- a/src/window.rs +++ b/src/window.rs @@ -979,14 +979,18 @@ fn compute_node( rows } else { let w = weights.get(i).copied().unwrap_or(1).max(1); + // u64 intermediates: `remainder * w` is the only + // place this arithmetic could overflow a u32, and a + // saturating fallback there would hand a non-last + // child the whole remainder and underflow the last + // one. Widening deletes the case outright. let e = if Some(i) == last_flexible { remainder - flexible_used + } else if total == 0 { + 0 } else { - remainder - .checked_mul(w) + u32::try_from(u64::from(remainder) * u64::from(w) / u64::from(total)) .unwrap_or(remainder) - .checked_div(total) - .unwrap_or(0) }; flexible_used += e; e diff --git a/tests/bottom_panel_stage1_acceptance.rs b/tests/bottom_panel_stage1_acceptance.rs index b89ca15..73b3986 100644 --- a/tests/bottom_panel_stage1_acceptance.rs +++ b/tests/bottom_panel_stage1_acceptance.rs @@ -1300,6 +1300,55 @@ fn acc19_adopters_place_side_affinely_through_real_entry_points() { assert_ne!(panel, document); } +/// A recompile carries no `display` (only cmdline/cwd are stored), so +/// the raw switch would put `*compilation*` in the selected DOCUMENT +/// window while the panel still shows it — the duplicate presentation +/// this arc removes elsewhere. +#[test] +fn acc19b_recompile_reuses_the_panel_instead_of_duplicating_into_the_document() { + let s = editor(); + exec(&s, "pmacs.window.split_horizontal()"); + exec(&s, "pmacs.compile.run(\"true\", { display = \"panel\" })"); + let panel = side_window(&s).expect("compile opened a panel"); + let compilation = s.core.borrow().windows[&panel].buffer_id; + + // Focus a document window, then recompile — which reaches + // `start_run` with no `display` at all. + let document = s + .core + .borrow() + .non_side_target(FrontendId::LOCAL) + .expect("document"); + s.core + .borrow_mut() + .focus_window(FrontendId::LOCAL, document); + let document_buffer = s.core.borrow().windows[&document].buffer_id; + exec(&s, "pmacs.command.invoke(\"compile.recompile\")"); + + assert_eq!( + s.core.borrow().windows[&panel].buffer_id, + compilation, + "the recompile stayed in the panel" + ); + assert_eq!( + s.core.borrow().windows[&document].buffer_id, + document_buffer, + "…and did not duplicate itself into the document window" + ); + + // A compilation that is NOT in a panel keeps the pre-arc raw switch. + let s = editor(); + exec(&s, "pmacs.compile.run(\"true\")"); + assert!(side_window(&s).is_none()); + let target = active_window(&s); + exec(&s, "pmacs.command.invoke(\"compile.recompile\")"); + assert_eq!(active_window(&s), target); + assert!( + side_window(&s).is_none(), + "no panel is created out of nowhere" + ); +} + // --------------------------------------------------------------------------- // 20 / 23 — quit: delete, restore chains, revalidation, and the cap // --------------------------------------------------------------------------- @@ -1549,27 +1598,30 @@ fn acc26_close_others_and_split_respect_the_side_window() { let s = editor(); exec(&s, "pmacs.window.split_horizontal()"); let panel = open_panel(&s, "*panel*", 5); - // From a side window both are pointed errors. + // From a side window both are pointed errors — asserted through the + // REAL Lua bindings, which is what `C-x 1` / `C-x 2` / `C-x 3` + // reach. A direct `core.try_split_active(..)` call would pass even + // with the guard unwired, which is exactly how an unwired guard + // survives review. exec(&s, "pmacs.window.focus_next()"); while active_window(&s) != panel { exec(&s, "pmacs.window.focus_next()"); } - assert!(s.core.borrow_mut().close_others().is_err()); - assert!( - s.core - .borrow_mut() - .try_split_active(Orientation::Horizontal, true) - .is_err() - ); + let before = structure(&layout_root(&s)); + assert!(try_exec(&s, "pmacs.window.close_others()").is_err()); + assert!(try_exec(&s, "pmacs.window.split_horizontal()").is_err()); + assert!(try_exec(&s, "pmacs.window.split_vertical()").is_err()); assert!(side_window(&s).is_some(), "nothing was mutated"); + assert_eq!( + before, + structure(&layout_root(&s)), + "the wrapper's final child is still Leaf(side)" + ); // From a document window, close_others deletes the panel too. exec(&s, "pmacs.window.focus_next()"); assert_ne!(active_window(&s), panel); - s.core - .borrow_mut() - .close_others() - .expect("document may close others"); + exec(&s, "pmacs.window.close_others()"); assert!(side_window(&s).is_none()); assert_eq!( s.core.borrow().views[&FrontendId::LOCAL] @@ -1728,6 +1780,57 @@ fn acc30_divider_drag_writes_fixed_rows_and_weights_and_creates_no_selection() { assert!(doubled > after, "the ratio scales with the frame"); } +/// An armed drag owns the pointer for its OWN frontend only. The daemon +/// routes every attached grid frontend through one `dispatch_mouse`, so +/// an unscoped guard would let one frontend's in-flight gesture cancel +/// and swallow another frontend's clicks. +#[test] +fn acc30c_an_armed_drag_does_not_swallow_another_frontends_mouse_events() { + let mut s = editor(); + let panel = open_panel(&s, "*panel*", 6); + let document = s + .core + .borrow() + .non_side_target(FrontendId::LOCAL) + .expect("document"); + let other = FrontendId(30); + let other_window = attach_frontend(&s, other, true); + + let rects = render(&s); + let divider_row = u16::try_from(rects[&document].origin.row + rects[&document].size.rows - 1) + .expect("row fits"); + s.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::Down(MouseButton::Left), divider_row, 3), + CellSize::new(ROWS, COLS), + ); + let armed_rows = fixed_rows_of(&s, panel); + + // A click from the OTHER frontend must be dispatched normally… + s.dispatch_mouse( + other, + mouse(MouseEventKind::Down(MouseButton::Left), 1, 2), + CellSize::new(ROWS, COLS), + ); + assert_eq!( + s.core.borrow().views[&other].active, + other_window, + "the peer's click reached its own window instead of being swallowed" + ); + + // …and LOCAL's gesture must still be armed and still work. + s.dispatch_mouse( + FrontendId::LOCAL, + mouse(MouseEventKind::Drag(MouseButton::Left), divider_row + 2, 3), + CellSize::new(ROWS, COLS), + ); + assert_eq!( + fixed_rows_of(&s, panel), + Some(armed_rows.expect("armed rows") - 2), + "the peer's event did not cancel LOCAL's in-flight drag" + ); +} + #[test] fn acc30b_ui_divider_face_resolves_and_paints_every_exposed_segment() { let s = editor(); From 7e1bfb6dc59a438a8dcae7721e2bc4edc95d9385 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 24 Jul 2026 15:21:48 -0400 Subject: [PATCH 06/22] test(window): pin the terminal anchor, not the tail-relative offset `TerminalViewStatus.scroll_offset` is the retained rows between the VIEWPORT and the live tail, so it necessarily tracks viewport height: an assertion that it survives a panel height change unchanged is either vacuous or wrong, and it went red once under a loaded sweep for exactly that reason. Q#BP7's invariant is that the ANCHOR is frozen, so acc32 and acc33 now compare the first visible row's text across the change, and additionally pin the follow behavior that distinguishes them: a shrink never re-arms follow, growth reaching the tail does, and growth with a frozen selection does not. Both also wait for the child's last line before sampling, so neither races further output. Also records the round in docs/active-work.md. Co-Authored-By: Claude Opus 5 (1M context) --- docs/active-work.md | 52 +++++-- tests/bottom_panel_stage1_acceptance.rs | 193 ++++++++++++++++++------ 2 files changed, 188 insertions(+), 57 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index fe1fb19..4e493af 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -84,21 +84,49 @@ If it does not, stop and repair the remote/fetch configuration. - Adopters: `listview.open`, `compile.run`, `pmacs.terminal.open` all take `display = "current" | "panel"` (Stage 1 default `"current"`); LSP/compile visits route through `display_file`. +- **Review round 1 addressed.** The load-bearing finding: the Q#BP6 + side-window split guard (`try_split_active`) had **no production + caller** — `pmacs.window.split_horizontal` / `split_vertical`, and so + `C-x 2` / `C-x 3`, still went through plain `split_active`. Splitting a + focused panel made the root wrapper's final child a split rather than + `Leaf(side)`, which both `Layout::compute`'s fixed pass and + `document_subtree` key on. It survived the first round because the + acceptance test called the core method **directly**; it now goes + through the real Lua binding. This is the folding-arc round-2 lesson + repeating exactly: *after wiring a guard into a production hook, pin it + through the real path — a direct-call test misses the wiring.* + Also fixed: the armed divider drag was not scoped to its arming + frontend (it could cancel and swallow a peer's mouse events); a + recompile carries no `display` and duplicated a panel-placed + `*compilation*` into the document window; and + `paint_mode_line_graphemes` had lost its doc block to an insertion. + Five bite-verified fixes (three via `scripts/bite`, two by manual + revert since their tests share `src/daemon.rs` with the production + code). +- Two Stage-2 hazard pins now exist in `src/daemon.rs`, closing the gap + the review named: a fresh attach while `LOCAL` is focused in a panel + inherits `LOCAL`'s **document** buffer, and an initial-target bootstrap + whose `after-load` hook creates and selects a panel still reasserts + into a document window. - Verification on this branch: `cargo fmt --check` clean; strict - workspace Clippy clean; 1,815 default + 1,992 CRDT library tests; - `bottom_panel_stage1_acceptance` 42/42; M4 121; required GPU 152; - `gpu_initial_target_acceptance` 1 default + 14 CRDT; vterm Stage 2 4 / + workspace Clippy clean; 1,817 default + 1,994 CRDT library tests; + `bottom_panel_stage1_acceptance` 44/44; M4 121; required GPU 152; + `gpu_initial_target_acceptance` 14 CRDT; compile 67; vterm Stage 2 4 / Stage 3 5; folding Stage 2 48; statusline 7; listview 6; desktop 11; - workspace sweep 3,103 passed across 86 suites; `git diff --check` + **workspace sweep 3,128 passed, zero failures**; `git diff --check` clean. - - **The sweep's only red was the known parallel-load GPU flake**: - `font_facts_out_of_range_sizes_fail_closed`, - `built_in_only_overwide_readout_…`, and - `statusline_wire_validation_is_atomic_…` fail under a loaded - workspace run (wgpu device contention) and pass both isolated and in - the dedicated `PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu` gate. - - `compile_mode_acceptance` likewise needs `--test-threads=1` locally; - it is 67/67 there. + - Durable test lesson from this round: `TerminalViewStatus.scroll_offset` + is documented as the retained rows between **this viewport** and the + live tail, so it necessarily tracks the viewport height. Asserting it + constant across a panel height change is either vacuous or wrong — + the invariant Q#BP7 actually states is that the **anchor** is frozen, + which the acceptance now pins by comparing the first visible row's + text, plus `at_bottom` for the follow re-arm. + - `compile_mode_acceptance` needs `--test-threads=1` locally; it is + 67/67 there. The `pmacs-gpu` bin tests have historically gone red + under a loaded sweep (wgpu device contention) — they were green in + the final run, but rerun isolated before treating one as a + regression. - Stage 2 (the GPU panel band, next available protocol version) has its own re-framing obligation before implementation; Stage 3 is the default placement flip. diff --git a/tests/bottom_panel_stage1_acceptance.rs b/tests/bottom_panel_stage1_acceptance.rs index 73b3986..e413394 100644 --- a/tests/bottom_panel_stage1_acceptance.rs +++ b/tests/bottom_panel_stage1_acceptance.rs @@ -1989,33 +1989,20 @@ fn acc32_terminal_panel_height_change_is_a_viewport_change() { let buffer: pmacs::lua_bindings::BufferIdLua = eval(&s, "return TERM_BUF"); render(&s); - // Wait for output. - let deadline = std::time::Instant::now() + Duration::from_secs(5); - loop { - s.tick_processes(); - let has_output = s - .terminal_manager - .borrow() - .snapshot(buffer.0) - .is_some_and(|snap| !snap.cells.is_empty()); - if has_output || std::time::Instant::now() > deadline { - break; - } - std::thread::sleep(Duration::from_millis(20)); - } + // Wait for the child's LAST line: `scroll_offset` is tail-relative, + // so comparing it across a height change is only meaningful once the + // tail has stopped moving. + wait_for_terminal_text(&mut s, buffer.0, "line200", Duration::from_secs(10)); // Scroll back, then change the panel height. `top` is preserved // verbatim: a height change is a viewport change, never a scroll one. exec(&s, "pmacs.window.focus_next()"); let key_before = pmacs::terminal::TerminalViewKey::new(FrontendId::LOCAL, panel, buffer.0); + let before_size = CellSize::new(11, COLS); s.terminal_manager .borrow_mut() - .scroll_view(key_before, CellSize::new(6, COLS), 5); - let offset_before = s - .terminal_manager - .borrow_mut() - .view_status(key_before) - .map(|status| status.scroll_offset); + .scroll_view(key_before, before_size, 5); + let top_before = first_visible_row(&s, key_before, before_size); exec( &s, &format!( @@ -2025,15 +2012,47 @@ fn acc32_terminal_panel_height_change_is_a_viewport_change() { ); render(&s); s.sync_terminal_layout(FrontendId::LOCAL, CellSize::new(ROWS, COLS)); - let offset_after = s - .terminal_manager - .borrow_mut() - .view_status(key_before) - .map(|status| status.scroll_offset); + // The ANCHOR is the invariant. `scroll_offset` is documented as the + // rows between this VIEWPORT and the live tail, so it necessarily + // tracks the viewport height; asserting it constant would either be + // vacuous or wrong. The first visible row is `top` itself. assert_eq!( - offset_before, offset_after, + top_before, + first_visible_row(&s, key_before, CellSize::new(9, COLS)), "a scrolled-back terminal panel keeps its top across a height change" ); + assert!( + !s.terminal_manager + .borrow_mut() + .view_status(key_before) + .expect("view status") + .at_bottom, + "…and a SHRINK cannot re-arm follow" + ); + + // Growth that reaches the live tail re-arms follow (Q#BP7 case 1), + // and later output then scrolls in. + exec( + &s, + &format!( + "pmacs.window.set_params({}, {{ fixed_rows = 23 }})", + panel.raw() + ), + ); + render(&s); + s.sync_terminal_layout(FrontendId::LOCAL, CellSize::new(ROWS, COLS)); + s.terminal_manager + .borrow_mut() + .snapshot_for_view(key_before, CellSize::new(22, COLS)) + .expect("snapshot at the grown size"); + assert!( + s.terminal_manager + .borrow_mut() + .view_status(key_before) + .expect("view status") + .at_bottom, + "growth reaching the live tail re-arms follow when no selection is frozen" + ); exec(&s, "pmacs.terminal.terminate(TERM_BUF)"); } @@ -2132,6 +2151,46 @@ fn wait_for_file(path: &std::path::Path, timeout: Duration) -> Vec { } } +/// Tick until the child's screen contains `needle`, so a test that +/// compares tail-relative state is not racing further output. +/// +/// `scroll_offset` is measured FROM THE LIVE TAIL: every row the child +/// appends increases it by one while the anchor itself stays frozen. A +/// test that snapshots the offset before the child is done therefore +/// compares two different tails, not two different anchors. +fn wait_for_terminal_text(s: &mut EditorState, buffer: BufferId, needle: &str, timeout: Duration) { + let deadline = std::time::Instant::now() + timeout; + loop { + s.tick_processes(); + let seen = s + .terminal_manager + .borrow() + .snapshot(buffer) + .is_some_and(|snapshot| { + let text: String = snapshot + .cells + .iter() + .filter_map(|cell| match &cell.glyph { + Glyph::Char(ch) => Some(*ch), + Glyph::Cluster(_) => Some('?'), + Glyph::Continuation => None, + }) + .collect(); + text.contains(needle) + }); + if seen { + // One more drain so nothing is left in flight. + s.tick_processes(); + return; + } + assert!( + std::time::Instant::now() < deadline, + "timed out waiting for {needle:?} on the terminal screen" + ); + std::thread::sleep(Duration::from_millis(20)); + } +} + #[test] fn acc33_growth_with_a_historical_selection_keeps_the_anchor_frozen() { let mut s = editor(); @@ -2147,19 +2206,13 @@ fn acc33_growth_with_a_historical_selection_keeps_the_anchor_frozen() { let key_id = pmacs::terminal::TerminalViewKey::new(FrontendId::LOCAL, panel, buffer.0); let view_size = CellSize::new(5, COLS); - let deadline = std::time::Instant::now() + Duration::from_secs(5); - loop { - s.tick_processes(); - let seen = s - .terminal_manager - .borrow_mut() - .snapshot_for_view(key_id, view_size) - .is_some(); - if seen || std::time::Instant::now() > deadline { - break; - } - std::thread::sleep(Duration::from_millis(20)); - } + // Wait for the child's LAST line, so the tail is stable before the + // before/after comparison below. + wait_for_terminal_text(&mut s, buffer.0, "row59", Duration::from_secs(10)); + s.terminal_manager + .borrow_mut() + .snapshot_for_view(key_id, view_size) + .expect("the view has a snapshot once output arrived"); // Scroll back into history and start a selection there. { @@ -2167,9 +2220,9 @@ fn acc33_growth_with_a_historical_selection_keeps_the_anchor_frozen() { assert!(manager.scroll_view(key_id, view_size, 10)); assert!(manager.begin_selection(key_id, view_size, CellCoord::new(0, 0))); } - let before = s.terminal_manager.borrow_mut().view_status(key_id); + let top_before = first_visible_row(&s, key_id, view_size); - // Grow the panel enough that it would otherwise reach the live tail. + // Grow the panel enough that following the tail WOULD reach it. exec( &s, &format!( @@ -2179,15 +2232,65 @@ fn acc33_growth_with_a_historical_selection_keeps_the_anchor_frozen() { ); render(&s); s.sync_terminal_layout(FrontendId::LOCAL, CellSize::new(ROWS, COLS)); - let after = s.terminal_manager.borrow_mut().view_status(key_id); + + let grown = CellSize::new(19, COLS); + let after = s + .terminal_manager + .borrow_mut() + .view_status(key_id) + .expect("view status"); + // The anchor is what freezes — `scroll_offset` is documented as + // "physical retained rows between this VIEWPORT and the live tail", + // so it moves with the viewport height by construction even when + // `top` is preserved verbatim. Assert the anchor itself: the first + // visible row is still the same child line. assert_eq!( - before.map(|status| (status.scroll_offset, status.selection)), - after.map(|status| (status.scroll_offset, status.selection)), - "a historical selection freezes the anchor across a height change" + top_before, + first_visible_row(&s, key_id, grown), + "the anchor is frozen: growth is a viewport change, not a scroll" ); + assert!(after.selection, "the historical selection survived"); + assert!( + !after.at_bottom, + "follow is NOT re-armed while a selection is frozen" + ); + + // The contrast that makes this bite: clear the selection and the + // same geometry DOES re-arm follow. + s.terminal_manager.borrow_mut().clear_selection(key_id); + s.terminal_manager + .borrow_mut() + .snapshot_for_view(key_id, grown) + .expect("snapshot after clearing"); exec(&s, "pmacs.terminal.terminate(TERM_BUF)"); } +/// Text of the view's first visible row — the anchor, read through the +/// same per-view projection the painter uses. +fn first_visible_row( + s: &EditorState, + key_id: pmacs::terminal::TerminalViewKey, + size: CellSize, +) -> String { + let snapshot = s + .terminal_manager + .borrow_mut() + .snapshot_for_view(key_id, size) + .expect("view snapshot"); + snapshot + .cells + .iter() + .take(size.cols as usize) + .filter_map(|cell| match &cell.glyph { + Glyph::Char(ch) => Some(*ch), + Glyph::Cluster(_) => Some('?'), + Glyph::Continuation => None, + }) + .collect::() + .trim_end() + .to_owned() +} + #[test] fn acc34_only_the_controller_resizes_the_pty() { let mut s = editor(); From 4d44be5d7ba9886894b404080039d56c53cc7179 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 24 Jul 2026 18:32:23 -0400 Subject: [PATCH 07/22] fix(terminal): implement the Q#BP7 growth re-arm and pin it honestly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #155 review round 2. Finding 1 (must fix): Q#BP7 item 1 — "growth reaching the live tail re-arms follow (top -> None), only when no selection is active" — was never implemented. `at_bottom` is the instantaneous geometric readout `scroll_offset == 0`, which a still-anchored view satisfies whenever it happens to be tall enough to reach the tail, so the round-1 assertion could not see the gap: the next rows the child printed pushed the anchored view back into history. `rearm_follow_on_growth` now clears `top` when a viewport-size declaration makes the view cover the tail and no selection is frozen, and every size-declaring path (`snapshot_for_view`, `record_view_size`, `view_status_for_size`) routes through one `declare_view_size` helper so grid and semantic declarations cannot disagree. `scroll_view` and `begin_selection` deliberately stay out: they write `top` themselves, and `scroll_view` already owns the scroll-driven arm. New acc32b is the pin the review asked for: scroll into history, grow past the tail, then release a SECOND burst of child output through a filesystem gate and assert the view moved with it. Finding 2: the PTY fixtures emitted LF-only output, which staircases rightward until every row clips to blanks past the viewport width — so the round-1 anchor assertions compared "" with "" and could not fail. Both fixtures now emit CRLF, and each anchor comparison is guarded by `assert!(!top_before.is_empty())`. Finding 3: acc33's contrast case asserted nothing, and the behavior it claimed was false as coded. With the re-arm in place it is true and now asserted: clearing the selection at the same geometry re-arms follow and leaves the frozen anchor. Finding 4: `start_run` gated the panel branch on `display == "panel" or already_in_panel(..)`, so an explicit `display = "current"` lost to the inference — and that value is the documented user-facing opt-out from the Stage 3 default flip. Now gated on OMISSION. acc19b gains the explicit-"current" case. Finding 5: `window_drag` is a `HashMap`, so a peer's mode-line press can no longer steal or clear another frontend's in-flight gesture, and concurrent drags are legal. Cleared on detach. acc30c gains the mode-line-press case. Minor: `pmacs.window.buffer()` resolves both arms through the acting frontend using the shared `lookup_window` / `selected_window` validators rather than re-implementing them beside an ambient `active_buffer_id()`. Co-Authored-By: Claude Opus 5 (1M context) --- builtin/runtime/compile.lua | 8 +- src/editor.rs | 65 +++++---- src/lua_bindings/mod.rs | 41 +++--- src/lua_bindings/window_panel.rs | 8 +- src/terminal/view.rs | 59 +++++++- tests/bottom_panel_stage1_acceptance.rs | 170 +++++++++++++++++++++--- 6 files changed, 276 insertions(+), 75 deletions(-) diff --git a/builtin/runtime/compile.lua b/builtin/runtime/compile.lua index 2e894d2..544c1d7 100644 --- a/builtin/runtime/compile.lua +++ b/builtin/runtime/compile.lua @@ -836,7 +836,13 @@ local function start_run(slot, cmdline, opts) -- the selected DOCUMENT window while the panel still shows it — the -- duplicate presentation this arc removes elsewhere. Detect that the -- buffer already owns the panel slot and keep it there. - if display == "panel" or already_in_panel(slot.buf) then + -- + -- Gated on OMISSION, never on an explicit value: `display = "current"` + -- is the documented user-facing opt-out from the Stage 3 default flip, + -- so it must reach the raw switch even when the previous run was + -- panel-placed. The duplicate presentation that produces is the + -- escape hatch's documented cost (R3-rp2). + if display == "panel" or (display == nil and already_in_panel(slot.buf)) then pmacs.window.display(slot.buf, { side = "bottom", select = false }) else pmacs.window.switch_buffer(slot.buf) diff --git a/src/editor.rs b/src/editor.rs index 246a575..79f1225 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -161,11 +161,16 @@ pub struct EditorState { /// Last left-button down event, used to synthesize terminal double /// clicks from crossterm's plain Down/Up mouse event stream. mouse_click: Option, - /// In-progress split-boundary drag (bottom-panel arc, Q#BP5), armed + /// In-progress split-boundary drags (bottom-panel arc, Q#BP5), armed /// by a left press on a mode-line row that is an exposed segment of a - /// horizontal boundary. Lives beside `mouse_click`; selection is - /// untouched for the whole gesture. - window_drag: Option, + /// horizontal boundary. Selection is untouched for the whole gesture. + /// + /// Keyed by frontend, unlike the older global `mouse_click` slot: the + /// daemon routes every attached grid frontend through one + /// `dispatch_mouse`, so a single slot would let one frontend's press + /// steal or clear another's in-flight gesture, and concurrent drags + /// are perfectly legal. + window_drag: HashMap, } #[derive(Default)] @@ -220,7 +225,6 @@ struct MouseClickState { /// mutation mid-drag cannot move a boundary that no longer exists. #[derive(Copy, Clone)] struct WindowDragState { - frontend_id: FrontendId, owner: WindowId, last_row: u32, } @@ -619,7 +623,7 @@ impl EditorState { snippets, statusline_registry, mouse_click: None, - window_drag: None, + window_drag: HashMap::new(), } } @@ -875,6 +879,9 @@ impl EditorState { /// Drop one detached frontend's pending key and terminal escape state. pub fn detach_frontend_input(&mut self, frontend_id: FrontendId) { self.dispatchers.remove(&frontend_id); + // A detached frontend cannot finish a divider gesture, and its + // `owner` window is about to stop being live (Q#BP5). + self.window_drag.remove(&frontend_id); self.terminal_manager .borrow_mut() .detach_frontend(frontend_id); @@ -1930,17 +1937,16 @@ impl EditorState { // grid frontend through this same dispatcher, so an unscoped // check would let one frontend's in-flight drag cancel and // swallow another frontend's clicks. - if self - .window_drag - .is_some_and(|drag| drag.frontend_id == frontend_id) - { + if self.window_drag.contains_key(&frontend_id) { match ev.kind { MouseEventKind::Drag(MouseButton::Left) => { self.drag_window_boundary(frontend_id, cell_row, term_size); } // Any other event — release, a different button, a - // wheel notch — ends the gesture. - _ => self.window_drag = None, + // wheel notch — ends THIS frontend's gesture only. + _ => { + self.window_drag.remove(&frontend_id); + } } return; } @@ -2076,11 +2082,20 @@ impl EditorState { .views .get(&frontend_id) .is_some_and(|view| view.layout.boundary_below(owner).is_some()); - self.window_drag = is_divider.then_some(WindowDragState { - frontend_id, - owner, - last_row: cell_row, - }); + // Only this frontend's slot is written, and only its own press + // can clear it — a peer pressing some other window's mode line + // must not disarm an in-flight gesture here. + if is_divider { + self.window_drag.insert( + frontend_id, + WindowDragState { + owner, + last_row: cell_row, + }, + ); + } else { + self.window_drag.remove(&frontend_id); + } } /// Continue an armed divider drag (Q#BP5). @@ -2095,16 +2110,16 @@ impl EditorState { cell_row: u32, term_size: CellSize, ) { - let Some(drag) = self.window_drag else { + let Some(drag) = self.window_drag.get(&frontend_id).copied() else { return; }; - if drag.frontend_id != frontend_id { - return; - } - self.window_drag = Some(WindowDragState { - last_row: cell_row, - ..drag - }); + self.window_drag.insert( + frontend_id, + WindowDragState { + last_row: cell_row, + ..drag + }, + ); let delta = i64::from(cell_row) - i64::from(drag.last_row); let Ok(delta) = i32::try_from(delta) else { return; diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index 959edbe..0d585ea 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -12365,30 +12365,23 @@ fn install_window_module(lua: &Lua, core: &SharedCore) -> mlua::Result
{ // panel" without first selecting the panel. win.set( "buffer", - lua.create_function(move |lua, target: Option| -> mlua::Result { - let Some(raw) = target else { - return Ok(BufferIdLua(cc.borrow().active_buffer_id())); - }; - let fid = window_panel::acting_frontend(lua, &cc); - let core = cc.borrow(); - let view = core.views.get(&fid).ok_or_else(|| { - mlua::Error::runtime("pmacs.window.buffer: acting frontend has no layout") - })?; - let id = view - .layout - .iter_ids() - .into_iter() - .find(|id| id.raw() == raw) - .ok_or_else(|| { - mlua::Error::runtime(format!( - "pmacs.window.buffer: window {raw} is not live in this frontend's layout" - )) - })?; - core.windows - .get(&id) - .map(|window| BufferIdLua(window.buffer_id)) - .ok_or_else(|| mlua::Error::runtime("pmacs.window.buffer: window not live")) - })?, + lua.create_function( + move |lua, target: Option| -> mlua::Result { + // Both arms resolve through the ACTING frontend, using + // the same validator the rest of the window surface + // does — no ambient `active_buffer_id()` asymmetry. + let fid = window_panel::acting_frontend(lua, &cc); + let id = match target { + Some(raw) => window_panel::lookup_window(&cc, fid, raw)?, + None => window_panel::selected_window(&cc, fid)?, + }; + cc.borrow() + .windows + .get(&id) + .map(|window| BufferIdLua(window.buffer_id)) + .ok_or_else(|| mlua::Error::runtime("pmacs.window.buffer: window not live")) + }, + )?, )?; } diff --git a/src/lua_bindings/window_panel.rs b/src/lua_bindings/window_panel.rs index 068f6c8..f4833ef 100644 --- a/src/lua_bindings/window_panel.rs +++ b/src/lua_bindings/window_panel.rs @@ -189,7 +189,7 @@ fn parse_request( /// Not `active_window_id()`, which resolves through the ambient active /// frontend: every other id in this module is `fid`-scoped, and the two /// only coincide because dispatch happens to set `active_frontend` first. -fn selected_window(core: &SharedCore, fid: FrontendId) -> mlua::Result { +pub(crate) fn selected_window(core: &SharedCore, fid: FrontendId) -> mlua::Result { core.borrow() .views .get(&fid) @@ -199,7 +199,11 @@ fn selected_window(core: &SharedCore, fid: FrontendId) -> mlua::Result /// Resolve a raw Lua window id, refusing one that is not live in the /// acting frontend's layout (Q#BP11). -fn lookup_window(core: &SharedCore, fid: FrontendId, raw: u64) -> mlua::Result { +pub(crate) fn lookup_window( + core: &SharedCore, + fid: FrontendId, + raw: u64, +) -> mlua::Result { let core = core.borrow(); let view = core .views diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 5d9d311..1c0957d 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -132,8 +132,7 @@ impl TerminalManager { last_bell_count: bell_count, ..TerminalViewState::default() }); - normalize_state(state, projection); - state.viewport_size = Some(viewport_size); + declare_view_size(state, projection, viewport_size); Some(project_snapshot( key.buffer_id, viewport_size, @@ -234,8 +233,7 @@ impl TerminalManager { last_bell_count: bell_count, ..TerminalViewState::default() }); - normalize_state(state, projection); - state.viewport_size = Some(viewport_size); + declare_view_size(state, projection, viewport_size); let rows = retained_rows(projection); let geometry = view_geometry(&rows, state, viewport_size.rows); Some(TerminalViewStatus { @@ -289,8 +287,7 @@ impl TerminalManager { last_bell_count: bell_count, ..TerminalViewState::default() }); - normalize_state(state, projection); - state.viewport_size = Some(viewport_size); + declare_view_size(state, projection, viewport_size); true } @@ -595,6 +592,56 @@ fn clamp_or_clear(rows: &RetainedRows<'_>, anchor: LogicalCellAnchor) -> Option< .then(|| row_lead(first)) } +/// The shared viewport-size declaration path (bottom-panel arc, Q#BP7). +/// +/// Normalize, then re-arm live-tail following when the newly declared +/// viewport reaches the tail, then record the size. Every path that +/// *declares* a size routes through here so grid and semantic +/// declarations cannot disagree; `scroll_view` and `begin_selection` +/// deliberately do not, because they write `top` themselves. +fn declare_view_size( + state: &mut TerminalViewState, + projection: BorrowedScreenProjection<'_>, + viewport_size: CellSize, +) { + normalize_state(state, projection); + rearm_follow_on_growth(state, projection, viewport_size.rows); + state.viewport_size = Some(viewport_size); +} + +/// Q#BP7 item 1: **growth reaching the live tail re-arms follow.** +/// +/// A height change is a viewport change, never a scroll change — `top` +/// is preserved verbatim — but once a taller viewport covers the tail, +/// staying anchored would leave the view frozen just short of the live +/// output while `at_bottom` reported `true`: `at_bottom` is the +/// instantaneous geometric readout `scroll_offset == 0`, so it cannot +/// distinguish "following" from "anchored, and currently tall enough to +/// reach". The next rows the child prints would then push the anchored +/// view back into history with nothing to explain it. +/// +/// **Only when no selection is active** (R1-8): a historical selection +/// froze this anchor on purpose, and growth must not yank the user's +/// region out from under them. `scroll_view` already handles the +/// scroll-driven arm (`next == tail_start`), so during ordinary +/// scrolling `scroll_offset == 0` implies follow is already armed — +/// which makes this rule fire on exactly the growth (and shrink-back) +/// case it names, and be idempotent everywhere else. +fn rearm_follow_on_growth( + state: &mut TerminalViewState, + projection: BorrowedScreenProjection<'_>, + viewport_rows: u32, +) { + if state.top.is_none() || state.selection.is_some() || viewport_rows == 0 { + return; + } + let rows = retained_rows(projection); + if view_geometry(&rows, state, viewport_rows).scroll_offset == 0 { + state.top = None; + state.selection_froze_top = false; + } +} + fn normalize_state(state: &mut TerminalViewState, projection: BorrowedScreenProjection<'_>) { if state .alternate_active diff --git a/tests/bottom_panel_stage1_acceptance.rs b/tests/bottom_panel_stage1_acceptance.rs index e413394..f9acea1 100644 --- a/tests/bottom_panel_stage1_acceptance.rs +++ b/tests/bottom_panel_stage1_acceptance.rs @@ -1336,6 +1336,26 @@ fn acc19b_recompile_reuses_the_panel_instead_of_duplicating_into_the_document() "…and did not duplicate itself into the document window" ); + // An EXPLICIT `display = "current"` still wins over the inference: + // it is the documented user-facing opt-out from the Stage 3 default + // flip, so it must reach the raw switch even while the panel holds + // this buffer. The resulting duplicate presentation is the escape + // hatch's documented cost (R3-rp2). + s.core + .borrow_mut() + .focus_window(FrontendId::LOCAL, document); + exec(&s, "pmacs.compile.run(\"true\", { display = \"current\" })"); + assert_eq!( + s.core.borrow().windows[&document].buffer_id, + compilation, + "explicit \"current\" reached the raw switch" + ); + assert_eq!( + s.core.borrow().windows[&panel].buffer_id, + compilation, + "…and the panel still holds it too — the escape hatch's cost" + ); + // A compilation that is NOT in a panel keeps the pre-arc raw switch. let s = editor(); exec(&s, "pmacs.compile.run(\"true\")"); @@ -1818,6 +1838,15 @@ fn acc30c_an_armed_drag_does_not_swallow_another_frontends_mouse_events() { "the peer's click reached its own window instead of being swallowed" ); + // …a peer press on a MODE-LINE row must not steal or clear the slot + // either — that press reaches the arming path, which a single global + // slot would let it overwrite. + s.dispatch_mouse( + other, + mouse(MouseEventKind::Down(MouseButton::Left), divider_row, 4), + CellSize::new(ROWS, COLS), + ); + // …and LOCAL's gesture must still be armed and still work. s.dispatch_mouse( FrontendId::LOCAL, @@ -1827,7 +1856,7 @@ fn acc30c_an_armed_drag_does_not_swallow_another_frontends_mouse_events() { assert_eq!( fixed_rows_of(&s, panel), Some(armed_rows.expect("armed rows") - 2), - "the peer's event did not cancel LOCAL's in-flight drag" + "the peer's events did not cancel or steal LOCAL's in-flight drag" ); } @@ -1981,8 +2010,14 @@ fn acc32_terminal_panel_height_change_is_a_viewport_change() { let mut s = editor(); exec( &s, + // `printf '...\\r\\n'`, not `echo`: a PTY in the default mode + // does not translate LF to CRLF for us, so LF-only output + // staircases rightward and every row past the viewport width + // clips to blanks — which would make the anchor assertions below + // compare "" with "" and pass for any regression. "TERM_BUF = pmacs.terminal.open { command = \"/bin/sh\", \ - args = { \"-c\", \"for i in $(seq 1 200); do echo line$i; done; sleep 30\" }, \ + args = { \"-c\", \"i=1; while [ $i -le 200 ]; do printf 'line%d\\\\r\\\\n' $i; \ + i=$((i+1)); done; sleep 30\" }, \ display = \"panel\" }", ); let panel = side_window(&s).expect("terminal panel"); @@ -2001,8 +2036,13 @@ fn acc32_terminal_panel_height_change_is_a_viewport_change() { let before_size = CellSize::new(11, COLS); s.terminal_manager .borrow_mut() - .scroll_view(key_before, before_size, 5); + .scroll_view(key_before, before_size, 30); let top_before = first_visible_row(&s, key_before, before_size); + assert!( + !top_before.is_empty(), + "the anchor row must carry real text, or the equality below \ + cannot fail for the regression it names" + ); exec( &s, &format!( @@ -2029,9 +2069,65 @@ fn acc32_terminal_panel_height_change_is_a_viewport_change() { .at_bottom, "…and a SHRINK cannot re-arm follow" ); + exec(&s, "pmacs.terminal.terminate(TERM_BUF)"); +} - // Growth that reaches the live tail re-arms follow (Q#BP7 case 1), - // and later output then scrolls in. +/// Q#BP7 item 1 proper: **growth reaching the live tail re-arms follow**, +/// so later output scrolls in. +/// +/// `at_bottom` alone cannot pin this — it is the instantaneous geometric +/// readout `scroll_offset == 0`, which a still-anchored view satisfies +/// whenever it happens to be tall enough to reach the tail. The pin has +/// to feed the child MORE output after the growth and assert the view +/// moved with it. +#[test] +fn acc32b_growth_reaching_the_tail_re_arms_follow_and_later_output_scrolls_in() { + let dir = tempfile::tempdir().expect("tempdir"); + let gate = dir.path().join("gate"); + // Inserted bare into the shell word: `tempfile` paths carry no + // spaces or quotes, and wrapping it would terminate the Lua string. + let gate_path = gate.display().to_string(); + let mut s = editor(); + // Two bursts with a filesystem gate between them, so "more output + // after the growth" is deterministic rather than a race. + exec( + &s, + &format!( + "TERM_BUF = pmacs.terminal.open {{ command = \"/bin/sh\", \ + args = {{ \"-c\", \"i=1; while [ $i -le 60 ]; do printf 'first%02d\\\\r\\\\n' $i; \ + i=$((i+1)); done; \ + while [ ! -f {gate_path} ]; do sleep 0.02; done; \ + i=1; while [ $i -le 40 ]; do printf 'second%02d\\\\r\\\\n' $i; \ + i=$((i+1)); done; sleep 30\" }}, \ + display = \"panel\" }}" + ), + ); + let panel = side_window(&s).expect("terminal panel"); + let buffer: pmacs::lua_bindings::BufferIdLua = eval(&s, "return TERM_BUF"); + let key_id = pmacs::terminal::TerminalViewKey::new(FrontendId::LOCAL, panel, buffer.0); + render(&s); + wait_for_terminal_text(&mut s, buffer.0, "first60", Duration::from_secs(10)); + + // Scroll back into history at a short viewport. + let short = CellSize::new(6, COLS); + assert!( + s.terminal_manager + .borrow_mut() + .scroll_view(key_id, short, 20) + ); + let anchored = first_visible_row(&s, key_id, short); + assert!(!anchored.is_empty(), "the anchor row carries real text"); + assert!( + s.terminal_manager + .borrow_mut() + .view_status(key_id) + .expect("status") + .scroll_offset + > 0, + "the view really is anchored in history" + ); + + // Grow the panel until the viewport covers the tail. exec( &s, &format!( @@ -2041,17 +2137,36 @@ fn acc32_terminal_panel_height_change_is_a_viewport_change() { ); render(&s); s.sync_terminal_layout(FrontendId::LOCAL, CellSize::new(ROWS, COLS)); + let grown = CellSize::new(40, COLS); s.terminal_manager .borrow_mut() - .snapshot_for_view(key_before, CellSize::new(22, COLS)) + .snapshot_for_view(key_id, grown) .expect("snapshot at the grown size"); - assert!( - s.terminal_manager - .borrow_mut() - .view_status(key_before) - .expect("view status") - .at_bottom, - "growth reaching the live tail re-arms follow when no selection is frozen" + + // Release the second burst. A view that merely LOOKS at-bottom while + // still anchored gets pushed back into history here; a re-armed one + // follows. + std::fs::write(&gate, b"go").expect("open the gate"); + wait_for_terminal_text(&mut s, buffer.0, "second40", Duration::from_secs(10)); + s.terminal_manager + .borrow_mut() + .snapshot_for_view(key_id, grown) + .expect("snapshot after the second burst"); + + let status = s + .terminal_manager + .borrow_mut() + .view_status(key_id) + .expect("status"); + assert_eq!( + status.scroll_offset, 0, + "the view followed the live tail through the new output" + ); + assert!(status.at_bottom); + assert_ne!( + anchored, + first_visible_row(&s, key_id, grown), + "…and its first visible row moved off the old anchor" ); exec(&s, "pmacs.terminal.terminate(TERM_BUF)"); } @@ -2197,7 +2312,7 @@ fn acc33_growth_with_a_historical_selection_keeps_the_anchor_frozen() { exec( &s, "TERM_BUF = pmacs.terminal.open { command = \"/bin/sh\", \ - args = { \"-c\", \"i=0; while [ $i -lt 60 ]; do printf 'row%02d\\\\n' $i; \ + args = { \"-c\", \"i=0; while [ $i -lt 60 ]; do printf 'row%02d\\\\r\\\\n' $i; \ i=$((i+1)); done; sleep 30\" }, \ display = \"panel\" }", ); @@ -2221,6 +2336,11 @@ fn acc33_growth_with_a_historical_selection_keeps_the_anchor_frozen() { assert!(manager.begin_selection(key_id, view_size, CellCoord::new(0, 0))); } let top_before = first_visible_row(&s, key_id, view_size); + assert!( + !top_before.is_empty(), + "the anchor row must carry real text, or the equality below \ + cannot fail for the regression it names" + ); // Grow the panel enough that following the tail WOULD reach it. exec( @@ -2255,13 +2375,29 @@ fn acc33_growth_with_a_historical_selection_keeps_the_anchor_frozen() { "follow is NOT re-armed while a selection is frozen" ); - // The contrast that makes this bite: clear the selection and the - // same geometry DOES re-arm follow. - s.terminal_manager.borrow_mut().clear_selection(key_id); + // The contrast that makes this bite: the freeze is owed to the + // SELECTION, so clearing it lets the next size declaration re-arm + // follow at the very same geometry. Without this, "no re-arm while + // selected" would also hold if the re-arm simply did not exist. + assert!(s.terminal_manager.borrow_mut().clear_selection(key_id)); s.terminal_manager .borrow_mut() .snapshot_for_view(key_id, grown) .expect("snapshot after clearing"); + let cleared = s + .terminal_manager + .borrow_mut() + .view_status(key_id) + .expect("view status after clearing"); + assert!( + cleared.at_bottom && cleared.scroll_offset == 0, + "clearing the selection re-arms follow at the same geometry" + ); + assert_ne!( + top_before, + first_visible_row(&s, key_id, grown), + "…and the view left the frozen anchor" + ); exec(&s, "pmacs.terminal.terminate(TERM_BUF)"); } From 52e7598da03603cf7ab61eadb51e1acb016d859c Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 24 Jul 2026 18:37:10 -0400 Subject: [PATCH 08/22] test(window): press the peer's own mode line in acc30c MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The round-2 peer press landed in the peer's CONTENT area, so it never reached `arm_window_drag` — the exact path Finding 5 names — and the case bit nothing. It now presses the peer's own mode-line row, where a single global drag slot is overwritten (and, since that lone window owns no boundary, cleared outright). Co-Authored-By: Claude Opus 5 (1M context) --- tests/bottom_panel_stage1_acceptance.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/bottom_panel_stage1_acceptance.rs b/tests/bottom_panel_stage1_acceptance.rs index f9acea1..e358351 100644 --- a/tests/bottom_panel_stage1_acceptance.rs +++ b/tests/bottom_panel_stage1_acceptance.rs @@ -1838,12 +1838,15 @@ fn acc30c_an_armed_drag_does_not_swallow_another_frontends_mouse_events() { "the peer's click reached its own window instead of being swallowed" ); - // …a peer press on a MODE-LINE row must not steal or clear the slot - // either — that press reaches the arming path, which a single global - // slot would let it overwrite. + // …a peer press on ITS OWN mode-line row must not steal or clear the + // slot either. That press reaches `arm_window_drag`, which a single + // global slot lets it overwrite — and the peer's lone window owns no + // boundary, so the write is an outright clear. The peer's mode line + // is the last row of its own single-window layout. + let peer_mode_line = u16::try_from(AREA_ROWS - 1).expect("row fits"); s.dispatch_mouse( other, - mouse(MouseEventKind::Down(MouseButton::Left), divider_row, 4), + mouse(MouseEventKind::Down(MouseButton::Left), peer_mode_line, 4), CellSize::new(ROWS, COLS), ); From bbe41520482b9a59afcc57ca93414353ac4df62e Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 24 Jul 2026 18:42:04 -0400 Subject: [PATCH 09/22] docs: record bottom-panel review round 2 Co-Authored-By: Claude Opus 5 (1M context) --- docs/active-work.md | 40 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 4e493af..f462c16 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -108,9 +108,38 @@ If it does not, stop and repair the remote/fetch configuration. inherits `LOCAL`'s **document** buffer, and an initial-target bootstrap whose `after-load` hook creates and selects a panel still reasserts into a document window. +- **Review round 2 addressed.** The load-bearing finding: **Q#BP7 item 1 + — "growth reaching the live tail re-arms follow" — was never + implemented.** `at_bottom` is the instantaneous geometric readout + `scroll_offset == 0`, which a still-anchored view satisfies whenever it + is momentarily tall enough to reach the tail, so the round-1 assertion + could not see the gap: the next rows the child printed pushed the + anchored view back into history. `src/terminal/view.rs` now has + `rearm_follow_on_growth`, reached by one shared `declare_view_size` + helper from every size-declaring path (`snapshot_for_view`, + `record_view_size`, `view_status_for_size`) so grid and semantic + declarations cannot disagree. + Also fixed: the PTY fixtures emitted LF-only output, which staircases + until every row clips to blanks — so the anchor assertions compared + `""` with `""` and could not fail (now CRLF, each guarded by + `assert!(!top_before.is_empty())`); acc33's contrast case asserted + nothing; `start_run` let `already_in_panel` override an **explicit** + `display = "current"`, which is the documented opt-out from the Stage 3 + flip (now gated on omission); and `window_drag` was a daemon-global + slot that a peer's mode-line press could clear. +- Durable test lessons from this round, both the same class: + 1. **A geometric readout is not a state predicate.** `at_bottom` says + "the viewport currently reaches the tail", not "this view follows + the tail". Pinning follow requires feeding MORE output and asserting + the view moved (acc32b uses a filesystem gate between two bursts). + 2. **A PTY in the default mode does not translate LF to CRLF.** An + `echo`-driven fixture staircases rightward and clips to blanks past + the viewport width, so any text equality over it is vacuously true. + Emit `\r\n`, and guard text comparisons with a non-empty assertion + the way the daemon pin guards on `!panel_hidden`. - Verification on this branch: `cargo fmt --check` clean; strict workspace Clippy clean; 1,817 default + 1,994 CRDT library tests; - `bottom_panel_stage1_acceptance` 44/44; M4 121; required GPU 152; + `bottom_panel_stage1_acceptance` 45/45; vterm Stage 1 9; M4 121; required GPU 152; `gpu_initial_target_acceptance` 14 CRDT; compile 67; vterm Stage 2 4 / Stage 3 5; folding Stage 2 48; statusline 7; listview 6; desktop 11; **workspace sweep 3,128 passed, zero failures**; `git diff --check` @@ -123,9 +152,12 @@ If it does not, stop and repair the remote/fetch configuration. which the acceptance now pins by comparing the first visible row's text, plus `at_bottom` for the follow re-arm. - `compile_mode_acceptance` needs `--test-threads=1` locally; it is - 67/67 there. The `pmacs-gpu` bin tests have historically gone red - under a loaded sweep (wgpu device contention) — they were green in - the final run, but rerun isolated before treating one as a + 67/67 there. Under default parallelism it fails roughly 1 run in 3, + with a *different* test each time (acc14/acc25a, then acc24) — + **verified pre-existing** by swapping in `githubsucks/main`'s + `builtin/runtime/compile.lua` and reproducing the same rate. The + `pmacs-gpu` bin tests have historically gone red under a loaded sweep + (wgpu device contention). Rerun isolated before treating either as a regression. - Stage 2 (the GPU panel band, next available protocol version) has its own re-framing obligation before implementation; Stage 3 is the default From 9110f9f32cf9adef4f3afb732db4db0127b8ec70 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 24 Jul 2026 20:05:51 -0400 Subject: [PATCH 10/22] fix(window): keep pmacs.window.buffer() infallible with no argument MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #155 review round 2, self-review of the round-2 commit. The round-2 change labelled "minor" — resolving both arms of pmacs.window.buffer() through the acting frontend for uniformity — made the NO-ARGUMENT arm fallible. `acting_frontend` follows the interactive origin, which can name a frontend that has no registered view: a bare `dispatch_key` from an unattached peer does exactly that. `selected_window` then raises "acting frontend has no layout" instead of answering. Nothing surfaced that error, because the runtime callers do not pcall it. killring, syntax, autosave, pair, indent and comment all read pmacs.window.buffer() on ordinary edits, so the raise silently dropped the operation: kill_ring_acceptance went 30/30 to 25/5, with frontend_detached_drops_per_frontend_state reporting only "B has kill state". main is 30/30, and reverting this one file restored it. The no-arg arm is back on ambient active_buffer_id() and now documents why that is deliberate rather than an oversight: dispatch sets active_frontend to the acting frontend before running a command, so the two agree on every real path, while only the ambient resolver has the fallback that makes it total. The explicit-window arm keeps its Q#BP11 layout validation, which is what the arc actually needed. acc19c pins it through the real path — a buffer.after-edit subscriber reading pmacs.window.buffer() during a viewless peer's dispatch_key — rather than by calling the binding directly. Bite-verified: scripts/bite bbe4152 src/lua_bindings/mod.rs --test bottom_panel_stage1_acceptance -- acc19c goes red with the exact "acting frontend has no layout" traceback. The ledger also records two gating facts found on the way: the workspace sweep must run with an isolated XDG_CONFIG_HOME, because the real user init.lua installs a local package and the losing race leaks a status message into painted-frame comparisons; and a latent pre-existing main bug in the buffer CRDT undo path, which is not this branch's and whose proptest seed is deliberately not committed here. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012j4omtTMn9v1UfmHQb9ap6 --- docs/active-work.md | 49 ++++++++++++++++++++--- src/lua_bindings/mod.rs | 30 +++++++++----- tests/bottom_panel_stage1_acceptance.rs | 52 +++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 15 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index f462c16..fb835a6 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -137,13 +137,52 @@ If it does not, stop and repair the remote/fetch configuration. the viewport width, so any text equality over it is vacuously true. Emit `\r\n`, and guard text comparisons with a non-empty assertion the way the daemon pin guards on `!panel_hidden`. +- **Round-2 self-review caught a regression the round-2 commit + introduced**, in the change it labelled "minor": routing + `pmacs.window.buffer()`'s **no-argument** arm through the fid-scoped + `selected_window` validator made it **fallible**, and + `acting_frontend` can name a frontend with **no registered view** (a + bare `dispatch_key` from an unattached peer does exactly that). The + runtime calls that function on ordinary edits from `killring`, + `syntax`, `autosave`, `pair`, `indent` and `comment` **without + `pcall`**, so the raise never surfaced as an error — it silently + dropped the operation. `kill_ring_acceptance` went 30/30 → 25/5 + (`frontend_detached_drops_per_frontend_state`: "B has kill state"). + The no-arg arm is back on ambient `active_buffer_id()` and documented + as deliberately infallible; the explicit-window arm keeps its Q#BP11 + validation. New **acc19c** pins it through the real path (a + `buffer.after-edit` subscriber during a viewless peer's `dispatch_key`) + and bites against the regressing commit. + Generalizes: **a "uniformity" cleanup that changes a function's + fallibility is not minor** — check every caller's error discipline + first, and remember that an ambient resolver's fallback IS its + contract. - Verification on this branch: `cargo fmt --check` clean; strict workspace Clippy clean; 1,817 default + 1,994 CRDT library tests; - `bottom_panel_stage1_acceptance` 45/45; vterm Stage 1 9; M4 121; required GPU 152; - `gpu_initial_target_acceptance` 14 CRDT; compile 67; vterm Stage 2 4 / - Stage 3 5; folding Stage 2 48; statusline 7; listview 6; desktop 11; - **workspace sweep 3,128 passed, zero failures**; `git diff --check` - clean. + `bottom_panel_stage1_acceptance` 46/46; kill ring 30 default + 30 CRDT; + vterm Stage 1 9 default + 10 CRDT; M4 121; required GPU 152; + compile 67; vterm Stage 2 4 / Stage 3 5 (7 CRDT); folding Stage 2 48; + statusline 7; listview 6; + **isolated-config workspace sweep 3,130 passed across 89 suites, zero + failures**; `git diff --check` clean. + - **Run the sweep with an isolated `XDG_CONFIG_HOME`.** The real + `~/.config/pmacs/init.lua` on this desktop calls + `pmacs.packages.install_local(...)`, so every editor the sweep builds + races on one shared install root; a losing race sets a status message + that leaks into the mode line and breaks + `folding_stage2_acceptance::unfolded_frame_is_identical_to_the_pre_folding_baseline`, + which compares whole painted frames. Standalone it is 48/48. This + generalizes the known `compile_mode_acceptance` real-config trap: + any suite that paints the status area inherits it. + - **A latent pre-existing `main` bug surfaced while gating and is NOT + this branch's**: `buffer::tests::proptests::rope_matches_crdt_projection_after_arbitrary_edits` + fails on `main` @ `352bf0b` with `ops = [Insert(0,"a"), + Insert(0,"aaa"), Replace(0,1,"a"), Undo]` — undo of a textually-null + `Replace` returns a no-op edit result still carrying `crdt_op = + Some`, violating the suite's own shape invariant. `src/buffer.rs` is + byte-identical here, and the seed was deliberately **not** committed + (it would make an unrelated failure deterministically red on this + PR). Needs its own lane. - Durable test lesson from this round: `TerminalViewStatus.scroll_offset` is documented as the retained rows between **this viewport** and the live tail, so it necessarily tracks the viewport height. Asserting it diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index 0d585ea..29e3b47 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -12357,9 +12357,9 @@ fn install_window_module(lua: &Lua, core: &SharedCore) -> mlua::Result
{ { let cc = core.clone(); - // With no argument: the selected window's buffer (unchanged). - // With an explicit window id: that window's buffer, validated - // against the acting frontend's layout like every other + // With no argument: the ambient active buffer, exactly as before + // this arc. With an explicit window id: that window's buffer, + // validated against the acting frontend's layout like every other // `WindowId`-taking operation (bottom-panel arc, Q#BP11) — an // adopter has to be able to ask "is my buffer the one in the // panel" without first selecting the panel. @@ -12367,14 +12367,24 @@ fn install_window_module(lua: &Lua, core: &SharedCore) -> mlua::Result
{ "buffer", lua.create_function( move |lua, target: Option| -> mlua::Result { - // Both arms resolve through the ACTING frontend, using - // the same validator the rest of the window surface - // does — no ambient `active_buffer_id()` asymmetry. - let fid = window_panel::acting_frontend(lua, &cc); - let id = match target { - Some(raw) => window_panel::lookup_window(&cc, fid, raw)?, - None => window_panel::selected_window(&cc, fid)?, + // The no-arg arm deliberately stays on ambient + // `active_buffer_id()`, and stays INFALLIBLE. This is not + // the asymmetry it looks like: dispatch sets + // `active_frontend` to the acting frontend before running a + // command, so the two agree on every real path — while + // `acting_frontend` can additionally name a frontend that + // has no registered view, where a `views`-keyed lookup + // raises instead of answering. `killring`, `syntax`, + // `autosave`, `pair`, `indent` and `comment` all call this + // on ordinary edits without `pcall`, so a raise here does + // not surface as an error — it silently drops the + // operation (it lost a whole kill in `kill_ring_acceptance` + // when this arm was routed through `selected_window`). + let Some(raw) = target else { + return Ok(BufferIdLua(cc.borrow().active_buffer_id())); }; + let fid = window_panel::acting_frontend(lua, &cc); + let id = window_panel::lookup_window(&cc, fid, raw)?; cc.borrow() .windows .get(&id) diff --git a/tests/bottom_panel_stage1_acceptance.rs b/tests/bottom_panel_stage1_acceptance.rs index e358351..fb3a4ad 100644 --- a/tests/bottom_panel_stage1_acceptance.rs +++ b/tests/bottom_panel_stage1_acceptance.rs @@ -1369,6 +1369,58 @@ fn acc19b_recompile_reuses_the_panel_instead_of_duplicating_into_the_document() ); } +/// `pmacs.window.buffer()` with NO argument must stay **infallible**. +/// +/// The optional window argument this arc added is validated against the +/// acting frontend's layout, and it is tempting to make the no-arg arm +/// symmetric by resolving it the same way. That silently breaks the +/// runtime: `acting_frontend` follows the interactive origin, which can +/// name a frontend with **no registered view** (as a bare +/// `dispatch_key` from a peer does), where a `views`-keyed lookup raises +/// instead of answering — and `killring`, `syntax`, `autosave`, `pair`, +/// `indent` and `comment` all call this on ordinary edits without +/// `pcall`, so the raise does not surface as an error, it just drops the +/// operation. Routing it through `selected_window` lost an entire kill in +/// `kill_ring_acceptance`. +#[test] +fn acc19c_window_buffer_stays_infallible_for_an_acting_frontend_without_a_view() { + let mut s = editor(); + let ambient = s.core.borrow().active_buffer_id(); + exec( + &s, + // A `buffer.after-edit` subscriber is the real shape: this is + // where syntax.lua, pair.lua and comment.lua each call + // `pmacs.window.buffer()` on every ordinary edit. + "SEEN = nil; ERR = nil \ + pmacs.hook.add(\"buffer.after-edit\", function() \ + local ok, got = pcall(pmacs.window.buffer) \ + if ok then SEEN = got else ERR = tostring(got) end \ + end)", + ); + + // A peer that never registered a view — the shape `dispatch_key` + // produces for an unattached frontend, and what the kill-ring suite + // drives with `ctrl_as`. + let viewless = FrontendId(9); + assert!( + !s.core.borrow().views.contains_key(&viewless), + "the premise: this frontend really has no view" + ); + s.dispatch_key(viewless, key(KeyCode::Char('z'), KeyModifiers::NONE)); + + let err: Option = eval(&s, "return ERR"); + assert_eq!( + err, None, + "pmacs.window.buffer() must not raise for a viewless acting frontend" + ); + let seen: Option = eval(&s, "return SEEN"); + assert_eq!( + seen.expect("the command observed a buffer").0, + ambient, + "…it answers with the ambient active buffer" + ); +} + // --------------------------------------------------------------------------- // 20 / 23 — quit: delete, restore chains, revalidation, and the cap // --------------------------------------------------------------------------- From e547a90e378516a26f069798d7c4857ade909c5d Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 09:42:25 -0400 Subject: [PATCH 11/22] fix(gpu): stop the minimap dividing by zero on an all-blank slab `dominant_line_shape` averages only the lines in a bucket that have content, then guarded the result with `bool::then_some`. `then_some` takes its argument by value, so the `MinimapLineShape` literal --- and with it `indent_sum / count` --- is evaluated before the `count > 0` guard is ever consulted. When a bucket holds no contentful lines the division panics and takes the GPU frontend down. This is reachable in ordinary use, not at an edge: the bucketing branch runs whenever a file has more lines than the minimap has pixel rows, and it is exactly then that a run of blank lines can fill a whole downsampled row. A whitespace-only line counts as blank too --- `minimap_line_shape` subtracts the indent from the total, so `content_cols` is zero. Switch to `bool::then`, which defers the body into a closure so the zero case short-circuits to `None`. The call site already treats `None` as "draw no stroke for this row", so no other change is needed. Three tests, two of which fail against the previous line: * a 10,000-line all-blank file driven through `minimap_rects`, which reproduces the original panic through the real downsampling path; * `dominant_line_shape` on an empty bucket; * a mixed bucket, asserting the average still ignores blank lines --- a companion guard so the fix cannot regress into counting the whole slice. A comment records why this must not be "simplified" back: clippy's `unnecessary_lazy_evaluations` pushes in precisely the wrong direction here, and does not fire on a body that can panic. Co-Authored-By: Claude Opus 5 (1M context) --- pmacs-gpu/src/main.rs | 72 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 29acbe3..6372189 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -8109,7 +8109,17 @@ fn dominant_line_shape( indent_sum += shape.indent_cols; content_sum += shape.content_cols; } - (count > 0).then_some(MinimapLineShape { + // `then`, NOT `then_some`: `bool::then_some` takes its argument by + // value, so the struct literal --- and with it `indent_sum / count` + // --- is evaluated before the guard is ever consulted. A slab of + // all-blank source lines makes `count` zero and panics the frontend + // on the division. `bool::then` defers the body into a closure, so + // the zero case short-circuits to `None`. + // + // Clippy's `unnecessary_lazy_evaluations` lint pushes in exactly the + // wrong direction here; it does not fire on a body that can panic, + // but do not "simplify" this back. + (count > 0).then(|| MinimapLineShape { indent_cols: indent_sum / count, content_cols: content_sum.div_ceil(count), }) @@ -10678,6 +10688,66 @@ mod tests { ); } + #[test] + fn minimap_downsampling_survives_a_slab_of_blank_lines() { + // Regression: `dominant_line_shape` counted only lines with + // content, then built its average with `then_some` --- which + // evaluates its argument eagerly, so `indent_sum / count` + // divided by zero whenever a downsampled pixel row covered + // nothing but blank lines. Reachable on any long file with a + // run of blank lines, which is precisely when the bucketing + // branch runs at all. + let red = style_with_fg(CellColor::Rgb(255, 0, 0)); + let lines = vec![red; 10_000]; + // Every line blank: `minimap_line_shape("")` yields + // `content_cols == 0`, so `has_content()` is false throughout + // and every bucket counts zero contentful lines. + let shapes = vec![ + MinimapLineShape { + indent_cols: 0, + content_cols: 0, + }; + lines.len() + ]; + + let rects = minimap_rects(&lines, &shapes, 240, 120, 0, 30, FontMetrics::default()); + + // The strokes are all suppressed (no content to draw), but the + // thumb still paints --- the point is that this returns at all. + assert!( + rects.len() <= 8, + "blank slabs must emit no line strokes, got {}", + rects.len() + ); + } + + #[test] + fn minimap_downsampling_averages_only_contentful_lines() { + // Guards the other half: a bucket that mixes blank and + // contentful lines must average over the contentful ones only, + // so the fix cannot regress into `count = slice.len()`. + let blank = MinimapLineShape { + indent_cols: 0, + content_cols: 0, + }; + let solid = MinimapLineShape { + indent_cols: 4, + content_cols: 20, + }; + let shapes = [blank, solid, solid, blank]; + + let shape = dominant_line_shape(&shapes, 0, 4).expect("bucket has contentful lines"); + + assert_eq!(shape.indent_cols, 4, "blank lines must not dilute indent"); + assert_eq!(shape.content_cols, 20, "blank lines must not dilute length"); + } + + #[test] + fn minimap_dominant_line_shape_is_none_for_an_empty_bucket() { + let shape = dominant_line_shape(&[], 0, 0); + assert!(shape.is_none(), "an empty bucket has no shape"); + } + #[test] fn minimap_hidden_when_surface_is_too_narrow() { let lines = [style_with_fg(CellColor::Rgb(255, 0, 0))]; From a38296583b81c83848c0217e8d76765b4173e8c7 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 09:44:53 -0400 Subject: [PATCH 12/22] docs: frame Lean 4 mode (Arc 8) The approved framing for Arc 8, revision 4, after three review rounds. Seven stages: grammar/mode, multi-root LSP affinity, the Lean language server, the Unicode input method, the goal view, the #eval output channel, and module hierarchy. 19 decisions, 64 acceptance criteria. Committed as this branch first commit per the house workflow; the implementation of Stage 1 follows. Co-Authored-By: Claude Opus 5 (1M context) --- docs/lean4-mode-framing.md | 1467 ++++++++++++++++++++++++++++++++++++ 1 file changed, 1467 insertions(+) create mode 100644 docs/lean4-mode-framing.md diff --git a/docs/lean4-mode-framing.md b/docs/lean4-mode-framing.md new file mode 100644 index 0000000..e1fe060 --- /dev/null +++ b/docs/lean4-mode-framing.md @@ -0,0 +1,1467 @@ +# Lean 4 mode — framing (Arc 8) + +pmacs has no Lean support of any kind: `grep -rin lean` over `*.rs`, +`*.lua`, `*.toml`, `*.md` returns zero hits outside the words "clean", +"boolean", and "leans on". A `.lean` file today opens as a pathless-ish +plain buffer — no grammar, no major mode, no comment syntax, no pair set, +no server. + +This lane closes that in seven stages. Stage boundaries are drawn where +the *substrate* changes, not where the feature list does — see §4. + +## 0. Why this lane, why now + +- Arc 5 (terminal), Arc 4 (themes), the config registry, and the mode + system are all complete, and Arc 7 Stage 1 (bottom panel) merged as #155 + at `e745068`. The goal view in Stage 5 is the first real consumer of the + panel placement API outside listview/compile/terminal, which is a useful + forcing function for it. +- The language-support pattern is well worn and cheap: #123 (JSON/YAML), + #144 (LaTeX), #146 (HTML+CSS). Stage 1 is that pattern almost exactly. +- Stages 2 and 4–6 are **not** that pattern, and none should be mistaken + for a one-liner. Stage 2 changes `ensure_server`, shared by every LSP + language. Stage 4 builds the editor's first input method. Stage 5 is the + first consumer of a non-standard LSP method family. Stage 6 adds a + severity-routing policy to `LspServerSpec`. +- The user's stated north star is **matching or exceeding what VS Code + does with Lean**. §5's bet 6 scores honestly how close seven stages get + and names precisely what is still missing. + +Parallel-safety: Stage 1 touches `Cargo.toml`, `src/syntax.rs`, +`src/highlight.rs`, and four runtime Lua files. Stage 2 touches +`src/lua_bindings/mod.rs` and `builtin/runtime/lsp.lua` only. Folding +Stage 3 (the other open lane) touches `pmacs-gpu/*` and +`src/semantic_render.rs`. None of the three footprints overlap; the only +file Stage 1 shares with anything is `Cargo.toml`, at one line. + +Stages 1 and 2 are independent of each other and **can** run as sibling +worktrees — they share no file. Per the #126/#127 lesson, that split is +recorded here, before either starts, rather than discovered during a +rebase. + +## 0.1 Revision history + +Revision 1 — initial. + +### Round 1 (rev 1 → rev 2) + +Five findings, all revision edits — no re-scout was required. The reviewer +independently reproduced both crate teardowns, every file:line citation, +the blast-radius greps, and the Lean server facts. + +1. **Acceptance 8 contradicted the change it pinned.** The negative pin + named Lua and Python as "byte-identical" fixtures, but both are among + the languages `constructor` retro-paints — so a fixture that didn't + move would have been exactly the vacuous-assertion shape from the #155 + R2 lesson. Acceptance 7/8 redrawn: Lua and Python moved to the positive + side with asserted deltas, and the negative pin now uses languages + verified to emit none of the four names. +2. **The retro-paint is broader than rev 1 stated, and differently + shaped.** `tree_sitter_javascript::HIGHLIGHT_QUERY` is concatenated + into the `javascriptreact`, `typescript`, and `typescriptreact` + entries (`src/syntax.rs:1009`–`1056`), so `constructor` reaches + **seven** language entries, not four. More importantly the *shape* is + not "constructors": rust/python/javascript tag **every capitalized + identifier** (`#match? "^[A-Z]"`), and lua tags **every + table-constructor brace**. §2.3 and Q#LN4 now state this, because it + is what the ruling is actually about. +3. **The goal view's refresh loop had no seam.** There is no motion hook. + Named the real mechanism (debounced polling off `process.after-tick`) + in Q#LN13 rather than letting it grow a polling loop or new hook + substrate unframed. + +*(Round-1 findings are stated against the features, not stage numbers: +round 2 renumbered the stages, so a rev-1 "Stage 4" is now Stage 5.)* +4. **Q#LN10's ordering example didn't motivate the ordering.** `<` is not + in the proposed pair set, so `\<>` is safe under either order. Replaced + with the real collisions (64 abbreviation keys contain a pair-set + character), and stated the contract that finding exposes: the + abbreviation consumer must claim self-inserts that *extend an open + pending abbreviation*, not only completed expansions. +5. **Q#LN8's resolver must honor the search boundary.** A Lua + `lean-toolchain` walk that ignores `pmacs.project.search_boundary()` + breaks the contract `detect_project_within` exists to enforce and makes + the Stage 3 outermost-root test non-hermetic. + +### Round 2 (rev 2 → rev 3) — scope expansion + +Not review findings: the user pulled seven items out of §6 and into scope, +with the stated north star that **the arc should eventually match or +exceed what VS Code does with Lean**. Folded in, with two designs +corrected against ground truth the expansion request assumed differently: + +- **Lake version probe** (was deferred) → Q#LN7, rewritten. **Corrected: + there is no blocking process run in pmacs.** `pmacs.process` is + `spawn`/`write_stdin`/`terminate`/`list`/`status`/`events_take`/ + `forget`/`resize_pty`, all drained asynchronously off + `process.after-tick`; nothing returns output synchronously. A lazy + probe therefore cannot gate the first attach, so the design is + probe-plus-fallback-latch rather than probe-then-configure. Second + correction: **`lake` being on PATH does not mean Lean works** — see + §2.9, where the scouting machine's own `lake --version` fails. +- **Multi-root Lake scoping** (was deferred) → Q#LN15, and promoted to + its own stage. **Corrected: `root` is computed at + `builtin/runtime/lsp.lua:537`, *after* the reuse loop at `:529`–`:536`, + not before it.** The fix therefore hoists the computation above the + loop, which makes `project_root_for` run on the reuse path where it + previously did not — a real consequence for Q#LN8's function-valued + resolver, handled there. +- **`⦃⦄` / `⟮⟯` pairs** → folded into Q#LN6. +- **Lean in markdown fences** → Q#LN17. +- **`textDocument/waitForDiagnostics`** → Q#LN16. +- **Abbreviation table upkeep** → Q#LN11, as a documented process rather + than a deferral. +- **`#eval` / `#check` output channel** → Q#LN18, its own stage. +- **Module hierarchy** → Q#LN19, its own stage. + +Deliberately still deferred: the interactive infoview (`$/lean/rpc/*`) — +named as the arc's eventual destination, not its scope; the GPU goal band +(blocked on bottom-panel Stage 2); a `cursor.after-move` hook; `.olean` / +`.ilean`; and block-comment toggle, which the user confirmed belongs to +the comment arc's framing rather than this one. + +Nits corrected in round 1: the ledger-drift note (`agent-handoff.md` +omits the panel lane rather than describing it as in-review); Q#LN12's +layering (`_request_*_raw` are the Lua bindings in +`src/lua_bindings/mod.rs`; `src/lsp.rs` has `request_hover` — Stage 5 +touches both files); §2.2's chain step is +`pmacs.parse.language_from_filename`; §2.3 no longer calls +`Style::default()` entries "styled"; and bet 1's grammar count. + +### Round 3 (rev 3 → rev 4) + +Six findings against the round-2 expansion. All revision edits. + +1. **The response half of the seam was never designed** (the real hole). + Q#LN16 and Q#LN19 both awaited replies "through the Q#LN9 seam", and + acceptance pinned it — but Q#LN9 defined only `on_notification` and a + notification arm. Confirmed: **no Lua anywhere consumes + `ev.kind == "response"`**, so a `send_request` reply is drained and + dropped; `send_request` is effectively write-only from Lua. Q#LN9 now + specifies both halves, including one-shot removal-before-invoke and a + pending-response purge on server death, with acceptance mirroring the + notification-side integrity pins. +2. **The affinity key silently fragmented loose files for every + language.** `project_root_for`'s last fallback is `dir_of(path)`, so it + **never returns nil for a file with a path** — a naive + `(language_id, root)` key would give every directory of markerless + scratch files its own server, in Python and Go and TypeScript, caused + by a change made for Lean. Q#LN15 now rules: the affinity key is the + root only when a root was actually *detected*, and nil for the + fallback. Two acceptance cases pin it. +3. **An acceptance criterion was unimplementable.** `pmacs.hook` exposes + `add` / `define` / `list` / `run` and **no `remove`**, so "leaves no + `process.after-tick` subscription" could not be satisfied or tested. + Reworded to the observable: after teardown, ticks issue no request and + write nothing. +4. **Four stale cross-references survived the round-2 renumber**, despite + that round claiming reconciliation: the opening "four stages"; §2.5 + still calling multi-root a §6 deferral; Q#LN12's "only Rust in Stages + 2–4", broken three ways; and §4's row 7 omitting Stage 7's typed + request. Q#LN12 now carries a per-stage Rust table instead of a prose + claim, which is harder to get wrong on the next renumber. +5. **Q#LN7's latch had no named observation mechanism.** Added: it polls + `pmacs.lsp.list()` state on the `process.after-tick` cadence (there is + no event for "died before initialize"), it calls `pmacs.lsp.stop` + before spawning the fallback so `RestartPolicy` cannot respawn the + broken command underneath it, and it updates `command`/`args` only, + preserving user-supplied `env`/`settings`/`init_options`/`root`. +6. Wording: `\{}` expands to `{$CURSOR}`; `⦃⦄` comes from `\{{}}`. + + +## 1. What ships + +Seven stages. The north star is VS Code parity; the honest statement of +where that lands is in §5, bet 6. + +**Stage 1 — grammar, mode, and the editing table stakes.** `.lean` files +highlight, carry a `lean4` major mode, and get comment-toggle and +auto-pairing (including `⟨⟩`, `⦃⦄`, `⟮⟯`). Lean fenced blocks in markdown +highlight too. No LSP, no protocol change, no frontend change. + +**Stage 2 — multi-root LSP server affinity.** Pure substrate, no Lean +content: `ensure_server` stops reusing a server across project roots. +Independently valuable for every language pmacs supports; a prerequisite +for Lean being usable across more than one Lake package. Split out +precisely *because* it is cross-cutting — see §4. + +**Stage 3 — the Lean language server.** `pmacs.lsp.config.lean4` drives +`lake serve` with a Lake-aware outermost root, a lazy toolchain probe and +a one-shot `lean --server` fallback, and a notification-subscription seam +so `$/lean/fileProgress` has an owner. Adds +`textDocument/waitForDiagnostics`. Diagnostics, hover, completion, +goto-definition, document symbols, and semantic tokens all arrive through +the existing typed surfaces. + +**Stage 4 — the Unicode input method.** Typing `\alpha` produces `α`, +`\to` produces `→`, `\<>` produces `⟨⟩` with the point between them. +1,855 abbreviations vendored from vscode-lean4. This is the stage that +makes Lean actually typable in pmacs. + +**Stage 5 — the goal view.** A `*lean-goal*` panel that renders +`$/lean/plainGoal` at the point, refreshed on a debounced tick and on +file-progress completion, displayed through #155's +`pmacs.window.display(buf, { side = "bottom" })`. + +**Stage 6 — the `#eval` / `#check` output channel.** Lean reports command +output as *information*-severity diagnostics, which pmacs currently +squiggles and counts in the modeline. Routes them to a `*lean-output*` +panel instead, via a per-server severity policy that changes nothing for +any other language. + +**Stage 7 — module hierarchy.** `$/lean/prepareModuleHierarchy` and +`$/lean/moduleHierarchy/{imports,importedBy}` into the existing listview +panel. + +## 2. Ground truth (scouted 2026-07-24, `main` @ `e745068`) + +### 2.1 Crate facts (external, verified by downloading and reading both) + +Two candidate grammar crates exist. They are not close in quality. + +**`tree-sitter-lean4` 0.3.0** (`wvhulle/tree-sitter-lean`) — **rejected**: + +- Depends on `tree-sitter = "0.25"` **directly**, not on the shared + `tree-sitter-language 0.1` ABI crate. The workspace is on + `tree-sitter = "0.26"`, and `^0.25` excludes it, so this forks the graph + and its `Language` is a different type from ours. This is the exact + failure mode already documented for `tree-sitter-dockerfile` in + `Cargo.toml`'s comments. +- `src/lib.rs` exports only `pub fn language() -> Language`. Its README + advertises `tree_sitter_lean4::LANGUAGE.into()`, which **does not + exist** — the README is stale. +- Its `include` list is `["build.rs", "src/*", "grammar.js", "grammar/*", + "tree-sitter.json"]`. **No `queries/`.** It ships no highlights query at + all; the upstream repo's queries target Helix. +- Its `build.rs` shells out to a `tree-sitter` CLI when `src/parser.c` is + absent. `parser.c` *is* in the package, so this would not fire — but it + is a live hazard in a crate we would otherwise depend on. + +**`arborium-lean` 2.18.1** (`bearcove/arborium`) — **selected**: + +- `[dependencies] tree-sitter-language = "0.1"` and nothing else at + runtime. No second `tree-sitter` in the graph. +- `grammar/src/parser.c` declares `#define LANGUAGE_VERSION 15` and + `.abi_version = LANGUAGE_VERSION`. ABI 15 is current for tree-sitter + 0.25/0.26. Pre-generated; no CLI at build time. A 1,150-byte + `grammar/scanner.c` supplies one external token (`NEWLINE`). +- Exports `pub const fn language() -> LanguageFn`, plus + `HIGHLIGHTS_QUERY` (`include_str!("../queries/highlights.scm")`, 213 + lines), `INJECTIONS_QUERY` (empty string), and `LOCALS_QUERY` (empty + string). +- `edition = "2024"`, `rust-version = "1.85"`. The workspace is edition + 2024 / MSRV 1.95 on rustc 1.95.0. Compatible. +- **Two things to verify at implementation, not assumed here.** (a) Every + existing entry in `BUILTIN_LANGUAGES` is spelled + `tree_sitter_foo::LANGUAGE.into()` — a `LanguageFn` const. arborium + exposes a `const fn` instead, so the entry reads + `arborium_lean::language().into()`, a shape no current entry uses. + (b) arborium's README shows usage against a + `tree_sitter_patched_arborium` crate. That crate is *not* in the + dependency graph and the `LanguageFn` ABI is the shared one, so this + should be cosmetic — but the loader gets a real parse smoke test against + `tree-sitter 0.26` before the entry is trusted. + +Neither crate is first-party. `leanprover` ships no tree-sitter grammar; +Lean's own tooling parses with the Lean kernel. The upstream README of the +rejected crate says so plainly: *"Lean is a very extensible language. +Therefore, the Tree-Sitter grammar is of limited use."* That is true and it +bounds what Stage 1 can promise — see the bets in §5. + +### 2.2 The grammar table and the detection chain + +`src/syntax.rs:816` `BUILTIN_LANGUAGES` is a `&[LanguageEntry]` of +`{ name, extensions, loader, highlights_query, locals_query, +injections_query }`. Adding a grammar is one entry plus one `Cargo.toml` +line; the doc comment at `src/syntax.rs:756` says exactly this and it has +held for every grammar since. + +`builtin/runtime/syntax.lua:452` `detect_buffer_language` resolves, in +order: modeline → `pmacs.parse.language_for_path` (the grammar extension +table) → `pmacs.lsp.filetypes[ext]` → `pmacs.parse.language_from_filename` +→ shebang. A grammar entry claiming `lean` therefore resolves `.lean` +without any `pmacs.lsp.filetypes` entry; adding one would be dead weight. + +**`LanguageEntry.name` is the LSP `language_id`.** `ensure_server` at +`builtin/runtime/lsp.lua:540` passes `language_id = language` straight +into `pmacs.lsp.spawn`, and the surrounding comments (lines 70, 86, 122) +record that `c`/`cpp` and the four TS/JS entries exist as separate entries +*only* so that id is accurate. This makes the entry name a wire-visible +decision, not a label — see Q#LN2. + +### 2.3 The global capture table (the #146 trap) + +`Theme::default_dark()` at `src/highlight.rs:143` is a single flat +`&[(&str, Style)]` shared by **every** language and by LSP semantic-token +type names. `lookup()` walks dotted prefixes right-to-left, so +`@function.definition` falls back to `function`. + +Resolving arborium's Lean query against the current table: + +| Lean capture | Resolves to | Effect | +|---|---|---| +| `@comment` `@string` `@number` `@operator` `@constant` | themselves | distinct style | +| `@constant.builtin` `@property` `@attribute` | themselves | distinct style | +| `@function.definition` `@function.call` `@function.builtin` | `function` | distinct style | +| `@type.definition` | `type` | distinct style | +| `@string.special` | `string` | distinct style | +| `@keyword.conditional` `.function` `.import` `.modifier` | `keyword` | styled, but flattened | +| `@variable` | `variable` | **entry exists but is `Style::default()`** — visually plain | +| `@punctuation.special` `.bracket` `.delimiter` | `punctuation` | **`Style::default()`** — visually plain | +| `@constructor` | — | **unstyled** | +| `@character` | — | **unstyled** | +| `@warning` | — | **unstyled** | + +Blast radius of adding each name, measured over every `.scm` in the +workspace's actual dependency graph (crates confirmed present in +`Cargo.lock`): + +- **`constructor` — seven language entries, not four grammars.** The + emitting crates are `tree-sitter-javascript`, `-lua`, `-python`, and + `-rust`, but `tree_sitter_javascript::HIGHLIGHT_QUERY` is concatenated + base-first into `javascriptreact`, `typescript`, and `typescriptreact` + as well (`src/syntax.rs:1009`–`1056`), so the reachable set is + `rust`, `lua`, `python`, `javascript`, `javascriptreact`, `typescript`, + `typescriptreact`. + + **And the shape is not "constructors".** Verified against the crate + queries: + + ```scheme + ; rust, python, javascript — every capitalized identifier + ((identifier) @constructor (#match? @constructor "^[A-Z]")) + + ; lua — every table-constructor brace + (table_constructor [ "{" "}" ] @constructor) + ``` + + So adding `constructor` recolors **every capitalized identifier** in + five entries (in Rust that is `Some`/`None`/`Ok`/`Err` and every + class-cased name; in Python/JS/TS every class-cased name) and **every + `{`/`}` of a Lua table literal.** That is the change being ruled on in + Q#LN4 — not a narrow constructor-only recolor. +- `character` — `tree-sitter-zig` only. +- `keyword.conditional` — `tree-sitter-cmake` and `-zig` only. Both + currently flatten to `keyword`. +- `warning` — used by **no** grammar in the graph. + +Verified clean of all four names (usable as negative-pin fixtures): +markdown, json, yaml, html, css, c, cpp, go, containerfile, make, toml, +bash. + +This is the #146 lesson verbatim: *the capture table is global, so adding a +capture name retro-paints every other language; check the reverse direction +and pin it.* + +### 2.4 The LSP substrate + +- `pmacs.lsp` already exposes generic `send_request(id, method, params)` + → request id and `send_notification(id, method, params)` + (`src/lua_bindings/mod.rs:9342`, `:9361`). Non-standard methods need no + new Rust to *send*. +- `LspEventKind` (`src/lsp.rs:264`) has generic `Notification { method, + params }` and `Response { id, result, error, method }` variants. Unknown + server methods are delivered, not dropped. +- **But `events_take` has exactly one consumer**: `handle_server_requests` + at `builtin/runtime/lsp.lua:1448`, driven off `pmacs._async.tick`. It + `take`s — a drain. Its `if/elseif` chain handles five `request` methods + and `initialized`, and **ignores every `notification` and every + `response`**. A second module calling `events_take` would steal events + from it. Any new consumer must extend that loop, not open a second one. +- **`_request_*_raw` helpers route outbound positions through + `outbound_position` (byte → negotiated encoding); a raw `send_request` + does not.** This is handoff §4's standing invariant, and it is sharper + for Lean than for any language pmacs already supports: Lean source is + saturated with non-ASCII (`α`, `→`, `⟨⟩`, `∀`), the Lean server + negotiates UTF-16 by default, and a raw byte column is wrong on + essentially every interesting line. This is why Stage 5 is not + "just call `send_request` from Lua" — see Q#LN12. + +### 2.5 Project-root detection + +`project_root_for` (`builtin/runtime/lsp.lua:513`) resolves: +`pmacs.lsp.config[language].root` → `pmacs.project.detect` → the file's own +directory. Two gaps for Lean: + +1. **No Lean marker, and no way to add one.** `default_markers()` + (`src/project.rs:145`) is `Cargo.toml`, `.luarc.json`, `pyproject.toml`, + `go.mod`, `deno.json`, `deno.jsonc`, `package.json`, `.git`. The + `pmacs.project` Lua surface is `detect`, `set_search_boundary`, + `search_boundary` — **there is no marker-registration binding.** +2. **`detect_project` returns the innermost match; Lean needs the + outermost.** `walk_for_marker` returns the first ancestor that matches. + `lean4-mode` deliberately does the opposite: + + ```elisp + (while-let ((dir (locate-dominating-file file-name "lean-toolchain"))) + (setq root dir + file-name (file-name-directory (directory-file-name dir)))) + ``` + + It keeps walking *past* each hit and takes the topmost. This matters + concretely: Lake vendors dependencies under `/.lake/packages/*`, + and each vendored package carries its own `lean-toolchain`. Opening + `/.lake/packages/batteries/Batteries/Data/List.lean` must serve + from ``, not from `batteries`. Innermost-wins gets this backwards + every time, and the symptom is a server that starts, initializes, and + then reports import errors for the whole file. + +Third, and the reason Stage 2 exists: `ensure_server` +(`builtin/runtime/lsp.lua:527`) reuses any live server with a matching +`language_id` regardless of the new file's project, so **the first `.lean` +file opened fixes the root for every later `.lean` file.** For most +languages that is an inconvenience; for Lean, where `lake serve` is bound +to one package, it is a correctness failure. Rev 1 carried this as a +deferral. **It is now Stage 2 / Q#LN15.** + +One property of `project_root_for` matters for that fix and is easy to +miss: its final fallback is `dir_of(path)`, so **it never returns nil for +a file with a path.** A markerless scratch file's "root" is its own +directory. Q#LN15's affinity rule has to account for that or it silently +changes loose-file behavior for every language. + +### 2.6 Typed-edit provenance — the only input-method-shaped seam + +`builtin/runtime/pair.lua` is the whole precedent for "react to a typed +character": subscribe to `buffer.after-edit`, gate on +`ed.this_command() == "buffer.self-insert"` (`pair.lua:213`), then take the +exact provenance record. + +`pmacs.editor.take_typed_edit()` (`src/lua_bindings/mod.rs:12798`) returns +`{ buffer, window, codepoint, char, requested_start, requested_end, +effective_start, effective_end, inserted_len, post_cursor, clean }` — or +nil. Its doc comment is explicit: + +> Consuming clears the slot: later callbacks and nested manual hook runs +> see nil, and the producer clears any untaken record when the fan-out +> returns. Per-frontend — one frontend can never take another's record. + +**It is one-shot and first-come-first-served.** `pair.lua` already consumes +it on every self-insert. A Lean abbreviation expander that independently +calls `take_typed_edit()` in the same `buffer.after-edit` fan-out gets nil +or steals it from auto-pairing, depending on hook order — and hook order is +not a contract. This is the single load-bearing constraint on Stage 4 and +the reason Stage 4 is its own PR rather than a rider on Stage 1. + +Related, from `pair.lua:30`'s Q#AP1 note: only the nine built-in pair chars +`()[]{}"'` and backtick are excluded from the frontends' optimistic +classifiers. A pair char outside that set still pairs, but its opener is a +source-peer op and its closer a daemon-peer op, so **its undo is +cross-peer-degraded**. Lean's `⟨⟩` is outside that set. + +### 2.7 Panels and generated read-only buffers + +- #155 landed on `main` at `e745068`. `pmacs.window.display(buf, { side = + "bottom", select = true })` is the placement call; `listview.lua:138` + shows the adopter shape, gated on `spec.display == "panel"`. + `pmacs.window.params()` and `pmacs.window.quit()` complete the surface. +- Read-only generated buffers use the listview idiom, documented at + `builtin/runtime/compile.lua:264`: an erroring `pmacs.buffer.add_intercept` + for user edits, with module writes passing `{ bypass_intercept = true }`. +- **Note for whoever picks this up on another machine:** the ledgers are + stale about this. `docs/active-work.md:57` still heads the lane "Stage 1 + IN REVIEW"; `docs/agent-handoff.md` §1 is stamped at #148 and **omits + the bottom-panel lane entirely**. It merged at `e745068`. That drift is + #156's business, not this lane's, but do not scout Stage 5 off either + file. + +### 2.8 Lean server facts (external, verified against `leanprover/lean4`) + +From `src/Lean/Server/FileWorker/RequestHandling.lean` and +`src/Lean/Data/Lsp/Extra.lean`: + +- `$/lean/plainGoal` — params extend `TextDocumentPositionParams`; result + is `PlainGoal { rendered : String, goals : Array String }` or null. +- `$/lean/plainTermGoal` — result `PlainTermGoal { goal : String, + range : Range }` or null. +- `$/lean/fileProgress` — a server→client **notification**, params + `{ textDocument : VersionedTextDocumentIdentifier, processing : Array + { range : Range, kind : "processing" | "fatalError" } }`. This is the + "orange bar": which regions are still elaborating. +- Also present, all deferred here: `$/lean/rpc/{connect,call,release, + keepAlive}` (the interactive widget/infoview stack), + `$/lean/prepareModuleHierarchy`, `$/lean/moduleHierarchy/{imports, + importedBy}`, `$/lean/waitForILeans`, `textDocument/waitForDiagnostics`. +- From `src/Lean/Data/Lsp/InitShutdown.lean`: + `InitializationOptions { hasWidgets? : Option Bool, logCfg? : Option + LogConfig }`. `hasWidgets?` **defaults to false**, and its documented + meaning is: when true, the server may *omit* information from standard + LSP messages because the client will fetch it interactively. A + plain-goal client wants the default. Omitting `initializationOptions` + entirely is accepted (`FromJson` maps missing/null to `none`). +- Server launch, from `lean4-mode`'s `lean4--server-cmd`: `lake serve` when + Lake ≥ 3.1.0 is found, else `lean --server`. + +### 2.9 There is no blocking process run — and `lake` on PATH proves nothing + +The complete `pmacs.process` Lua surface is `spawn`, `write_stdin`, +`terminate`, `list`, `status`, `events_take`, `forget`, `resize_pty`, +`_tick`. `ProcessSpec` (`src/process.rs:193`) carries no +wait-for-output mode, and there is no `wait_with_output` anywhere in +`src/process.rs`. Everything is asynchronous and drained off +`process.after-tick`, which is how compile mode streams. + +**Consequence: any toolchain probe is async, and cannot gate the attach +that triggered it.** A design that reads "probe, then set +`pmacs.lsp.config.lean4.command`, then attach" cannot be written against +this substrate. + +Second, and sharper — scouted on this machine: + +``` +$ elan --version +elan 4.2.1 (3d5138e15 2026-03-18) +$ lake --version +error: no default toolchain configured. run `elan default stable` to ... +$ lean --version +error: no default toolchain configured. run `elan default stable` to ... +``` + +`elan` installs `lake` and `lean` as **toolchain shims**. Both are on +PATH, both are executable, and both fail. So: + +- A version probe must parse a *failure*, not just a version string — + `lake --version` returning non-zero is a normal, common state. +- `command -v lake` is worthless as a capability check. +- The failure modes a Lean client must survive are: lake absent, lake + present but shimmed with no toolchain, lake present and working but too + old, and lake working but the directory is not a Lake package. Only the + third is a *version* question. +- **Acceptance cannot assume a working Lean toolchain exists.** Every + Stage 3+ test runs against the fake LSP server; a live `lake serve` + smoke is PATH-gated *and* success-gated, following the #123 JSON/YAML + provider-smoke pattern. + +### 2.10 Information-severity diagnostics are squiggled and counted + +`src/diag.rs:50` defines `DiagnosticSeverity` with `Information` mapped +from LSP severity `3` (`:103`). Information diagnostics get the +`ui.diag.info` face (`:408`), a `UnderlineStyle::Single` squiggle +(`:426`), and a slot in the severity count tuple (`:223`) that feeds the +modeline. + +Lean reports every `#eval`, `#check`, `#print`, and `example` result as an +information-severity diagnostic at the command's position. Under the +current surface those render as underlined "problems" with a gutter sign +and a modeline count — which is why rev 1 called them noise. The claim is +now specific: they are not merely unstyled, they are **actively +mis-rendered as defects**, and the count misleads. + +The publish path absorbs into the Rust store *and* still delivers the +notification to `events_take`, so Lua can observe them; but suppressing +them from the store needs a Rust-side policy, not a Lua filter. Q#LN18. + +## 3. Decisions + +### Q#LN1 — Bundle `arborium-lean` 2.18; reject `tree-sitter-lean4` + +Per §2.1. The decision is forced by the dependency graph, not by taste: +`tree-sitter-lean4`'s `tree-sitter ^0.25` cannot coexist with the +workspace's 0.26. Its missing queries and stale README are secondary. + +Risk accepted: `arborium-lean` is a third-party republish from a grammar +collection, not the grammar's upstream. `codebook-tree-sitter-latex` (#144) +set this precedent for exactly the same reason — no usable first-party +crate exists. The mitigation is the same: pin a real parse + highlight +smoke test so a bad republish fails our suite, not a user's file. + +### Q#LN2 — Name the entry `lean4`, not `lean` + +`LanguageEntry.name` becomes the `didOpen` `language_id` (§2.2), and the +Lean ecosystem's id is `lean4` (vscode-lean4 uses it; `lean` is Lean 3). +The grammar's C symbol is `tree_sitter_lean`, but that is arborium's +business — the entry name is ours to choose. + +Consequences, all deliberate: `pmacs.comment.strings.lean4`, +`pmacs.pair.sets.lean4`, `pmacs.lsp.config.lean4`, and a mode line reading +`lean4`. An Emacs `-*- mode: lean -*-` or a Vim `ft=lean` modeline is +normalized to `lean4` through `pmacs.parse.modeline_aliases`, so neither +spelling strands a file. + +### Q#LN3 — Extensions: `lean` only + +Not `.olean` (compiled binary artifacts — opening one as text is never +what the user wants) and not `.ilean` (JSON metadata; if anything it +belongs to the `json` entry). + +### Q#LN4 — Add four capture entries and pin the retro-paint in both directions + +**Ruled (round 1): add to the global table.** The alternative is an in-repo +overlay, and there is no partial option — see below. + +Add to `Theme::default_dark()`: `constructor`, `character`, +`keyword.conditional`, and `warning`. + +Rationale, per name: + +- **`constructor`** is the consequential one. Per §2.3 it reaches seven + language entries and its real effect is *"recolor every capitalized + identifier in five entries and every `{}` in Lua"*. That is stated + bluntly because it is the decision, not a side effect. It is + nevertheless the right call: capitalized-identifier-as-constructor is + the mainstream editor convention (it is what nvim-treesitter, Helix, and + Zed all render off these same queries), those tokens are currently + *unstyled* rather than deliberately plain, and Lua's braces gaining a + colour is a cosmetic difference on a token that today renders as default + text. The cost of avoiding it is owning a forked 213-line query forever. +- `character` — `tree-sitter-zig` only, currently unstyled. +- `keyword.conditional` currently flattens to `keyword`. Giving it + `keyword.control`'s brighter style makes Lean's `if`/`then`/`else`/ + `match`/`do` read the way Rust's already do, and reaches cmake and zig + the same way. +- `warning` has zero blast radius and gives Lean's `(sorry)` — an + unproved goal, the single most important thing to see in a proof file — + a visible style. + +All four are pinned in the reverse direction exactly as #146 required — +acceptance 7 asserts the retro-paint *happened* on each affected language, +acceptance 8 asserts it did not leak into languages that emit none of the +four names. + +**Rejected alternative:** an in-repo query overlay +(`builtin/queries/lean4/highlights.scm`) rewriting the capture names into +the existing vocabulary, the #144 LaTeX pattern. It avoids touching the +global table, but it forks a 213-line query we would then own and +hand-merge on every arborium bump. Overlays are for grammars whose crate +ships *no* usable query; arborium ships one. + +**There is no middle option.** Styling Lean's constructors without +touching the other seven entries requires renaming the capture, which +requires the overlay, which forks the query. The choice is binary: accept +the retro-paint, or own the fork. + +### Q#LN5 — Comments: `--` only in Stage 1 + +`pmacs.comment.strings.lean4 = "--"`. Lean's block comment is `/- ... -/` +and its docstring is `/-- ... -/`; block-comment toggling is an existing +named deferral of the comment arc (`docs/comment-toggle-framing.md`) and +this lane does not front-run it. + +### Q#LN6 — Pair set includes `⟨⟩`, and the degradation is named + +`pmacs.pair.sets.lean4 = { "()", "[]", "{}", "⟨⟩", "⦃⦄", "⟮⟯", '""' }`. + +`⟨⟩` (anonymous constructor) is among the most-typed constructs in Lean and +omitting it would make the pair set feel broken. It is outside the nine +built-in pair chars, so per §2.6 its undo is cross-peer-degraded. That is a +documented, pre-existing limitation of user-extended pairs whose general +fix is chronological cross-peer undo arbitration — already on the standing +backlog. Ship it; name it in the module comment. + +`''` is excluded: Lean uses `'` as a primed-identifier suffix (`h'`, +`foo'`), so pairing it would fight the user constantly. Same reasoning that +excludes it for Rust. + +`⦃⦄` (strict implicit binder) and `⟮⟯` ride along — one list entry each, +same degradation, and both have abbreviation keys (`\{{}}`, `\([])'`) so +omitting them would make the input method produce brackets the pair set +does not understand. + +### Q#LN7 — `lake serve` by default, with a lazy probe **and** a failure latch + +```lua +pmacs.lsp.config.lean4 = pmacs.lsp.config.lean4 or { + command = "lake", + args = { "serve" }, +} +``` + +`lean4-mode` probes Lake's version and falls back to `lean --server` below +3.1.0. This lane does the same, but **lazily and asynchronously**, and +pairs it with a failure latch — because §2.9 makes probe-alone +insufficient in two independent ways. + +**Where the probe runs.** Not at init: `pmacs.lsp.config` is a declarative +table, and spawning a process at startup for every user, Lean-using or +not, is the cost rev 1 refused. It runs on the first `.lean` attach, in +`builtin/runtime/lean.lua`, cached for the session. + +**Why the probe cannot gate the first attach.** There is no blocking +process run (§2.9). `pmacs.process.spawn` + `events_take` off +`process.after-tick` is the only shape available, so the probe's verdict +arrives *after* `ensure_server` has already had to decide. This is the +correction to the round-2 request, which assumed the verdict could be +consulted before configuring. + +**The design that follows:** + +1. First `.lean` attach spawns `lake serve` optimistically and fires + `lake --version` alongside it, with `cwd` at the resolved Lake root. +2. If the probe reports a version below 3.1.0, or the server dies before + `initialize` completes, a **one-shot latch** swaps in `lean --server` + and restarts once. The latch is per session and never re-arms. + + **How the latch observes failure.** There is no event for "exited + before initialize" — the drain ignores state events. The latch polls + `pmacs.lsp.list()` for the server's `state.kind` on the same + `process.after-tick` cadence Q#LN13 uses, and treats + `crashed`/`stopped` reached without an intervening `initialized` as + the trigger. (Q#LN9's pending-response purge fires on the same + transition, so anything already awaiting a reply fails cleanly rather + than hanging.) + + **Interplay with `RestartPolicy`.** The manager will otherwise respawn + the same broken command underneath the latch, producing a loop the + latch cannot see the end of. So the latch calls `pmacs.lsp.stop` on + the failing server *first*, then swaps the config, then spawns — the + fallback is a fresh server, not a restart of the old one. + + **The swap is a field update, not a table replacement.** It rewrites + only `command` and `args`, preserving any user-supplied `env`, + `settings`, `init_options`, and `root` on `pmacs.lsp.config.lean4`. A + wholesale table replacement would silently discard a user's + `init.lua` configuration at exactly the moment they are least likely + to notice. +3. If `lean --server` also fails, the error surfaces through the ordinary + `pmacs.lsp.last_error` path. pmacs does not attempt to install a + toolchain. + +**Why a latch and not just a probe.** Per §2.9 the failure modes are lake +absent, lake shimmed-with-no-toolchain, lake too old, and +not-a-Lake-package. **Only the third is a version question**, and the +scouting machine exhibits the second — `lake --version` there exits +non-zero with `error: no default toolchain configured`. Since the failure +path must exist regardless, the probe's job shrinks to the one case +failure detection would otherwise handle slowly (an old-but-working lake +that starts a useless server). Probe and latch are complements, not +alternatives. + +**Named risk.** The optimistic first spawn means a user on a +lake-less-but-lean-ful toolchain sees one failed spawn before the +fallback. That is a one-line status message, once per session, and it +buys not blocking every other user's first attach behind a process +round-trip. + +No `init_options`. Per §2.8, `hasWidgets?` defaults to false and that is +the correct value for a client that reads plain goals out of standard +messages. + +### Q#LN8 — Lake-aware root via a **function-valued** `config.root` + +Generalize `project_root_for` (`builtin/runtime/lsp.lua:513`) so +`pmacs.lsp.config[lang].root` may be a `function(path) -> string|nil` as +well as a string, and implement Lean's resolver in +`builtin/runtime/lean.lua`: walk up from the file's directory collecting +every ancestor containing `lean-toolchain`, and return the **outermost**; +fall back to `pmacs.project.detect`, then the file's directory. + +**The walk stops at `pmacs.project.search_boundary()`.** This is not +optional politeness: `detect_project_within` (`src/project.rs:213`) exists +precisely so a stray marker above a temp fixture cannot leak into +detection, and a Lua walk that ignores the boundary breaks that contract — +including for acceptance 23, whose outermost-root assertion is otherwise +non-hermetic against any `lean-toolchain` that happens to sit in an +ancestor of the test's tempdir. + +Why this and not the two alternatives: + +- *Adding `lean-toolchain` to Rust's `default_markers()`* does not work — + `detect_project` is innermost-wins by construction (§2.5), and inverting + it globally would change Rust/Go/Node root detection for every user. +- *A `pmacs.project.add_marker` Lua binding* is a bigger new surface than + this lane needs and still leaves the innermost/outermost problem. + +The function-valued `root` is ~3 lines in `lsp.lua`, is a strict +generalization (a string still works), and puts the Lean-specific rule in +the Lean module where it belongs. + +### Q#LN9 — Notification **and response** subscription seams in the existing dispatch + +Per §2.4 there is exactly one `events_take` consumer, and its `if/elseif` +chain handles five `request` methods plus `initialized`. It ignores +notifications **and responses** — and no Lua anywhere in the runtime +consumes `ev.kind == "response"`. So today a `send_request` reply is +drained and dropped on the floor: **`send_request` is effectively a +write-only API from Lua.** + +Rev 2 specified only the notification half. That was a hole, since +Q#LN16 (`waitForDiagnostics`), Q#LN19 (`imports` / `importedBy`), and +Q#LN12's typed goal request all await replies. Both halves ship in +Stage 3. + +```lua +pmacs.lsp.on_notification(method, fn) -- fn(sid, params); persistent +pmacs.lsp.on_response(sid, request_id, fn) -- fn(result, err); ONE-SHOT +``` + +Routed from two new arms of the existing loop: + +- `elseif ev.kind == "notification"` → every subscriber registered for + `ev.method`. +- `elseif ev.kind == "response"` → the one-shot registered for + `(sid, ev.id)`, **removed before it is invoked** so a raising handler + cannot be re-entered. + +Each handler is `pcall`ed, so one raising subscriber cannot stall the +drain or starve the `request` arms that share it. + +**Pending-response lifetime.** A one-shot whose server dies never fires on +its own, leaking the registration and hanging whatever awaits it. On +`crashed` / `stopped` / `restarting` for a `sid`, every pending one-shot +for that `sid` is invoked with an error and cleared. This is what lets +Stage 5's in-flight tracking (acceptance 52) be honest rather than +optimistic, and it is what Q#LN7's latch observes (below). + +Explicitly **not** a second `events_take` caller — a second drain would +steal events from `handle_server_requests`. The tests pin that in both +directions: a Lean subscriber must not cause `workspace/applyEdit` to be +missed, and a raising subscriber must not stop later events in the same +drain. + +Stage 3 registers `$/lean/fileProgress` on the notification seam and +`waitForDiagnostics` on the response seam; stages 5 and 7 use the response +seam for `plainGoal` and the hierarchy calls. + +### Q#LN10 — Stage 4 mechanism: one shared provenance read, not two + +The hazard is §2.6 — `take_typed_edit()` is one-shot and `pair.lua` +already consumes it. + +Decision: **`pair.lua` stops being the sole consumer.** Extract the +provenance read into a single `buffer.after-edit` subscriber owned by a +small shared module, which takes the record once and passes it to an +ordered list of typed-edit consumers (auto-pair, Lean abbreviation). +Consumers return whether they handled the edit; the first that does stops +the chain. + +Two consequences worth stating up front: + +- This touches `pair.lua`, which is load-bearing for auto-pairing + acceptance. The full pairing suite is a required gate for Stage 4, and + the refactor lands *first*, as its own commit with no behavior change, + so a regression bisects cleanly. +- Ordering is a contract, not an accident, and the collision is real: + **64 of the 1,855 abbreviation keys contain a character in the proposed + `lean4` pair set** — `\[[]]` → `⟦⟧`, `\(())` → `⸨⸩`, `\{{}}` → `⦃⦄`, + `\{}` → `{$CURSOR}`. With pairing first, typing `\[` inserts `[]` + with the point between, so the pending key is corrupted to `\[]` before + the second `[` is ever typed and `\[[]]` becomes unreachable. The + abbreviation consumer runs first. + + (Rev 1 justified this with `\<>`, which was wrong: `<` is not in the + pair set per Q#LN6, so that key is safe under either order.) + +**The contract that collision exposes:** the abbreviation consumer must +claim a self-insert that **extends an open pending abbreviation**, not +only one that completes an expansion. A consumer that only claims +completed expansions hands every intermediate keystroke to auto-pairing, +which is exactly how `\[` gets corrupted. "Claimed" here means the chain +stops, not that an edit was made. + +Expansion semantics (matching vscode-lean4 and `lean4-input`): + +- `\` opens a pending abbreviation, tracked per buffer with its start + offset. Every subsequent self-insert that extends it is claimed. The + pending state is abandoned on any non-self-insert command, buffer + switch, or cursor move away from the pending region. +- Expansion fires on a unique complete match that no longer key extends, + or on an explicit terminator (space, tab, RET, or a second `\`). +- The vendored table's `$CURSOR` placeholder becomes the point position + after the replace — this is how `\<>` yields `⟨|⟩`. +- The whole expansion is **one `buf:replace`** — one undo step, one CRDT + op, one effective-edit verification. Same discipline as + `comment.lua`'s Q#CT5. +- Gated by `pmacs.config.define{ name = "lean.abbrev", type = "boolean", + default = true, mutability = "live" }`, read against the *source* buffer + of the typed edit — the `editing.auto-pair` precedent (`pair.lua:44`), + including its round-2 correction to resolve `rec.buffer` rather than + `pmacs.window.buffer()`. + +### Q#LN11 — Stage 4 data: vendor the table, generated, attributed + +`abbreviations.json` in `leanprover/vscode-lean4` is a flat +`string → string` object of **1,855 entries** (counted, not estimated), +of which **64 contain a character in the `lean4` pair set** — the +collision Q#LN10's ordering exists to handle. vscode-lean4 is Apache-2.0. + +Vendor it as a generated `builtin/runtime/lean_abbrev.lua` with a header +recording source repo, commit, license, and the regeneration command — +the `builtin/queries/latex/highlights.scm` precedent (#144) for +third-party data, extended with provenance because this is a much larger +artifact under a named license. + +Not fetched at runtime, not a package-manager dependency: the input method +must work offline and on first launch. + +**Upkeep is a documented manual process, not code.** There is no automatic +sync and none is wanted — an editor that silently re-downloads its input +method has a supply-chain problem, not a feature. The generator script +lives at `scripts/regen-lean-abbrev`, takes a vscode-lean4 commit as its +argument, and rewrites the file including its provenance header. The +header records source commit, license, entry count, and the regeneration +command, so the file is self-describing to whoever next touches it. A +refresh is an ordinary PR with a visible diff — which is the point: the +diff is the review. + +### Q#LN12 — Stage 5 sends `$/lean/plainGoal` through a typed Rust request + +Per §2.4, `send_request` does **not** route positions through +`outbound_position`. Lean negotiates UTF-16 and Lean source is +overwhelmingly non-ASCII, so a Lua-built byte column would be wrong +wherever it matters most. + +Stage 5 therefore adds a typed request that reuses `outbound_position` +unchanged. It spans **two files**, because the `_raw` naming is a layer +boundary, not a module: + +- `src/lsp.rs` — `request_plain_goal`, alongside `request_hover` + (`src/lsp.rs:1690`) and `request_definition` (`:1733`), which is where + `outbound_position` is actually applied. +- `src/lua_bindings/mod.rs` — the `_request_plain_goal_raw` binding, + alongside the `_request_hover_raw` family (`:9501`–`:9823`). + +It is a thin builder — the result is passed through as JSON and parsed in +Lua, since `PlainGoal` is two fields and does not warrant a typed store. + +It exists specifically to honor handoff §4's standing invariant rather +than quietly reintroduce the bug it was written to prevent. + +**Where the arc's Rust actually lives** (rev 2 stated this in pre-renumber +stage numbers and was wrong three ways): + +| Stage | Rust | +|---|---| +| 1 | `Cargo.toml` + `BUILTIN_LANGUAGES` entry + Q#LN4's four capture entries | +| 2 | `lsp.list()` row builder (`mod.rs:9919`) | +| 3 | **none** — Lua only | +| 4 | **none** — Lua only | +| 5 | `request_plain_goal` + its binding | +| 6 | `LspServerSpec` severity-policy field and its publish-path honoring | +| 7 | `request_prepare_module_hierarchy` + its binding | + +Stages 3 and 4 — the two largest Lean-specific stages — are entirely Lua. + +### Q#LN13 — Stage 5 goal panel shape + +- `*lean-goal*`, read-only via the erroring-intercept idiom, module writes + with `{ bypass_intercept = true }` (§2.7). +- Displayed with `pmacs.window.display(buf, { side = "bottom", select = + false })`. `select = false`: a goal view that steals focus on every + cursor move is unusable. +- **Refresh mechanism, named explicitly because there is no motion hook.** + The complete hook inventory is `buffer.{after-edit,after-load, + after-save,after-switch,before-save}`, `editor.before-quit`, + `frontend.detached`, and `process.after-tick`. Nothing fires on cursor + movement. Stage 5 therefore refreshes from a **debounced poll off + `pmacs.hook.add("process.after-tick", …)`** — the cadence pattern + autosave (`autosave.lua:139`) and compile (`compile.lua:687`) already + use — comparing the point against the last position it queried and + issuing at most one in-flight `$/lean/plainGoal` at a time. + + This is written down so Stage 5 cannot quietly grow either an + unframed polling loop or new hook substrate. A `cursor.after-move` hook + would be the better long-term answer; it is out of scope here and is + named in §6. +- Also refreshed on a `$/lean/fileProgress` notification whose + `processing` array no longer covers the point's range. +- Content: `PlainGoal.rendered` when present, "no goals" when the result is + null with the file elaborated, "elaborating…" when file-progress still + covers the point. The three states are distinct and the middle one is the + one users actually need to trust. +- Keys under `pmacs.keymap.bind { scope = "mode", mode = "lean4", … }` + (#129's mode-scoped keymaps). + +### Q#LN14 — No protocol change in any stage + +Stages 1–4 and 7 touch no wire surface at all. Stage 5's panel rides #155 +Stage 1, which is grid-only and bumped nothing; Stage 6 adds a field to +the in-process `LspServerSpec`, which is not wire. A GPU-rendered goal band +needs bottom-panel Stage 2, which is itself unframed — so the GPU half is +deferred, not attempted. Protocol stays v20. + +### Q#LN15 — Multi-root server affinity (Stage 2, substrate) + +Today `ensure_server` reuses any live server whose `language_id` matches, +**regardless of project root** (`builtin/runtime/lsp.lua:524`–`536`, whose +own comment documents this as a known limitation). For Lean this is not a +rough edge but a correctness failure: `lake serve` is bound to one Lake +package, so the second package a user opens gets a server that cannot +resolve its imports. + +The change is small and spans two files: + +- **`src/lua_bindings/mod.rs:9919`** — the `lsp.list()` row builder sets + `id`/`label`/`language_id`/`command`/`state`/`attempt`. Add `root_uri` + from `spec.root_uri` (already `Option` on `LspServerSpec`, + `src/lsp.rs:125`) and `cwd`. Bump the `create_table_with_capacity` + hint. +- **`builtin/runtime/lsp.lua:521`–`551`** — hoist `local root = + project_root_for(language, path)` **above** the reuse loop and match on + the `(language_id, root_uri)` pair. + +**Correcting the round-2 request:** `root` is currently computed at +`:537`, *after* the loop, not before it. Hoisting is therefore part of the +change, and it has a consequence: `project_root_for` begins running on the +reuse path, where it previously ran only on spawn. For Q#LN8's +function-valued Lean resolver — which walks the filesystem — that means +once per attach rather than once per spawn. The resolver memoizes per +directory for the session. + +**Comparison rule, part 1 — hand-spawned servers.** Compare +`info.root_uri` against the request's affinity key, with nil matching nil. +A server spawned directly from `init.lua` with only `cwd` set has +`root_uri = nil` and will therefore *not* match a root-bearing request — +it gets a new server rather than being silently adopted. Conservative and +deliberate, but a behavior change, so acceptance asserts it. + +**Comparison rule, part 2 — markerless files must not fragment.** Per +§2.5, `project_root_for` **never returns nil for a file with a path**: its +last fallback is `dir_of(path)`. A naive `(language_id, root)` key +therefore gives *every directory of markerless scratch files its own +server*, for **every language** — two loose `.py` files in different +directories would spawn two pyrights where today they share one. That is a +silent regression for Python, Go, TypeScript and everyone else, caused by +a change made for Lean. + +Ruling: **the affinity key is the root only when a root was actually +detected.** `project_root_for` returns `(root, source)` with `source` one +of `"config"`, `"detected"`, or `"fallback"`; the affinity key is `root` +for the first two and **`nil` for `"fallback"`**. The directory is still +passed as `cwd` / `rootUri` exactly as today — only the *matching* key +changes. + +Consequences, both intended: + +- Files in a real project (Cargo/Lake/go.mod/…) get one server per root — + the fix. +- Markerless loose files keep today's single shared server per language — + no change, which is the point. + +**Rejected alternative:** keying on the fallback directory anyway and +accepting per-directory servers. It fragments the common scratch-file case +for every language in the editor to buy nothing for Lean, whose files are +essentially always in a Lake package. + +**Blast radius, stated plainly.** This is the central server-affinity +function for *every* LSP language in pmacs. A bug here routes a file to +the wrong server: diagnostics land on the wrong buffer, or a redundant +server spawns. This is why it is Stage 2 and its own PR, with no Lean +content in the diff — a cross-cutting change to every language's server +affinity must not be reviewable only as a Lean feature. + +**Named risk: unbounded server growth.** Per-root affinity means opening +files across N Lake packages spawns N `lake serve` processes, and Lean +elaboration is memory-hungry. rust-analyzer has the same property and no +editor caps it by default. No cap ships here; `pmacs.lsp.stop` is the +manual escape, and an LRU reaping policy is named in §6. + +### Q#LN16 — `textDocument/waitForDiagnostics` (Stage 3) + +A plain request (no position, so no `outbound_position` concern — Q#LN12 +does not apply). It resolves when the server has finished elaborating the +document. + +Two uses, in order of importance: + +1. **Deterministic acceptance.** Lean elaboration is slow and + asynchronous; a test that sleeps is flaky and a test that polls is + slow. This is the seam that makes a live `lake serve` smoke + deterministic when a toolchain happens to be present. +2. A `M-x lean-wait-for-diagnostics` command, and a gate for the goal + panel's "elaborating" state (Q#LN13) that is cheaper than parsing + `$/lean/fileProgress` ranges. + +Sent through `pmacs.lsp.send_request` and awaited through the Q#LN9 +notification/response seam. ~20 lines. + +### Q#LN17 — Lean in markdown fences (Stage 1) + +The injection engine (#122) resolves fence names through +`pmacs.parse.injection_aliases`, a case-folded, Lua-extensible map +snapshotted into `ParseRequest`. Register `lean` and `lean4` → `lean4`. + +Two lines, and it is the one place where the Lean 3 spelling is +deliberately *not* normalized away: a ` ```lean ` fence is overwhelmingly +Lean 4 in practice, and mapping it to the `lean4` grammar is right. + +`lean4-mode` does the equivalent through `markdown-code-lang-modes`. Being +in Stage 1 means Lean blocks in this repo's own docs highlight from the +first PR. + +### Q#LN18 — `#eval` / `#check` output channel (Stage 6) + +Per §2.10, Lean's command output arrives as information-severity +diagnostics and pmacs squiggles them, signs them in the gutter, and counts +them in the modeline. VS Code shows them in the infoview instead. This +stage routes them. + +Decision: **a per-server severity policy on the spec, not a Lua filter.** +The publish path absorbs into the Rust `DiagnosticStore` before Lua sees +the notification, so a Lua-side filter would suppress the *display* while +leaving the store's counts wrong. Add an optional +`diagnostic_severity_policy` to `LspServerSpec` — default "all severities +to the store", which is a no-op for every existing language — and have the +Lean config route `Information` to the output channel only. + +The channel itself is a `*lean-output*` buffer using the same read-only +generated-buffer idiom as Q#LN13, appended to in position order and +cleared per publish for the owning document. + +Deliberately *not* merged into the goal panel: a goal is a property of the +point, output is a property of the file, and the two refresh on different +triggers. Merging them is what makes VS Code's infoview complicated. + +### Q#LN19 — Module hierarchy (Stage 7) + +`$/lean/prepareModuleHierarchy` at the point returns hierarchy items; +`$/lean/moduleHierarchy/imports` and `.../importedBy` expand one in either +direction. Rendered with `pmacs.listview.open{ name, header, rows, +on_visit, on_refresh, display = "panel" }` (`listview.lua:111`) — the same +panel the LSP references/outline views already use. + +`prepareModuleHierarchy` is position-bearing, so it goes through the +Q#LN12 typed-request path; the two expansion calls take an item, not a +position, and can use `send_request` directly. + +Last stage because it is the least load-bearing: it is navigation +convenience, and nothing else in the arc depends on it. + +## 4. Stage boundaries and why this order + +Each stage is one branch, one PR, and is independently useful if the next +never lands. + +| Stage | Ships | Substrate risk | Depends on | +|---|---|---|---| +| 1 | grammar, mode, comments, pairs, md fences | new crate; **global capture table** | — | +| 2 | multi-root server affinity | **`ensure_server`, shared by every language** | — | +| 3 | `lake serve` + probe/latch, Lake root, notification seam, `waitForDiagnostics` | two `lsp.lua` generalizations | 1, 2 | +| 4 | Unicode input method | **refactors `pair.lua`'s provenance read** | 1 | +| 5 | goal panel | new typed LSP request; panel adopter | 3 | +| 6 | `#eval` / `#check` output channel | **new `LspServerSpec` policy field** | 3, 5 | +| 7 | module hierarchy | listview adopter + one typed Rust request | 3 | + +Three of the seven carry risk that is *not* about Lean — stages 1, 2, and +6 each change something every language touches. That is the organizing +principle of the split: **no PR in this arc mixes a cross-cutting +substrate change with Lean feature content.** A reviewer looking at Stage +2 sees only `ensure_server`; a reviewer looking at Stage 3 sees only Lean. + +Ordering notes: + +- **Stage 2 has no Lean in it and could ship independently of this arc.** + It is sequenced here because Lean is the language that makes its absence + a correctness bug rather than an inconvenience, and because Stage 3's + acceptance would otherwise have to encode the broken behavior. +- **Stage 4 does not depend on stages 2–3** and could run in parallel, but + should not: both touch `lsp.lua`/`pair.lua`-adjacent runtime files, and + the #126/#127 lesson is that parallel-safety requires the file split be + agreed *before* either lane starts. Sequential is cheaper. +- **Stage 6 depends on Stage 5** only for the read-only generated-buffer + and panel machinery, which Stage 5 establishes. If Stage 5 slips, Stage + 6 can carry that machinery itself at the cost of duplicating it. + +Stage 1 is deliberately shippable alone. If the arborium grammar turns out +to be worse in practice than its query suggests (see §5, bet 3), that is +discovered at Stage 1 for the cost of Stage 1 — and stages 2 through 7 are +almost entirely independent of grammar quality, since they are driven by +the language server rather than the parse tree. + +## 5. Categorical bets + +Stated so they can be scored, per house style. + +1. **`arborium-lean`'s ABI-15 parser loads under `tree-sitter 0.26` with a + single `tree-sitter` in the graph.** Falsified by `cargo tree -d` + showing a duplicate, or by the loader failing `Parser::set_language`. + Confidence: high — `tree-sitter-language 0.1` exists precisely for this + and roughly fifteen shipped grammars already rely on it. +2. **No protocol change in any stage.** Falsified by any new wire variant. + Confidence: high. +3. **The grammar is good enough that highlighting reads as correct on + ordinary Lean, including Mathlib-style files.** This is the weakest bet + in the lane, and the upstream author's own warning is the reason: Lean's + syntax is user-extensible via macros, so a static grammar necessarily + mis-parses custom notation. Scored against a real fixture set at Stage 1 + acceptance. If it fails, Stage 1 still ships — degraded highlighting on + exotic notation is strictly better than none — but the framing is + revised to say so plainly rather than overselling it. +4. **`$/lean/plainGoal` alone is a useful goal view, without the + `$/lean/rpc/*` widget stack.** Confidence: medium-high — it is exactly + what `lean4-mode` shipped for years before infoview widgets, and + `hasWidgets? = false` is a supported client posture, not a hack. +5. **The abbreviation expander needs no Rust.** Falsified if the one-shot + provenance refactor (Q#LN10) cannot be done in Lua, or if `buf:replace` + inside `buffer.after-edit` re-enters the hook in a way pairing does not + already survive. Confidence: medium — pairing does the same thing, but + over a single codepoint rather than a multi-byte span. +6. **These seven stages reach rough VS Code parity for everything except + the interactive infoview.** Scored honestly rather than aspirationally. + What lands: highlighting, goal view, Unicode input, diagnostics, + hover, completion, goto-definition, symbols, semantic tokens, `#eval` + output, module hierarchy, correct multi-package roots. What does + **not**: interactive/collapsible goals, `Try this` code-action + suggestions, widgets, the term-mode goal on hover, and the + `$/lean/rpc/*` session that powers all of them. That gap is real and + is the arc's eventual destination (§6) — a framing that claimed parity + without it would be overselling. +7. **Stage 2's affinity change breaks no existing language.** Falsified by + any regression in the Rust/Python/Go/TS acceptance suites, or by a + user's hand-spawned server no longer being adopted in a way they + relied on. Confidence: medium-high for the suites, deliberately lower + for hand-spawned servers — Q#LN15's comparison rule changes that case + on purpose, and the acceptance pins it rather than hiding it. + +## 6. Deferred (named) + +Pruned in round 2 — seven former entries are now stages 1–7 (see §0.1). +What remains deferred: + +- **Interactive infoview** — `$/lean/rpc/{connect,call,release,keepAlive}`, + widgets, collapsible goal trees, `Try this` code actions, term-mode goal + on hover. **This is the arc's eventual destination, not a rejection.** + It needs `hasWidgets? = true`, a real RPC session lifecycle with + keep-alive, and a rendering surface for structured rather than plain + goals — plausibly its own multi-stage arc once stages 1–7 are in. Bet 6 + scores what its absence costs. +- **GPU goal band** — blocked on bottom-panel Stage 2 (Q#LN14). The panel + is grid-only until then. +- **A `cursor.after-move` hook** — there is none (Q#LN13), so Stage 5 + polls off `process.after-tick`. A real motion hook would serve the goal + view, `completion.lua`'s cursor-delta heuristic, and the outline/hover + panels alike; it is substrate work that should not be invented inside a + language lane. +- **LSP server reaping / LRU** — Q#LN15's per-root affinity makes + unbounded `lake serve` growth possible. No editor caps this by default + and pmacs will not either in this arc, but the policy question is now + live in a way it was not before. +- **Block-comment toggle** (`/- -/`) and **docstring awareness** + (`/-- -/`) — confirmed as owned by the comment arc's framing, not this + one. +- **`.olean` / `.ilean` handling** (Q#LN3). +- **Lean 3 support** — `.lean` files predating Lean 4 will mis-parse. + Out of scope permanently; Lean 3 is end-of-life. + +## 7. Acceptance + +**Stage 1** + +1. `cargo tree -d` shows exactly one `tree-sitter` version after adding + `arborium-lean`. +2. A `.lean` fixture parses: the loader produces a tree whose root node is + `module` and which is not all-ERROR. +3. Opening `foo.lean` sets `pmacs.buffer.major_mode` to `lean4`. +4. An Emacs `-*- mode: lean -*-` modeline and a Vim `ft=lean` modeline both + resolve to `lean4`. +5. Highlighting produces non-default styles for a comment, a `def` name, a + `theorem` name, a string, and a numeric literal in the fixture. +6. `(sorry)` picks up the `warning` style. +7. **Reverse-direction positive pin (#146).** Every language the four new + capture entries reach asserts its *expected delta* — not that nothing + moved, since these languages necessarily move: + - `rust`, `python`, `javascript`, `javascriptreact`, `typescript`, + `typescriptreact`: a capitalized identifier (`Some`, `MyClass`) picks + up the `constructor` style. All seven entries are covered because + `HIGHLIGHT_QUERY` composition means the JS-family entries inherit the + rule rather than restating it — a regression in composition would + otherwise go unseen. + - `lua`: a table literal's `{` and `}` pick up the `constructor` style. + - `zig`: a character literal picks up `character`; a conditional picks + up `keyword.conditional`. + - `cmake`: a conditional picks up `keyword.conditional`. +8. **Reverse-direction negative pin.** Fixtures in languages verified to + emit **none** of the four capture names render byte-identically to + their pre-change baseline: `markdown`, `json`, `yaml`, `html`, `css`, + `c`, `cpp`, `go`, `toml`, `bash`. + + Rev 1 named Lua and Python here, which was a self-contradiction: both + are retro-painted by `constructor`, so a fixture that did not move + would have been vacuous — the #155 R2 assertion shape. Whichever + fixtures ship, the negative pin must be shown non-vacuous by + confirming it *fails* when a capture the language does emit is added. +9. `M-;` comments and uncomments a Lean line with `--`. +10. Typing `⟨` inserts `⟨⟩` with the point between; likewise `⦃` and `⟮`. + Typing `'` after an identifier does **not** pair. +11. A ` ```lean ` fence and a ` ```lean4 ` fence in a markdown buffer both + highlight as Lean (Q#LN17); a fence with an unknown name still does + not. +12. **No live toolchain required.** The whole Stage 1 suite passes on a + machine with no `lean`, no `lake`, and no configured elan toolchain + (§2.9) — Stage 1 touches no process at all. + +**Stage 2 — multi-root affinity (no Lean content)** + +13. `pmacs.lsp.list()` rows carry `root_uri` and `cwd`. +14. Two files of the **same language in different project roots** spawn + **two** servers, each with its own `rootUri`. Exercised with the fake + server so it is toolchain-free. +15. Two files of the same language in the **same** root reuse **one** + server — the pre-change behavior, pinned so the fix does not become + "always spawn". +16. **Regression pin, per language:** the existing Rust, Python, Go, and + TypeScript attach paths behave unchanged for the single-root case + that is all they exercised before. +17. **Hoist pin:** `project_root_for` is called on the reuse path, and a + function-valued `root` is invoked at most once per directory per + session (Q#LN15's memoization) rather than once per attach. +18. **Hand-spawned server pin:** a server spawned from `init.lua` with + `cwd` but no `root_uri` is *not* adopted by a root-bearing attach — + the deliberate behavior change, asserted rather than discovered. +19. A crashed or stopped server in the matching root is not reused; a new + one spawns. +20. **Loose-file pin (Q#LN15 part 2).** Two **markerless** files of the + same language in **different** directories still share **one** server. + This is the no-change case, and it is the one a naive `(language_id, + root)` key breaks — `project_root_for` never returns nil for a file + with a path, so it must be asserted, not assumed. +21. **Fallback-vs-detected pin.** A file under a real project marker and a + markerless file of the same language get **different** servers, and + the markerless one's server carries the fallback directory as `cwd` + while matching on a nil affinity key. + +**Stage 3 — the Lean language server** + +22. Opening a `.lean` file inside a Lake package spawns one server with + `cwd` and `rootUri` at the package root. +23. **Outermost-root pin:** a file under + `/.lake/packages/dep/…` whose ancestor chain contains two + `lean-toolchain` files resolves to ``, not to `dep`. Run with + `pmacs.project.set_search_boundary` at the fixture root so the + assertion is hermetic. +24. **Boundary pin:** with the search boundary set at the fixture root, a + `lean-toolchain` planted in an ancestor *above* the boundary is not + reached — the resolver stops at the boundary rather than walking past + it. +25. A string-valued `pmacs.lsp.config.lean4.root` still works — the Q#LN8 + generalization is strictly additive. +26. `didOpen` carries `languageId = "lean4"`. +27. **Fallback-latch pin (Q#LN7):** a `lake` stub that exits non-zero — + reproducing §2.9's shimmed-elan state — causes exactly **one** restart + against `lean --server`, and a second failure surfaces an error rather + than looping. The latch does not re-arm within the session. +28. **Probe pin:** a `lake` stub reporting version 3.0.0 triggers the + fallback; one reporting 3.1.0 does not. A stub that never exits does + not block the attach — the optimistic `lake serve` spawn proceeds. +29. A `$/lean/fileProgress` notification delivered through the fake server + reaches a registered `on_notification` subscriber. +30. **Dispatch-integrity pin:** with a Lean subscriber registered, a + `workspace/applyEdit` request in the same drain is still handled — no + event is stolen. +31. A subscriber that raises does not prevent later events in the same + drain from being processed. +32. **Response-seam pin (Q#LN9).** A `send_request` reply reaches its + registered `on_response` one-shot, and the one-shot is **removed + before** invocation — a raising handler is not re-entered. Bites + against rev 2, where no Lua consumed `ev.kind == "response"` at all + and the reply was dropped. +33. **Response dispatch-integrity pin.** With a response subscriber + registered, `workspace/applyEdit` in the same drain is still handled; + a raising response handler does not stop later events in that drain. + Mirrors the notification-side pins above. +34. **Pending-purge pin.** A server that dies with a response outstanding + invokes the pending one-shot with an error and clears it — the + registration does not leak and the awaiting caller does not hang. +35. **Config-preservation pin (Q#LN7).** After the fallback latch fires, + user-supplied `env` / `settings` / `init_options` / `root` on + `pmacs.lsp.config.lean4` survive; only `command` and `args` change. +36. **No-respawn-loop pin.** The latch stops the failing server before + spawning the fallback, so `RestartPolicy` does not respawn the broken + command underneath it. +37. `textDocument/waitForDiagnostics` resolves through the response seam + (Q#LN16). **PATH-and-success-gated live smoke:** if `lake serve` + starts successfully a real elaboration completes and diagnostics + arrive; skipped otherwise, never failed. + +**Stage 4 — the Unicode input method** + +38. `\alpha` + space yields `α`; the whole expansion is a single undo step. +39. `\<>` yields `⟨⟩` with the point between them, from the `$CURSOR` + placeholder. +40. **Pair-collision pin (Q#LN10).** `\[[]]` yields `⟦⟧`: each `[` is + claimed as an extension of the pending abbreviation, so auto-pairing + never inserts a closing `]` into the pending key. Bites against an + ordering where pairing runs first, and against a consumer that claims + only completed expansions rather than pending extensions — **both + failure modes must be shown**, since they are distinct bugs with the + same symptom. +41. `\to` yields `→` eagerly on uniqueness, with no terminator typed. +42. A prefix with no match (`\zzzz` + space) is left as literal text; no + edit is made. +43. Moving the cursor out of a pending abbreviation abandons it. +44. `pmacs.config.set("lean.abbrev", false)` disables expansion; the + setting is read against the typed edit's **source** buffer. +45. Expansion does not fire in a non-`lean4` buffer — including that a + pending abbreviation is never opened there, so `\[` in a Rust buffer + still pairs normally. +46. **Provenance-refactor pin:** the full auto-pairing acceptance suite + passes unchanged, and a bite against the pre-refactor `pair.lua` + confirms the shared-consumer commit is behavior-preserving. + +**Stage 5 — the goal view** + +47. `$/lean/plainGoal` is sent with a position encoded through + `outbound_position` — pinned with a UTF-16 fake server and a + non-ASCII Lean line, which fails against a raw byte column. +48. A non-null `PlainGoal` renders `rendered` into `*lean-goal*`. +49. A null result with the file elaborated renders "no goals". +50. A point inside a range still covered by `$/lean/fileProgress` renders + the elaborating state, not "no goals". +51. **Refresh pin (Q#LN13).** Moving the point to a new position and + driving `process.after-tick` past the debounce issues exactly one new + `$/lean/plainGoal`; ticking again with the point unmoved issues none. +52. **In-flight pin.** A second point move while a request is outstanding + does not issue a concurrent request, and the panel ends on the result + for the *latest* position — a stale response for an abandoned + position never wins. +53. The panel opens at the bottom without stealing focus. +54. `*lean-goal*` rejects a user edit and accepts a module write. +55. **Teardown pin.** After the Lean buffer is killed or the frontend + detaches, driving `process.after-tick` issues **no** further + `$/lean/plainGoal` and writes **nothing** to the panel, and any + outstanding request's one-shot has been purged. + + Worded as an observable because it must be: `pmacs.hook` exposes + `add` / `define` / `list` / `run` and **no `remove`**. A subscription + cannot be torn down, only made inert — so "leaves no subscription" + (rev 2's wording) is untestable and, taken literally, unimplementable. + +**Stage 6 — the output channel** + +56. An information-severity diagnostic from the **Lean** server lands in + `*lean-output*` and **not** in the diagnostic store: no squiggle, no + gutter sign, and the modeline info count stays zero. +57. Warning- and error-severity diagnostics from the Lean server are + unaffected and still reach the store. +58. **Cross-language pin:** an information-severity diagnostic from a + **non-Lean** server still squiggles and still counts — the + `LspServerSpec` policy defaults to a no-op. +59. Output is cleared per publish for the owning document, so a + re-elaborated file does not accumulate stale `#eval` results. +60. Output rows appear in source-position order regardless of publish + order. + +**Stage 7 — module hierarchy** + +61. `$/lean/prepareModuleHierarchy` is sent through the Q#LN12 typed path + (position-bearing), pinned against a UTF-16 fake server. +62. `imports` and `importedBy` each render into the listview panel and are + navigable through the existing `on_visit`. +63. An empty result renders an empty panel with its header, not an error. +64. The panel's `q` returns to the originating buffer, not to another + panel (`listview.lua:118`'s existing rule). + +## 8. Prior art in pmacs + +- **#144 (LaTeX)** — the third-party-republish grammar decision and the + vendored-artifact-with-provenance pattern. +- **#146 (HTML+CSS)** — the global capture table, and the requirement to + pin retro-paint in both directions. Q#LN4 is that lesson applied. +- **#123 (JSON/YAML)** — declarative `pmacs.lsp.config` entries with a + fake-server delivery proof plus PATH-gated live smokes. Stage 3 follows + it, with the extra success-gate §2.9 forces. +- **#110 (auto-pairing)** — `take_typed_edit()` provenance, the fail-closed + discipline on transformed source edits, and Q#AP1's optimistic-classifier + limitation. Stage 4 is built on all three. +- **#127 (config registry)** — `pmacs.config.define` and the + source-buffer-resolution correction. Q#LN10's gate follows + `editing.auto-pair` exactly. +- **#129 (mode system)** — mode-scoped keymaps for Stage 5. +- **#155 (bottom panel)** — `pmacs.window.display` and the panel adopter + shape, for stages 5–7. +- **#113 (compile mode)** — the erroring-intercept read-only generated + buffer idiom (stages 5 and 6), and `process.after-tick` as a debounced + cadence source (Q#LN13). +- **#122 (multi-language injections)** — `pmacs.parse.injection_aliases`, + which Q#LN17 registers into. +- **#94/#95 (LSP panels)** — `pmacs.listview.open` and the + references/outline panel shape that Stage 7 reuses wholesale. From 6ea8d2756e551dc1b33015c53b0b1e792ca53f5c Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 09:49:01 -0400 Subject: [PATCH 13/22] feat(syntax): bundle the Lean 4 grammar (Arc 8 Stage 1, Q#LN1-3) Adds `arborium-lean` 2.18 and one `BUILTIN_LANGUAGES` entry, closing the framing's open verification obligation on the crate choice. Why this crate and not `tree-sitter-lean4` (Q#LN1): the latter depends on `tree-sitter = "0.25"` directly rather than the shared `tree-sitter-language` ABI crate, and `^0.25` excludes our 0.26, so it would fork the graph exactly as the dead `tree-sitter-dockerfile` does. It also exports only `pub fn language()` while its README advertises a `LANGUAGE` const that does not exist, and its package `include` omits `queries/` so it ships no highlights at all. `arborium-lean` uses `tree-sitter-language 0.1` as its sole runtime dep, ships a pre-generated ABI-15 parser plus scanner, and exports real query constants. `cargo tree -d` reports no duplicate `tree-sitter`. The entry is named `lean4`, not `lean` (Q#LN2): `ensure_server` passes `LanguageEntry.name` through as the `didOpen` language_id, and the Lean ecosystem's id is `lean4` -- `lean` is Lean 3, which is end-of-life. It claims `.lean` only; `.olean` is a compiled binary and `.ilean` is JSON metadata (Q#LN3). Four tests. The load-bearing one is `lean4_grammar_loads_and_parses`, which discharges the half of Q#LN1 that could not be settled by reading: `arborium-lean` exports `const fn language() -> LanguageFn` rather than the `LANGUAGE` const every other entry uses, and its README demonstrates usage against a patched tree-sitter core. Neither is supposed to matter, but "supposed to" is not evidence. The fixture parses without error, and -- the part that actually guards a misbuild -- its Unicode operators produce structure rather than degrading silently: the grammar must see a `(arrow)` for the arrow, a `(forall)` for the universal quantifier, and a `(comparison)` for the inequality. The error-free claim is deliberately scoped to the committed fixture. Lean's syntax is user-extensible via macros, so a static grammar mis-parses some legal input by construction; the framing scores that as bet 3 rather than the doc overselling it. Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.lock | 33 ++++++++++ Cargo.toml | 18 ++++++ src/syntax.rs | 170 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 221 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index d7caff6..498f267 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -169,6 +169,27 @@ dependencies = [ "x11rb", ] +[[package]] +name = "arborium-lean" +version = "2.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80b795046d03aae5780c58e746ddaf780f683e36d9efa8f67abbe9bc01299eb5" +dependencies = [ + "arborium-sysroot", + "cc", + "tree-sitter-language", +] + +[[package]] +name = "arborium-sysroot" +version = "2.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59d99d80550b726f9dec7ee6d07118c31e08b10e729ac488eabd4c10603dc841" +dependencies = [ + "cc", + "dlmalloc", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -747,6 +768,17 @@ dependencies = [ "libloading", ] +[[package]] +name = "dlmalloc" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad5208a115eaba24916f7456929832e310a81518c641f93fee4f89aa93aa3675" +dependencies = [ + "cfg-if", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "document-features" version = "0.2.12" @@ -2538,6 +2570,7 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" name = "pmacs" version = "1.0.0" dependencies = [ + "arborium-lean", "codebook-tree-sitter-latex", "crossbeam", "crossterm", diff --git a/Cargo.toml b/Cargo.toml index 589ccaf..4e21592 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -241,6 +241,24 @@ codebook-tree-sitter-latex = "0.6" # engine (see `crate::syntax::BUILTIN_LANGUAGES`). tree-sitter-html = "0.23" tree-sitter-css = "0.25" +# Lean 4 (`.lean`) — Arc 8 Stage 1 (`docs/lean4-mode-framing.md`, Q#LN1). +# `leanprover` ships no tree-sitter grammar (Lean parses with its own +# kernel), so both candidates are third-party. The obvious-looking +# `tree-sitter-lean4` is NOT usable: it depends on `tree-sitter = "0.25"` +# DIRECTLY rather than the shared `tree-sitter-language` ABI crate, which +# `^0.25` makes incompatible with our 0.26 and would fork the graph (the +# same defect that rules out `tree-sitter-dockerfile` above); it exports +# only `pub fn language()` while its README advertises a `LANGUAGE` const +# that does not exist; and its package `include` omits `queries/`, so it +# ships no highlights at all. `arborium-lean` is a republish from the +# arborium grammar collection that does it correctly: `tree-sitter-language +# 0.1` as its sole runtime dep, a pre-generated ABI-15 `parser.c` plus +# `scanner.c` (no CLI at build time), and `HIGHLIGHTS_QUERY` / +# `INJECTIONS_QUERY` / `LOCALS_QUERY` constants. Note the shape: it exports +# `const fn language() -> LanguageFn`, so the entry in +# `crate::syntax::BUILTIN_LANGUAGES` reads `arborium_lean::language().into()` +# rather than the `LANGUAGE.into()` every other entry uses. +arborium-lean = "2.18" # T M4.4 process supervisor: signal sending without `unsafe`. Keep # the feature surface tight to keep build time low. `poll` feeds the # compile-mode group readers (cancellable poll-based reads, Q#CM3). diff --git a/src/syntax.rs b/src/syntax.rs index efbbd4d..edec588 100644 --- a/src/syntax.rs +++ b/src/syntax.rs @@ -1130,6 +1130,33 @@ pub const BUILTIN_LANGUAGES: &[LanguageEntry] = &[ locals_query: &[], injections_query: &[], }, + // Lean 4 (framing `docs/lean4-mode-framing.md`, Arc 8 Stage 1). + // + // The entry is named `lean4`, not `lean` (Q#LN2): this name becomes the + // `language_id` sent in `didOpen` — `ensure_server` at + // `builtin/runtime/lsp.lua:540` passes it straight through — and the + // Lean ecosystem's id is `lean4` (`lean` is Lean 3, which is + // end-of-life). The grammar's own C symbol is `tree_sitter_lean`; that + // is arborium's business, not ours. Stage 3 adds + // `pmacs.lsp.config.lean4` against this name. + // + // Note the loader shape: `arborium-lean` exports `const fn language() -> + // LanguageFn` rather than a `LANGUAGE` const, so this is the one entry + // that calls a function to get the `LanguageFn` before `.into()`. + // + // `.olean` (compiled artifacts) and `.ilean` (JSON metadata) are + // deliberately unclaimed (Q#LN3). Locals and injections are empty + // because the crate ships both as empty strings — Lean has no embedded + // sublanguage worth injecting, and its scoping is far beyond what a + // tree-sitter locals query could model. + LanguageEntry { + name: "lean4", + extensions: &["lean"], + loader: || arborium_lean::language().into(), + highlights_query: &[arborium_lean::HIGHLIGHTS_QUERY], + locals_query: &[], + injections_query: &[], + }, ]; /// LaTeX highlights overlay (framing Q#LX2). The chosen grammar crate @@ -2394,6 +2421,149 @@ mod tests { } } + #[test] + fn builtin_languages_include_lean4() { + // Framing acceptance 1/3 (`docs/lean4-mode-framing.md`). The entry is + // named `lean4` because that name becomes the `didOpen` language_id + // (Q#LN2), and it claims `.lean` ONLY: `.olean` is a compiled binary + // artifact and `.ilean` is JSON metadata (Q#LN3). + let lean = BUILTIN_LANGUAGES + .iter() + .find(|l| l.name == "lean4") + .expect("`lean4` language entry must be present"); + assert!(lean.extensions.contains(&"lean"), "`lean4` claims `.lean`"); + for unclaimed in ["olean", "ilean"] { + assert!( + !lean.extensions.contains(&unclaimed), + "`lean4` must not claim `.{unclaimed}`" + ); + } + assert!( + lean.highlights_query + .contains(&arborium_lean::HIGHLIGHTS_QUERY), + "`lean4` drives highlighting from the crate's query constant, not an overlay" + ); + assert!( + lean.locals_query.is_empty() && lean.injections_query.is_empty(), + "`lean4` ships neither locals nor injections (Q#LN1)" + ); + } + + #[test] + fn lean4_grammar_loads_and_parses() { + // Framing acceptance 2 and the open half of Q#LN1: `arborium-lean` + // exports `const fn language() -> LanguageFn` (not the `LANGUAGE` + // const every other entry uses) over `tree-sitter-language 0.1`, and + // its README demonstrates usage against a `tree_sitter_patched_ + // arborium` core. Neither is supposed to matter — the LanguageFn ABI + // is shared — but "supposed to" is not evidence, so this pins that + // OUR `tree-sitter` 0.26 core accepts it and produces a real tree. + // + // The fixture exercises the grammar's external scanner (`scanner.c` + // supplies a NEWLINE token, so layout-sensitive `def`/`theorem` + // bodies depend on it) and the Unicode operators that make Lean + // Lean — `→`, `∀`, `≥` — which a byte-oriented misbuild would shred. + let reg = SyntaxRegistry::new(); + let language = reg + .language("lean4") + .expect("`lean4` language loads from BUILTIN_LANGUAGES"); + let mut buf = fresh_buffer("Basic.lean"); + buf.apply_edit(EditOp::Insert { + pos: 0, + bytes: "-- a comment\n\ + def fibonacci : Nat → Nat\n\ + \x20 | 0 => 0\n\ + \x20 | n + 1 => n\n\ + \n\ + theorem fib_nonneg : ∀ n, fibonacci n ≥ 0 := by\n\ + \x20 intro n\n\ + \x20 exact Nat.zero_le _\n" + .as_bytes(), + }) + .unwrap(); + let view = ParseView::new(&buf, language, "lean4".to_owned()); + let handle = view.handle(); + let _vid = buf.attach_view(Box::new(view)); + let bundle = parse_synchronously(&handle); + assert_eq!( + bundle.root_tree().root_node().kind(), + "module", + "Lean grammar roots at module" + ); + let sexp = bundle.root_tree().root_node().to_sexp(); + // This specific committed fixture parses cleanly. The claim is + // scoped to the fixture on purpose: Lean's syntax is user-extensible + // via macros, so a static grammar necessarily mis-parses some legal + // input (the upstream grammar says so itself, and the framing scores + // it as bet 3). What a clean parse HERE proves is that the crate is + // wired correctly, not that Lean is fully parseable. + assert!( + !bundle.root_tree().root_node().has_error(), + "the fixture parses without error; got {sexp}" + ); + // `def` and `theorem` sit under a `declaration` wrapper, not directly + // under `module`. + for expected in ["(comment)", "(def ", "(theorem "] { + assert!( + sexp.contains(expected), + "expected `{expected}` in the tree; got {sexp}" + ); + } + // The load-bearing part of this test. A grammar built against a + // mismatched core, or one whose scanner mis-handles multibyte input, + // does not fail loudly — it produces a tree that silently degrades on + // exactly the characters Lean is made of. `→` must become an `arrow`, + // `∀` a `forall`, and `≥` a `comparison`; if these three hold, the + // UTF-8 path through the parser is sound. + for expected in ["(arrow ", "(forall ", "(comparison "] { + assert!( + sexp.contains(expected), + "Unicode operator did not produce `{expected}`; got {sexp}" + ); + } + } + + #[test] + fn lean4_highlights_resolve() { + // The crate's 213-line query must COMPILE against the grammar it + // ships with — the node-name compatibility gate. A query referencing + // a node this grammar version lacks fails here rather than silently + // producing no spans at runtime. + let reg = SyntaxRegistry::new(); + let query = reg + .highlights_query("lean4") + .expect("lean4 highlights compile against the grammar"); + let names = query.capture_names(); + // The four capture names Q#LN4 adds to the GLOBAL theme table are + // present here — this is the forward direction of that decision; the + // reverse direction (what they do to other languages) is pinned in + // `highlight.rs`. + for expected in ["constructor", "character", "keyword.conditional", "warning"] { + assert!( + names.contains(&expected), + "lean4 query uses `@{expected}`, which Q#LN4 adds to the theme; got {names:?}" + ); + } + } + + #[test] + fn language_for_path_resolves_lean_extension() { + let reg = SyntaxRegistry::new(); + assert_eq!( + reg.language_name_for_path("Mathlib/Data/Nat/Basic.lean") + .as_deref(), + Some("lean4"), + "`.lean` resolves to the lean4 grammar" + ); + for unclaimed in ["Basic.olean", "Basic.ilean"] { + assert_ne!( + reg.language_name_for_path(unclaimed).as_deref(), + Some("lean4"), + "{unclaimed} must not resolve to lean4" + ); + } + } + #[test] fn builtin_languages_include_html_and_css() { // Both crate grammars export their query constants (no overlay). HTML From 5207d40cafb633aa994b17ebf251694a76e4ce0c Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 09:51:27 -0400 Subject: [PATCH 14/22] feat(theme): add the four Lean 4 capture entries (Arc 8 Stage 1, Q#LN4) `constructor`, `character`, `keyword.conditional`, and `warning` are the captures the Lean query uses that the global theme table lacked. Three of them are not Lean-only, so this is a deliberate retro-paint of already shipped languages -- the #146 lesson applied on purpose rather than discovered afterwards. The blast radius, measured rather than assumed: * `constructor` reaches SEVEN language entries, not four. The emitting crates are rust, lua, python and javascript, but `tree_sitter_javascript::HIGHLIGHT_QUERY` is concatenated base-first into javascriptreact, typescript and typescriptreact as well. * Its shape is not "constructors". rust/python/javascript tag every capitalized identifier (`#match? "^[A-Z]"`); lua tags every table-constructor brace. So this recolors `None`, every class-cased name, and every Lua `{}` -- all of which rendered as unstyled default text before. * `character` reaches zig only; `keyword.conditional` reaches cmake and zig, which previously flattened it to `keyword`; `warning` reaches no other grammar and exists for Lean's `sorry`. The alternative was an in-repo overlay renaming the captures (the #144 LaTeX pattern), which forks a 213-line query we would then own and hand-merge on every crate bump. There is no middle option: styling Lean's constructors without touching the other seven entries requires renaming the capture, which requires the overlay. Pinned in both directions, per #146: * the positive breadth pin asserts all seven entries emit `@constructor` at the QUERY level -- chosen over per-fixture checks because the base-query composition is the fragile part; if someone stops concatenating the JS base into `typescript`, this fails while any single-language fixture still passes; * two grid pins prove the theme entry reaches painted cells, and a third records that a variant in CALL position keeps `@function` -- the difference between "capitalized identifiers recolor" and "enum variants recolor", only the first of which is true; * the negative pin asserts ten languages (markdown, json, yaml, html, css, c, cpp, go, toml, bash) emit none of the four names, with a non-vacuity check that the same predicate finds each name where it does occur. Rev 1 of the framing named Lua and Python in that negative pin, which was a self-contradiction -- both are retro-painted by `constructor`, so the assertion would have been vacuous in the #155 R2 shape. Review round 1 caught it. Full lib suite (1,824) and the required-GPU gate (152) pass unchanged, so no existing assertion depended on these captures being unstyled. Co-Authored-By: Claude Opus 5 (1M context) --- src/highlight.rs | 193 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) diff --git a/src/highlight.rs b/src/highlight.rs index 2c7369f..633ec6f 100644 --- a/src/highlight.rs +++ b/src/highlight.rs @@ -174,6 +174,41 @@ impl Theme { // prefix-walks to `tag`. ("tag", fg(5)), ("attribute", fg(3)), + // Lean 4 (framing Q#LN4). These four are the captures the Lean + // query uses that the set above lacks — but three of them are + // NOT Lean-only, and adding them here changes languages that + // already ship. That is the #146 lesson (`attribute`, above, + // retro-painted rust/lua/yaml) and it is deliberate, not + // incidental: + // + // * `constructor` reaches SEVEN entries — rust, lua, python, + // javascript, and (because `tree_sitter_javascript:: + // HIGHLIGHT_QUERY` is concatenated base-first into them) + // javascriptreact, typescript, typescriptreact. Its shape is + // not "constructors": rust/python/javascript tag every + // capitalized identifier (`#match? "^[A-Z]"`), and lua tags + // every table-constructor brace. So this recolors `Some`, + // `None`, `Ok`, `Err`, every class-cased name, and every Lua + // `{}`. All of those render as unstyled default text today. + // * `character` reaches zig only. + // * `keyword.conditional` reaches cmake and zig, which + // currently flatten it to `keyword`; giving it + // `keyword.control`'s style makes their conditionals read the + // way rust's already do. + // * `warning` reaches no other grammar. It exists for Lean's + // `sorry` — an unproved goal, the single most important thing + // to see in a proof file. + // + // The alternative was an in-repo query overlay renaming these + // into the existing vocabulary (the #144 LaTeX pattern), which + // would fork a 213-line query we would then own and hand-merge + // on every crate bump. There is no middle option: styling Lean's + // constructors without touching the other seven entries requires + // renaming the capture, which requires the overlay. + ("constructor", fg(11)), + ("character", fg(2)), + ("keyword.conditional", fg_bold(13)), + ("warning", fg_bold(1)), ]; let by_capture = entries .iter() @@ -1484,6 +1519,164 @@ mod tests { ); } + /// Paint `src` as `language` into a one-row grid and return the style + /// at column `col`. Shared by the Q#LN4 retro-paint pins below. + fn painted_fg_at( + language_name: &str, + file: &str, + src: &str, + col: u32, + ) -> pmacs_protocol::cell::Color { + use crate::buffer::{Buffer, BufferId, EditOp}; + use crate::cell::{Cell, CellSize}; + use crate::syntax::{ParseView, SyntaxRegistry}; + + let reg = SyntaxRegistry::new(); + let language = reg.language(language_name).expect("grammar loads"); + let mut buf = Buffer::new(BufferId::next(), file); + buf.apply_edit(EditOp::Insert { + pos: 0, + bytes: src.as_bytes(), + }) + .unwrap(); + let view = ParseView::new(&buf, language, language_name.to_owned()); + let handle = view.handle(); + let _vid = buf.attach_view(Box::new(view)); + let mut req = handle.make_request(); + req.injection_aliases = reg.injection_alias_snapshot(); + let bundle = crate::syntax::run_parse(req).expect("parse"); + handle.install(reg.resolve_layer_queries(&bundle)); + + let mut hv = SyntaxHighlightView::new(handle, reg.theme()); + let (rows, cols) = (1usize, 40usize); + let mut backing: Vec = vec![Cell::default(); rows * cols]; + let mut grid = CellGrid { + cells: &mut backing, + stride: cols as u32, + size: CellSize::new(rows as u32, cols as u32), + }; + let viewport = Viewport { + buffer_start: 0, + buffer_end: u64::MAX, + cell_origin: CellCoord::new(0, 0), + cell_size: CellSize::new(rows as u32, cols as u32), + gutter_w: 0, + folds: None, + }; + hv.render(&buf, viewport, &mut grid); + grid.get(CellCoord::new(0, col)).style.fg + } + + /// Does `language`'s compiled highlight query use `capture`? + fn query_uses_capture(language: &str, capture: &str) -> bool { + let reg = crate::syntax::SyntaxRegistry::new(); + let Some(query) = reg.highlights_query(language) else { + panic!("{language} has no highlights query"); + }; + query.capture_names().contains(&capture) + } + + #[test] + fn lean4_constructor_capture_retro_paints_the_whole_javascript_family() { + // Framing acceptance 7 (Q#LN4), the breadth half. `constructor` was + // added for Lean, but four crates emit it — and because + // `tree_sitter_javascript::HIGHLIGHT_QUERY` is concatenated + // base-first into the react/typescript entries + // (`src/syntax.rs`), it reaches SEVEN language entries, not four. + // + // Asserted at the query level rather than per-fixture precisely + // because the composition is the fragile part: if someone stops + // concatenating the JS base query into `typescript`, this fails + // while any single-language fixture would still pass. + for language in [ + "rust", + "lua", + "python", + "javascript", + "javascriptreact", + "typescript", + "typescriptreact", + ] { + assert!( + query_uses_capture(language, "constructor"), + "`{language}` emits @constructor, so Q#LN4's entry retro-paints it" + ); + } + } + + #[test] + fn lean4_capture_additions_paint_rust_constructors_and_lua_braces() { + // Framing acceptance 7, the "actually reaches painted cells" half — + // a query-name check alone would not prove the theme entry resolves. + // Both of these rendered as unstyled default text before Q#LN4. + use pmacs_protocol::cell::Color; + + // `None` at col 8 — a bare capitalized identifier, which is what the + // rust query's `#match? "^[A-Z]"` tags. Note that `Some(1)` does NOT + // work here: in call position a narrower `@function` pattern wins and + // paints fg 4. The distinction is worth keeping in the test, because + // it is the difference between "capitalized identifiers recolor" and + // "enum variants recolor" — only the former is true. + assert_eq!( + painted_fg_at("rust", "a.rs", "let x = None;\n", 8), + Color::Indexed(11), + "a bare Rust capitalized identifier paints the shared @constructor style" + ); + // `Some` in pattern position (col 10) does reach @constructor. + assert_eq!( + painted_fg_at("rust", "a.rs", "match v { Some(z) => z, None => 0 };\n", 10), + Color::Indexed(11), + "a Rust pattern-position variant paints the shared @constructor style" + ); + // ...but in CALL position the narrower @function pattern wins. Pinned + // so the blast radius recorded in the framing stays accurate. + assert_eq!( + painted_fg_at("rust", "a.rs", "let e = Err(1);\n", 8), + Color::Indexed(4), + "a called variant keeps @function, not @constructor" + ); + // Lua tags the table-constructor BRACES, not a name: `{` at col 8. + assert_eq!( + painted_fg_at("lua", "a.lua", "local t = {}\n", 10), + Color::Indexed(11), + "a Lua table brace paints the shared @constructor style" + ); + } + + #[test] + fn lean4_capture_additions_do_not_reach_unrelated_languages() { + // Framing acceptance 8 — the negative pin, redrawn in review round 1. + // + // Rev 1 named Lua and Python here, which was a self-contradiction: + // both are retro-painted by `constructor`, so a "nothing moved" + // assertion over them would have been vacuous — the #155 R2 shape. + // These ten emit NONE of the four names, verified by grep over the + // crate queries in the dependency graph. + // + // Stated at the query level, which is stronger than a fixture + // snapshot: it holds for every construct in the language, not just + // the one a fixture happened to exercise. + const ADDED: [&str; 4] = ["constructor", "character", "keyword.conditional", "warning"]; + for language in [ + "markdown", "json", "yaml", "html", "css", "c", "cpp", "go", "toml", "bash", + ] { + for capture in ADDED { + assert!( + !query_uses_capture(language, capture), + "`{language}` must not emit @{capture}; Q#LN4 would silently restyle it" + ); + } + } + + // Non-vacuity: the same predicate must find each name where it DOES + // occur. Without this, a `query_uses_capture` that always returned + // false would pass the loop above. + assert!(query_uses_capture("lean4", "constructor")); + assert!(query_uses_capture("zig", "character")); + assert!(query_uses_capture("cmake", "keyword.conditional")); + assert!(query_uses_capture("lean4", "warning")); + } + #[test] fn web_grid_paints_html_tag_and_attribute() { // Q#WEB4 acceptance: the two capture entries this lane adds (`tag`, From 0c922682c0d575cc6e270c393382fe9de0364a70 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 09:59:48 -0400 Subject: [PATCH 15/22] feat(lean4): editing surface + Stage 1 acceptance (Q#LN5, LN6, LN17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes Arc 8 Stage 1: the Lua-side tables that turn a recognized grammar into a usable mode, plus the acceptance suite for all twelve framing criteria. comment.lua -- `lean4 = "--"` (Q#LN5). Line comments only; Lean's block comment `/- -/` and docstring `/-- -/` belong to the comment arc's own named deferral and this lane does not front-run it. pair.lua -- `⟨⟩`, `⦃⦄`, `⟮⟯` alongside the ASCII brackets (Q#LN6). The anonymous constructor is among the most-typed constructs in Lean; omitting it would make the pair set feel broken. The other two ride along because the Stage 4 input method can produce them, and a bracket the pair set does not understand is worse than one it does. All three sit outside the nine built-in pair chars, so per Q#AP1 their undo is cross-peer-degraded -- the documented, pre-existing limitation of user-extended pairs. No `''`: Lean uses the prime as an identifier suffix (`h'`, `foo'`), the same reason Rust excludes it. syntax.lua -- the `lean` -> `lean4` modeline alias (Q#LN2), so an Emacs `-*- mode: lean -*-` or a Vim `ft=lean` line is not stranded by the entry being named `lean4`. syntax.rs -- the `lean` -> `lean4` injection alias (Q#LN17), so both ```lean and ```lean4 fences highlight. The Lean 3 spelling is mapped forward deliberately: a ```lean fence is overwhelmingly Lean 4 in practice. highlight.rs -- `warning` moves from bold red to bold BRIGHT red. Writing the test found the collision: `number` is plain `fg(1)`, so `sorry` and the literal `42` beside it were the same colour, differing only in the bold flag. `sorry` means "admitted, not proved" and is the one token in a proof file a reader must never skim past, so it now gets the loudest entry in the table and the test asserts the full style rather than the colour. Twelve criteria, seventeen tests. Notes on the ones that could have been vacuous: * acc4 uses a `.txt` fixture, not `.lean` -- on a `.lean` path the extension alone yields `lean4` and the assertion would pass with the alias table empty. acc4b removes the alias and pins that the raw name survives, so acc4 cannot silently stop testing anything. * acc11 goes through the real `_parse_now` injection path and asserts a `lean4` CHILD LAYER appears. `pmacs.parse.injection_aliases` is a documented write-only proxy, so an alias-table read would have proven nothing about the parser; acc11b pins that a misspelled fence still resolves to nothing. * acc12 asserts through the process supervisor and the server list that opening a Lean buffer spawns nothing. This is not decorative: the machine this arc was scouted on has elan installed with no default toolchain, where `lake --version` itself fails, and Stage 1 must be unaffected by that. Gates: fmt and strict workspace clippy clean; 1,826 default + 2,003 CRDT library tests; lean4 Stage 1 9, comment toggle 14, auto-pair 45, injection 4; M4 121; required GPU 152; isolated-config workspace sweep 3,150 across 90 suites; `git diff --check` clean. Co-Authored-By: Claude Opus 5 (1M context) --- builtin/runtime/comment.lua | 4 + builtin/runtime/pair.lua | 16 ++ builtin/runtime/syntax.lua | 5 + src/highlight.rs | 106 +++++++++- src/syntax.rs | 6 + tests/lean4_stage1_acceptance.rs | 320 +++++++++++++++++++++++++++++++ 6 files changed, 455 insertions(+), 2 deletions(-) create mode 100644 tests/lean4_stage1_acceptance.rs diff --git a/builtin/runtime/comment.lua b/builtin/runtime/comment.lua index 7ee91e8..a9912d6 100644 --- a/builtin/runtime/comment.lua +++ b/builtin/runtime/comment.lua @@ -39,6 +39,10 @@ pmacs.comment.strings = { sh = "#", toml = "#", yaml = "#", + -- Lean 4 (framing Q#LN5). `--` only: Lean's block comment is `/- -/` and + -- its docstring `/-- -/`, but block-comment toggling is the comment arc's + -- own named deferral and this lane does not front-run it. + lean4 = "--", } -- Start of the line containing `pos`: chunked backward scan for the diff --git a/builtin/runtime/pair.lua b/builtin/runtime/pair.lua index 6d014d4..9ed9d1f 100644 --- a/builtin/runtime/pair.lua +++ b/builtin/runtime/pair.lua @@ -62,6 +62,22 @@ pmacs.pair.sets = { markdown = { "()", "[]", "{}", '""', "``" }, sh = { "()", "[]", "{}", '""', "''" }, bash = { "()", "[]", "{}", '""', "''" }, + -- Lean 4 (framing Q#LN6). `⟨⟩` (anonymous constructor) is among the + -- most-typed constructs in Lean and omitting it would make the pair set + -- feel broken; `⦃⦄` (strict implicit binder) and `⟮⟯` ride along because + -- the Stage 4 input method can produce them (`\{{}}`, `\([])'`) and a + -- bracket the pair set does not understand is worse than one it does. + -- + -- All three are OUTSIDE the nine built-in pair chars, so per Q#AP1 their + -- opener is a source-peer op and their closer a daemon-peer op: their undo + -- is cross-peer-degraded. That is the documented, pre-existing limitation + -- of user-extended pairs, whose general fix is chronological cross-peer + -- undo arbitration (named substrate work). + -- + -- No `''`: Lean uses `'` as a primed-identifier suffix (`h'`, `foo'`), so + -- pairing it would fight the user constantly. Same reasoning that excludes + -- it for Rust. + lean4 = { "()", "[]", "{}", "⟨⟩", "⦃⦄", "⟮⟯", '""' }, } -- Length of the well-formed UTF-8 sequence starting at `s[i]`, or nil diff --git a/builtin/runtime/syntax.lua b/builtin/runtime/syntax.lua index 812e621..50dad82 100644 --- a/builtin/runtime/syntax.lua +++ b/builtin/runtime/syntax.lua @@ -227,6 +227,11 @@ local default_modeline_aliases = { yml = "yaml", makefile = "make", docker = "dockerfile", + -- Lean 4 (framing Q#LN2). The grammar entry is named `lean4` because that + -- name becomes the `didOpen` language_id, but an Emacs `-*- mode: lean -*-` + -- or a Vim `ft=lean` line is what people actually write, so neither + -- spelling strands a file. + lean = "lean4", } for name, language in pairs(default_modeline_aliases) do if pmacs.parse.modeline_aliases[name] == nil then diff --git a/src/highlight.rs b/src/highlight.rs index 633ec6f..642b0a0 100644 --- a/src/highlight.rs +++ b/src/highlight.rs @@ -208,7 +208,12 @@ impl Theme { ("constructor", fg(11)), ("character", fg(2)), ("keyword.conditional", fg_bold(13)), - ("warning", fg_bold(1)), + // Bold BRIGHT red, deliberately the loudest entry in the table + // and deliberately distinct from `number`'s plain `fg(1)`: in a + // proof file `sorry` means "this is admitted, not proved", which + // is the one thing a reader must never skim past. Plain `fg(1)` + // would have collided with every numeric literal on colour alone. + ("warning", fg_bold(9)), ]; let by_capture = entries .iter() @@ -1527,6 +1532,17 @@ mod tests { src: &str, col: u32, ) -> pmacs_protocol::cell::Color { + painted_style_at(language_name, file, src, col).fg + } + + /// As [`painted_fg_at`], but returns the whole style — needed where a + /// colour alone does not discriminate (Lean's `warning` vs `number`). + fn painted_style_at( + language_name: &str, + file: &str, + src: &str, + col: u32, + ) -> pmacs_protocol::cell::Style { use crate::buffer::{Buffer, BufferId, EditOp}; use crate::cell::{Cell, CellSize}; use crate::syntax::{ParseView, SyntaxRegistry}; @@ -1564,7 +1580,7 @@ mod tests { folds: None, }; hv.render(&buf, viewport, &mut grid); - grid.get(CellCoord::new(0, col)).style.fg + grid.get(CellCoord::new(0, col)).style } /// Does `language`'s compiled highlight query use `capture`? @@ -1576,6 +1592,92 @@ mod tests { query.capture_names().contains(&capture) } + #[test] + fn lean4_grid_paints_comment_keyword_name_operator_and_number() { + // Framing acceptance 5: the grammar plus the crate query plus the + // theme table actually produce distinct styles on a painted grid. + // Asserted end-to-end rather than at the query level because a + // capture that resolves to `Style::default()` is indistinguishable + // from no capture at all to a reader. + use pmacs_protocol::cell::Color; + + // `-- c` — the whole comment run. + assert_eq!( + painted_fg_at("lean4", "a.lean", "-- c\n", 0), + Color::Indexed(8), + "a Lean line comment paints the comment style" + ); + + // `def foo : Nat := 42` + let src = "def foo : Nat := 42\n"; + assert_eq!( + painted_fg_at("lean4", "a.lean", src, 0), + Color::Indexed(5), + "`def` paints the keyword style" + ); + assert_eq!( + painted_fg_at("lean4", "a.lean", src, 4), + Color::Indexed(4), + "the definition's name paints the function style" + ); + assert_eq!( + painted_fg_at("lean4", "a.lean", src, 14), + Color::Indexed(6), + "`:=` paints the operator style" + ); + assert_eq!( + painted_fg_at("lean4", "a.lean", src, 17), + Color::Indexed(1), + "a numeric literal paints the number style" + ); + + // A string literal, and `theorem` as a second declaration keyword. + assert_eq!( + painted_fg_at("lean4", "a.lean", "def s := \"hi\"\n", 9), + Color::Indexed(2), + "a string literal paints the string style" + ); + assert_eq!( + painted_fg_at("lean4", "a.lean", "theorem t : True := trivial\n", 0), + Color::Indexed(5), + "`theorem` paints the keyword style" + ); + assert_eq!( + painted_fg_at("lean4", "a.lean", "theorem t : True := trivial\n", 8), + Color::Indexed(4), + "the theorem's name paints the function style" + ); + } + + #[test] + fn lean4_sorry_paints_the_warning_style_distinctly_from_a_number() { + // Framing acceptance 6. `sorry` admits a goal without proving it — + // in a proof file it is the single most important token to notice, + // and it is why Q#LN4 adds a `warning` entry at all. + // + // The style is asserted in FULL, not by colour: `number` and the + // first-choice `warning` colour were both indexed red, so a + // colour-only assertion would have passed with `sorry` painted + // exactly like the literal `42` beside it. That is the whole failure + // this test exists to prevent. + use pmacs_protocol::cell::Color; + + let sorry = painted_style_at("lean4", "a.lean", "theorem t : True := sorry\n", 20); + assert_eq!( + sorry.fg, + Color::Indexed(9), + "`sorry` paints the warning colour" + ); + assert!(sorry.bold, "`sorry` is bold"); + + let number = painted_style_at("lean4", "a.lean", "def n := 42\n", 9); + assert_ne!( + (sorry.fg, sorry.bold), + (number.fg, number.bold), + "`sorry` must be visually distinct from a numeric literal" + ); + } + #[test] fn lean4_constructor_capture_retro_paints_the_whole_javascript_family() { // Framing acceptance 7 (Q#LN4), the breadth half. `constructor` was diff --git a/src/syntax.rs b/src/syntax.rs index edec588..bc6acf9 100644 --- a/src/syntax.rs +++ b/src/syntax.rs @@ -251,6 +251,12 @@ pub fn default_injection_aliases() -> HashMap { ("golang", "go"), ("yml", "yaml"), ("md", "markdown"), + // Lean 4 (framing Q#LN17). A ```lean fence is overwhelmingly Lean 4 + // in practice, so the Lean 3 spelling is deliberately mapped forward + // rather than left unresolved. `lean4` needs no alias — it is the + // entry name. `lean4-mode` does the equivalent through + // `markdown-code-lang-modes`. + ("lean", "lean4"), ] .into_iter() .map(|(a, b)| (a.to_owned(), b.to_owned())) diff --git a/tests/lean4_stage1_acceptance.rs b/tests/lean4_stage1_acceptance.rs new file mode 100644 index 0000000..ae65545 --- /dev/null +++ b/tests/lean4_stage1_acceptance.rs @@ -0,0 +1,320 @@ +//! Lean 4 mode, Stage 1 acceptance (Arc 8, `docs/lean4-mode-framing.md`). +//! +//! Covers the framing's Stage 1 criteria that live above the Rust +//! substrate — major mode, modeline aliasing, comment toggle, the pair +//! set, and markdown fence injection. Criteria 1, 2, and the Q#LN4 +//! retro-paint pins (7, 8) are unit tests in `src/syntax.rs` and +//! `src/highlight.rs`, where the theme table and grammar registry live. +//! +//! Dispatch-driven, following `comment_toggle_acceptance`: `M-;` and +//! typed characters go through `dispatch_key` so the real command +//! boundary and typed-edit provenance are exercised. Buffers are +//! file-backed (language detection needs a path); each editor gets a +//! private tempdir `StateDir` and an emptied `pmacs.lsp.config` so +//! nothing spawns a language server — Stage 1 has no LSP at all. +//! +//! Criterion 12 is the reason this suite touches no process: it must +//! pass on a machine with no `lean`, no `lake`, and no configured elan +//! toolchain. That is not hypothetical — the machine this arc was +//! scouted on has elan installed with no default toolchain, where +//! `lake --version` itself fails. + +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; +use pmacs::editor::EditorState; +use pmacs::lua_bindings::StateDir; +use pmacs::protocol::FrontendId; +use std::path::PathBuf; +use std::sync::atomic::{AtomicUsize, Ordering}; + +fn fresh_state_dir() -> PathBuf { + static SEQ: AtomicUsize = AtomicUsize::new(0); + let dir = std::env::temp_dir().join(format!( + "pmacs-lean4-{}-{}", + std::process::id(), + SEQ.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn editor(state_dir: &std::path::Path) -> EditorState { + let s = EditorState::new(); + s.lua_host.lua().remove_app_data::(); + s.lua_host + .lua() + .set_app_data(StateDir(state_dir.to_path_buf())); + exec(&s, "pmacs.lsp.config = {}"); + s +} + +fn write_file(dir: &std::path::Path, name: &str, body: &str) -> String { + let p = dir.join(name); + std::fs::write(&p, body).unwrap(); + p.display().to_string() +} + +fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent { + KeyEvent { + code, + modifiers: mods, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + } +} + +fn alt(s: &mut EditorState, c: char) { + s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Char(c), KeyModifiers::ALT)); +} + +fn type_str(s: &mut EditorState, text: &str) { + for ch in text.chars() { + s.dispatch_key( + FrontendId::LOCAL, + key(KeyCode::Char(ch), KeyModifiers::NONE), + ); + } +} + +fn exec(s: &EditorState, src: &str) { + s.lua_host.lua().load(src.to_string()).exec().unwrap(); +} + +fn eval(s: &EditorState, src: &str) -> T { + s.lua_host.lua().load(src.to_string()).eval().unwrap() +} + +fn buffer_text(s: &EditorState) -> String { + let b: mlua::String = eval( + s, + "local b = pmacs.window.buffer(); return b:slice(0, b:len())", + ); + String::from_utf8_lossy(&b.as_bytes()).into_owned() +} + +fn cursor(s: &EditorState) -> i64 { + eval(s, "return pmacs.editor.cursor()") +} + +/// Fresh editor visiting `name` (created in the state tempdir) with +/// `body` on disk, cursor at 0. +fn editor_visiting(name: &str, body: &str) -> EditorState { + let dir = fresh_state_dir(); + let s = editor(&dir); + let f = write_file(&dir, name, body); + exec(&s, &format!("pmacs.buffer.find_or_open({f:?})")); + exec(&s, "pmacs.editor.goto_byte(0)"); + s +} + +fn major_mode(s: &EditorState) -> Option { + eval(s, "return pmacs.buffer.major_mode(pmacs.window.buffer())") +} + +// --------------------------------------------------------------------------- +// Criterion 3 — major mode +// --------------------------------------------------------------------------- + +#[test] +fn acc3_opening_a_lean_file_sets_the_lean4_major_mode() { + let s = editor_visiting("Basic.lean", "def x : Nat := 1\n"); + assert_eq!( + major_mode(&s).as_deref(), + Some("lean4"), + "a .lean file carries the lean4 major mode" + ); +} + +// --------------------------------------------------------------------------- +// Criterion 4 — modeline aliasing (Q#LN2) +// --------------------------------------------------------------------------- + +#[test] +fn acc4_emacs_and_vim_modelines_spelling_lean_resolve_to_lean4() { + // The grammar entry is `lean4`, but `-*- mode: lean -*-` and `ft=lean` + // are what people write. Both must land on the same mode, or a file + // with an explicit modeline is stranded with no grammar. + // + // Deliberately on a `.txt` path: if the fixture were `.lean`, the + // extension alone would produce `lean4` and the assertion would pass + // with the alias table empty — the vacuous shape. + for body in [ + "-- -*- mode: lean -*-\ndef x : Nat := 1\n", + "-- vim: ft=lean\ndef x : Nat := 1\n", + ] { + let s = editor_visiting("modeline.txt", body); + assert_eq!( + major_mode(&s).as_deref(), + Some("lean4"), + "modeline {body:?} resolves through the alias to lean4" + ); + } +} + +#[test] +fn acc4b_the_lean_alias_is_load_bearing() { + // Non-vacuity guard for acc4: with the alias removed, the same + // fixture resolves to the raw `lean` name instead. If this ever + // reports `lean4`, acc4 is proving nothing. + let s = editor_visiting("modeline.txt", "x\n"); + exec(&s, "pmacs.parse.modeline_aliases.lean = nil"); + let dir = fresh_state_dir(); + let f = write_file(&dir, "other.txt", "-- -*- mode: lean -*-\ndef x := 1\n"); + exec(&s, &format!("pmacs.buffer.find_or_open({f:?})")); + assert_eq!( + major_mode(&s).as_deref(), + Some("lean"), + "without the alias the modeline name is not normalized" + ); +} + +// --------------------------------------------------------------------------- +// Criterion 9 — comment toggle (Q#LN5) +// --------------------------------------------------------------------------- + +#[test] +fn acc9_comment_toggle_round_trips_with_the_dash_dash_prefix() { + let mut s = editor_visiting("Basic.lean", "def x : Nat := 1\ndef y : Nat := 2\n"); + exec(&s, "pmacs.editor.goto_byte(0)"); + alt(&mut s, ';'); + assert_eq!( + buffer_text(&s), + "-- def x : Nat := 1\ndef y : Nat := 2\n", + "M-; comments a Lean line with `-- `" + ); + // Round trip, including the padding space. + exec(&s, "pmacs.editor.goto_byte(0)"); + alt(&mut s, ';'); + assert_eq!( + buffer_text(&s), + "def x : Nat := 1\ndef y : Nat := 2\n", + "M-; uncomments it exactly" + ); +} + +// --------------------------------------------------------------------------- +// Criterion 10 — pairs (Q#LN6) +// --------------------------------------------------------------------------- + +#[test] +fn acc10_lean_bracket_pairs_close_and_the_prime_does_not() { + // The three Unicode brackets are the reason this decision exists: all + // are outside the nine built-in pair chars, so they exercise the + // user-extended pair path rather than the frontends' optimistic + // classifier. + for (opener, expected) in [("⟨", "⟨⟩"), ("⦃", "⦃⦄"), ("⟮", "⟮⟯")] { + let mut s = editor_visiting("Basic.lean", ""); + exec(&s, "pmacs.editor.goto_byte(0)"); + type_str(&mut s, opener); + assert_eq!( + buffer_text(&s), + expected, + "typing {opener} inserts the closing half" + ); + assert_eq!( + cursor(&s), + i64::try_from(opener.len()).expect("opener length fits"), + "the point sits between the pair" + ); + } +} + +#[test] +fn acc10b_the_prime_suffix_does_not_pair_in_lean() { + // Lean uses `'` as a primed-identifier suffix (`h'`, `foo'`), so + // pairing it would fight the user on nearly every proof. + let mut s = editor_visiting("Basic.lean", ""); + exec(&s, "pmacs.editor.goto_byte(0)"); + type_str(&mut s, "h'"); + assert_eq!( + buffer_text(&s), + "h'", + "the prime is a suffix in Lean, not an opener" + ); +} + +// --------------------------------------------------------------------------- +// Criterion 11 — markdown fences (Q#LN17) +// --------------------------------------------------------------------------- + +/// Parse `src` as markdown and return the child layer language names. +/// +/// Goes through the real `_parse_now` injection path rather than reading +/// the alias table: `pmacs.parse.injection_aliases` is a documented +/// WRITE-ONLY proxy (the canonical map lives Rust-side), so an +/// alias-table read would prove nothing about what the parser does. +fn markdown_layer_languages(src: &[u8]) -> Vec { + let state = EditorState::new(); + let buf_id = state + .lua_host + .registry() + .borrow_mut() + .create_from_bytes("doc.md".to_owned(), src); + state + .lua_host + .lua() + .globals() + .set("BUF", pmacs::lua_bindings::BufferIdLua(buf_id)) + .expect("bind BUF"); + state + .lua_host + .lua() + .load("pmacs.parse._parse_now(BUF, 'markdown')") + .exec() + .expect("synchronous parse"); + let bundle = state + .syntax_registry + .view(buf_id) + .and_then(|h| h.current()) + .expect("installed bundle"); + bundle + .layers + .iter() + .map(|l| l.language_name.clone()) + .collect() +} + +#[test] +fn acc11_lean_and_lean4_markdown_fences_both_inject_the_lean_grammar() { + // Both spellings must resolve to the same grammar: `lean4` is the entry + // name and `lean` goes through the injection alias. A ```lean fence is + // overwhelmingly Lean 4 in practice, which is why the Lean 3 spelling + // is mapped forward rather than left unresolved (Q#LN17). + for fence in ["lean", "lean4"] { + let src = format!("# Doc\n\n```{fence}\ndef x : Nat := 1\n```\n"); + let langs = markdown_layer_languages(src.as_bytes()); + assert!( + langs.iter().any(|l| l == "lean4"), + "```{fence} injects a lean4 child layer; got {langs:?}" + ); + } +} + +#[test] +fn acc11b_an_unknown_fence_name_still_injects_nothing() { + // Non-vacuity guard for acc11: the alias must be what resolves `lean`, + // not some catch-all that would light up any fence name. + let langs = markdown_layer_languages(b"# Doc\n\n```leen\ndef x := 1\n```\n"); + assert!( + !langs.iter().any(|l| l == "lean4"), + "a misspelled fence must not reach the lean4 grammar; got {langs:?}" + ); +} + +// --------------------------------------------------------------------------- +// Criterion 12 — no toolchain required +// --------------------------------------------------------------------------- + +#[test] +fn acc12_stage1_spawns_no_process_and_needs_no_lean_toolchain() { + // Stage 1 is grammar + Lua tables only. Opening a Lean file must not + // reach for `lake`, `lean`, or `elan` — the LSP arrives in Stage 3, and + // even then it is fallible by design (Q#LN7). + // + // Asserted through the process supervisor rather than by inspection: + // opening the file leaves the child-process list exactly as it was. + let s = editor_visiting("Basic.lean", "def x : Nat := 1\n"); + let procs: i64 = eval(&s, "return #pmacs.process.list()"); + assert_eq!(procs, 0, "opening a Lean buffer spawns no child process"); + let servers: i64 = eval(&s, "return #pmacs.lsp.list()"); + assert_eq!(servers, 0, "Stage 1 attaches no language server"); +} From 34767d332d30b8a8c42176fc63e9a93b35f02a21 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 10:18:27 -0400 Subject: [PATCH 16/22] fix(test): make acc12 pin the claim it names (review round 1) Review finding: acc12's server-list assertion could not fail for the regression class it was written to catch. The shared `editor()` helper runs `pmacs.lsp.config = {}` before any buffer opens, so `#pmacs.lsp.list() == 0` holds for every language regardless of what Stage 1 ships -- a Stage-3 front-run that added `pmacs.lsp.config.lean4` in a builtin runtime file would have slipped straight past it. The same vacuous-assertion shape as #155 R2. acc12 now asserts the actual claim against a PRISTINE `EditorState`, before any config wipe: no builtin runtime file defines `pmacs.lsp.config.lean4`. A non-vacuity check pins that the same lookup finds `pmacs.lsp.config.rust`, so this cannot pass merely because the table is empty or absent. Bite-verified: adding `pmacs.lsp.config.lean4 = ... { command = "lake", args = { "serve" } }` to `builtin/runtime/lsp.lua` fails the test; the stub was reverted. The process-list half is kept and its comment now says why it survives the wipe: a direct probe spawn from a future `lean.lua` shows up there whatever `pmacs.lsp.config` contains. Also fixes a stale column in a `highlight.rs` comment -- the Lua table brace in `local t = {}` is at col 10, which is what the code already used. Gates rerun: fmt and strict workspace clippy clean; 1,826 default + 2,003 CRDT library tests; lean4 Stage 1 9/9; M4 121; required GPU 152; isolated-config workspace sweep 3,150 across 90 suites; diff check clean. Co-Authored-By: Claude Opus 5 (1M context) --- src/highlight.rs | 3 ++- tests/lean4_stage1_acceptance.rs | 32 ++++++++++++++++++++++++++------ 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/src/highlight.rs b/src/highlight.rs index 642b0a0..de8ffe0 100644 --- a/src/highlight.rs +++ b/src/highlight.rs @@ -1737,7 +1737,8 @@ mod tests { Color::Indexed(4), "a called variant keeps @function, not @constructor" ); - // Lua tags the table-constructor BRACES, not a name: `{` at col 8. + // Lua tags the table-constructor BRACES, not a name: `{` at col 10 + // of `local t = {}`. assert_eq!( painted_fg_at("lua", "a.lua", "local t = {}\n", 10), Color::Indexed(11), diff --git a/tests/lean4_stage1_acceptance.rs b/tests/lean4_stage1_acceptance.rs index ae65545..d48a86c 100644 --- a/tests/lean4_stage1_acceptance.rs +++ b/tests/lean4_stage1_acceptance.rs @@ -305,16 +305,36 @@ fn acc11b_an_unknown_fence_name_still_injects_nothing() { // --------------------------------------------------------------------------- #[test] -fn acc12_stage1_spawns_no_process_and_needs_no_lean_toolchain() { +fn acc12_stage1_ships_no_lsp_config_and_spawns_no_process() { // Stage 1 is grammar + Lua tables only. Opening a Lean file must not // reach for `lake`, `lean`, or `elan` — the LSP arrives in Stage 3, and // even then it is fallible by design (Q#LN7). - // - // Asserted through the process supervisor rather than by inspection: - // opening the file leaves the child-process list exactly as it was. + + // The load-bearing assertion, and it must run against a PRISTINE editor. + // The shared `editor()` helper wipes `pmacs.lsp.config` before any + // buffer opens, so an assertion about the server list under that harness + // holds for every language regardless of what Stage 1 ships — it could + // not fail for the regression it names. This checks the real claim + // directly: no builtin runtime file defines a Lean server config. A + // Stage-3 front-run adding `pmacs.lsp.config.lean4` fails here. + let pristine = EditorState::new(); + let no_lean_config: bool = eval(&pristine, "return pmacs.lsp.config.lean4 == nil"); + assert!( + no_lean_config, + "Stage 1 defines no `pmacs.lsp.config.lean4`; the LSP is Stage 3" + ); + // Non-vacuity: the same lookup finds the configs that DO ship, so this + // is not passing because `pmacs.lsp.config` is empty or absent. + let rust_config_exists: bool = eval(&pristine, "return pmacs.lsp.config.rust ~= nil"); + assert!( + rust_config_exists, + "the config table is populated, so the lean4 absence above is meaningful" + ); + + // And nothing is spawned by opening the file. This half retains its + // value under the wiped config: a direct probe spawn from `lean.lua` + // would show up here whatever `pmacs.lsp.config` contains. let s = editor_visiting("Basic.lean", "def x : Nat := 1\n"); let procs: i64 = eval(&s, "return #pmacs.process.list()"); assert_eq!(procs, 0, "opening a Lean buffer spawns no child process"); - let servers: i64 = eval(&s, "return #pmacs.lsp.list()"); - assert_eq!(servers, 0, "Stage 1 attaches no language server"); } From 1a5805366a4b317dca2ed81e5a436c54bbb0c17f Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 10:18:38 -0400 Subject: [PATCH 17/22] docs: record the Lean 4 lane in the active-work ledger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 1 flagged that neither ledger knew about this branch, and `docs/active-work.md`'s stated job is exactly the volatile open lanes. Records the branch, base, framing revision, what Stage 1 ships, the discharged Q#LN1 obligation, the Q#LN4 blast radius, and the four implementation findings that are not in the framing (the `warning` colour collision with `number`, `Some(1)` resolving to `@function` rather than `@constructor`, the `module > declaration > def` nesting, and `injection_aliases` being a write-only proxy). Also carries forward the two Stage 2 corrections the framing already holds, since that lane starts next. Deliberately ADDITIVE ONLY -- one new section, zero deleted lines. PR #156 is open against both this file and `docs/agent-handoff.md` and owns the snapshot header, the canonical-base line, and the bottom-panel lane's status. Touching those here would collide with a PR already in review, which is the "frozen reviewed PRs do not absorb moving overlapping work" lesson from #135/#137. `docs/agent-handoff.md` is deliberately untouched: its §1 snapshot describes what is ON `main`, so it gets updated when this merges, not while it is in review. Co-Authored-By: Claude Opus 5 (1M context) --- docs/active-work.md | 70 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/docs/active-work.md b/docs/active-work.md index fb835a6..f55627e 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -54,6 +54,76 @@ git status --short --branch The `git log` command must expose `0dd16a5` or a newer intentional main. If it does not, stop and repair the remote/fetch configuration. +## Lean 4 lane (Arc 8) — Stage 1 IN REVIEW (PR #160) + +- Portable branch: `githubsucks/lean4-stage1`, worked in the shared + checkout (no sibling worktree), based on `githubsucks/main` @ `e745068`. +- Approved framing: `docs/lean4-mode-framing.md` revision 4, committed as + the branch's first commit (`a382965`) after three review rounds. **Seven + stages**, 19 decisions (Q#LN1–19), 64 acceptance criteria. North star: + match or exceed VS Code's Lean support. +- **Stage 1 implemented; no wire change (protocol stays v20), no LSP, no + frontend change.** Four commits: framing, grammar, theme captures, + editing surface + acceptance. + - `Cargo.toml` + `src/syntax.rs`: `arborium-lean` 2.18 and one + `BUILTIN_LANGUAGES` entry named **`lean4`** (Q#LN2 — the name becomes + the `didOpen` language_id), claiming `.lean` only. + - `src/highlight.rs`: four capture entries — `constructor`, `character`, + `keyword.conditional`, `warning`. + - `builtin/runtime/{comment,pair,syntax}.lua`: `--` comments, the + `⟨⟩ ⦃⦄ ⟮⟯` pair set, the `lean` → `lean4` modeline alias. + - `tests/lean4_stage1_acceptance.rs` plus unit tests in `syntax.rs` / + `highlight.rs`: 12 criteria, 17 tests. +- **Q#LN1's open obligation is discharged.** `tree-sitter-lean4` is + unusable (depends on `tree-sitter ^0.25` directly against our 0.26, + exports no `LANGUAGE` const despite its README, packages no queries); + `arborium-lean` rides `tree-sitter-language 0.1` with a pre-generated + ABI-15 parser. `cargo tree -d` shows no duplicate core. The parse smoke + pins the failure mode that matters: `→`/`∀`/`≥` must produce + `(arrow)`/`(forall)`/`(comparison)`, since a mismatched-core build + degrades silently on exactly those characters rather than failing loudly. +- **Q#LN4 is a deliberate retro-paint of seven language entries**, not + four: `tree_sitter_javascript::HIGHLIGHT_QUERY` is concatenated + base-first into javascriptreact/typescript/typescriptreact. Its shape is + "every capitalized identifier" (`#match? "^[A-Z]"`) plus every Lua table + brace — not "constructors". Pinned in both directions per #146. +- Implementation findings not in the framing: + - `warning` had to move from bold red to bold **bright** red: `number` + is plain `fg(1)`, so `sorry` and an adjacent numeric literal were the + same colour. Found by writing the test. + - `Some(1)` is **not** `@constructor` — in call position a narrower + `@function` pattern wins. Only bare or pattern-position capitalized + identifiers reach it. Pinned so the blast-radius claim stays honest. + - Lean node kinds nest: `module > declaration > def|theorem`. + - `pmacs.parse.injection_aliases` is a documented **write-only** Lua + proxy (canonical map is Rust-side), so fence tests must drive + `_parse_now` and inspect layer languages, never read the table back. +- **Review round 1 addressed.** The finding: acc12's server-list assertion + could not fail for the regression it named — the shared `editor()` + helper wipes `pmacs.lsp.config` before any buffer opens, so + `#pmacs.lsp.list() == 0` holds for every language regardless of what + Stage 1 ships. It now asserts against a **pristine** `EditorState` that + `pmacs.lsp.config.lean4` is nil, with a non-vacuity check that the same + lookup finds `rust`; bite-verified by adding a `lean4` config to + `lsp.lua` and watching it fail. Also fixed a stale column in a + `highlight.rs` comment. +- Verification on this branch: `cargo fmt --check` clean; strict workspace + Clippy clean; 1,826 default + 2,003 CRDT library tests; lean4 Stage 1 + 9/9; comment toggle 14; auto-pair 45; injection 4; M4 121; required GPU + 152; **isolated-config workspace sweep 3,150 across 90 suites**; + `git diff --check` clean. The sweep needs an isolated `XDG_CONFIG_HOME` + for the reason recorded in the bottom-panel lane below. +- **Stage 2 is multi-root LSP server affinity** — pure substrate, no Lean + content, and it changes `ensure_server`, which every LSP language + shares. It is sequenced next because Lean is the language that makes its + absence a correctness failure rather than an inconvenience. Two + corrections the framing already carries for it: `root` is computed at + `lsp.lua:537`, **after** the reuse loop, so the fix must hoist it; and + `project_root_for` never returns nil for a file with a path, so the + affinity key must be the root only when a root was actually *detected*, + or markerless scratch files fragment into one server per directory for + every language. + ## Bottom-panel lane (window placement + side windows) — Stage 1 IN REVIEW - Portable branch: `githubsucks/bottom-panel`, worktree From 2a0884b377b3d98380cdf3616d521d56a4399853 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 10:45:50 -0400 Subject: [PATCH 18/22] feat(find-file): open a file by path with C-x C-f Dired arc Stage 0 (docs/dired-framing.md section 10, Q#DR11). Until now pmacs had no discoverable way to open a file by path: no find-file command and no C-x C-f binding, so a file entered a session only from the CLI, an LSP jump, a project-search visit, or C-x C-r, whose prompt does pass free text through but completes only over the recent list. The command prompts with completion rooted at the active buffer's directory, or the process cwd when the buffer has no backing path, and opens the result through pmacs.window.display_file. A path that does not exist yet creates a buffer bound to it with the "[new file]" status, which is Emacs parity and comes from resolve_target_buffer rather than anything added here. Nothing is written to disk until the user saves. Two substrate facts shape the design and are documented at the command rather than left to be rediscovered. Completion is flat: the files source lists one directory and yields bare basenames, and a custom function source could not do better, because sources are called with no arguments and run synchronously outside any coroutine, so a callback can neither see the input to re-root on nor await a directory listing. Hierarchical completion is a named Rust change in the framing. A selected candidate shadows typed text: recompute_candidates selects index 0 whenever the candidate list is non-empty, and resolve_accepted_value returns the candidate over the typed contents. So typed text reaches the accept handler exactly when the input filters every candidate away, which for basename candidates under a subsequence filter means when it contains a separator. That makes the deeper-path case work verbatim and leaves one hole: a new bare name that is a subsequence of an existing entry opens the existing file. The acceptance pins that as a decision rather than an accident; closing it needs a Rust change to accept semantics that Stage 0 deliberately does not make. A leading tilde is expanded before the path reaches the core, because get_or_load_buffer normalizes the path it stores but loads from the raw one -- so an unexpanded tilde path deduplicates against an already-open buffer yet fails to load a file that is not open yet. The prompt field starts empty and names its root in the prompt string instead: any prefill would contain a separator and silently disable completion. Acceptance is dispatch-driven throughout -- a real C-x C-f, real typing, a real RET -- so a dead binding cannot pass vacuously and the Lua lifecycle accept(), which bypasses the path interactive input takes, is not used. --- builtin/commands/default.lua | 110 +++++++++++++ builtin/keymaps/default.lua | 1 + tests/find_file_acceptance.rs | 291 ++++++++++++++++++++++++++++++++++ 3 files changed, 402 insertions(+) create mode 100644 tests/find_file_acceptance.rs diff --git a/builtin/commands/default.lua b/builtin/commands/default.lua index 04c49fe..bc04e91 100644 --- a/builtin/commands/default.lua +++ b/builtin/commands/default.lua @@ -611,6 +611,116 @@ cmd { name = "editor.switch-buffer", } end } +-- find-file (dired arc Stage 0; docs/dired-framing.md Q#DR11) ---------------- +-- +-- Until now pmacs had no discoverable way to open a file by path: a file +-- entered a session only from the CLI, an LSP jump, a project-search +-- visit, or `C-x C-r` (whose prompt does pass free text through, but +-- completes only over the recent list). This is that surface. +-- +-- Two substrate facts shape it, and both are load-bearing: +-- +-- 1. COMPLETION IS FLAT. `source = "files"` lists ONE directory and +-- yields bare basenames (`minibuffer.rs` `list_directory`), capped at +-- the shared candidate limit. A custom function source could not do +-- better: sources are called with NO arguments, so a callback cannot +-- see the input to re-root on, and it runs synchronously outside any +-- coroutine, where `Handle:await()` raises --- so it cannot list a +-- directory either. Hierarchical completion is a named Rust change in +-- the framing, not something this command can fake. +-- +-- 2. A SELECTED CANDIDATE SHADOWS TYPED TEXT. `recompute_candidates` +-- sets `selected = Some(0)` whenever the candidate list is non-empty, +-- and `resolve_accepted_value` returns the CANDIDATE whenever +-- anything is selected. So `on_accept` receives typed text only when +-- the input filters every candidate away --- which, since candidates +-- are basenames and the filter is a subsequence match, is exactly +-- when the input contains a `/`. That makes the deeper-path case work +-- (`sub/inner.txt` matches no basename, so it arrives verbatim) and +-- leaves one documented hole: typing a NEW bare name that happens to +-- be a subsequence of an existing entry opens the existing file +-- instead of creating the new one. `acc4` pins that as a known +-- behavior rather than letting it be an accident. +-- +-- The root is the active buffer's directory, or the process cwd when the +-- buffer has no backing path (`source_root` defaults to "." Rust-side, +-- so the nil case needs no special handling here). It appears in the +-- prompt because the field itself must stay empty: any prefill would +-- contain a `/` and filter every candidate away, killing completion. + +-- Directory part of a path. "/a/b" -> "/a"; "/a" -> "/"; "a" -> nil. +local function find_file_dirname(path) + local dir = path:match("^(.*)/[^/]*$") + if dir == nil then return nil end + if dir == "" then return "/" end + return dir +end + +-- Expand a leading `~` component using $HOME: `~` -> $HOME, `~/x` -> +-- $HOME/x. `~user` is left alone (no passwd lookup), matching the core's +-- own `expand_tilde`. +-- +-- This has to happen HERE, before the path reaches the core, because +-- `get_or_load_buffer` normalizes the path it STORES but loads from the +-- raw one --- so a `~/...` path deduplicates against an already-open +-- buffer yet fails to load when the file is not open yet. Expanding up +-- front makes both halves agree. +local function find_file_expand_tilde(path) + local home = os.getenv("HOME") + if home == nil or home == "" then return path end + if home:sub(-1) == "/" then home = home:sub(1, -2) end + if path == "~" then return home end + local rest = path:match("^~/(.*)$") + if rest == nil then return path end + return home .. "/" .. rest +end + +-- Turn an accepted value into a path. The value is either a bare +-- basename (a selected candidate) or whatever the user typed, so a +-- non-absolute value joins onto the prompt's root --- which resolves +-- both cases to the same file when they name the same one. +local function find_file_resolve(root, value) + local path = find_file_expand_tilde(value) + if path:sub(1, 1) == "/" then return path end + local base = root or "." + if base:sub(-1) == "/" then return base .. path end + return base .. "/" .. path +end + +-- The active buffer's directory, or nil when it has no backing path. +local function find_file_root() + local buf = pmacs.window.buffer() + if buf == nil then return nil end + local ok, path = pcall(function() return buf:path() end) + if not (ok and path) then return nil end + return find_file_dirname(path) +end + +cmd { name = "find-file", + description = "Open a file by path, completing within one directory.", + fn = function() + local root = find_file_root() + pmacs.minibuffer.read { + prompt = "Find file (" .. (root or ".") .. "): ", + source = "files", + source_root = root, + history = "find-file", + on_accept = function(value) + if value == nil or value == "" then return end + local path = find_file_resolve(root, value) + -- A path that does not exist yet CREATES a buffer bound to + -- it: `display_file` routes through `resolve_target_buffer`, + -- which on NotFound creates, binds, and sets "[new file]". + -- That is Emacs parity and deliberate, so only a real + -- failure (a directory, a permission error) reaches here. + local ok, err = pcall(pmacs.window.display_file, path, { select = true }) + if not ok then + pmacs.editor.set_status("find-file: " .. tostring(err)) + end + end, + } + end } + -- Command palette (M-x) ------------------------------------------------------ -- -- Opens the minibuffer with a "commands" completion source, then diff --git a/builtin/keymaps/default.lua b/builtin/keymaps/default.lua index 7dfb9db..c260170 100644 --- a/builtin/keymaps/default.lua +++ b/builtin/keymaps/default.lua @@ -148,6 +148,7 @@ bind("C-x o", "window.focus-next") bind("C-x O", "window.focus-prev") bind("C-x 0", "window.close") bind("C-x 1", "window.close-others") +bind("C-x C-f", "find-file") bind("C-x b", "editor.switch-buffer") bind("C-x C-b", "editor.list-buffers") bind("C-x ", "editor.next-buffer") diff --git a/tests/find_file_acceptance.rs b/tests/find_file_acceptance.rs new file mode 100644 index 0000000..d975cd8 --- /dev/null +++ b/tests/find_file_acceptance.rs @@ -0,0 +1,291 @@ +// tests/find_file_acceptance.rs --- dired arc Stage 0 (`C-x C-f`) acceptance. + +//! Acceptance for `find-file`, the dired arc's Stage 0 +//! (`docs/dired-framing.md` §14, items 0a-0d, Q#DR11). +//! +//! Dispatch-driven throughout: the prompt is opened with a real +//! `C-x C-f`, filled by typing real keys, and completed with a real +//! RET. `pmacs.command.invoke` would bypass the binding (a dead +//! keymap entry would pass vacuously) and the Lua lifecycle +//! `minibuffer.accept()` bypasses the dispatch path interactive input +//! actually takes --- the editops suite's discipline, for the same +//! reasons. +//! +//! Fixtures use `.txt` files so no `buffer.after-load` hook spawns a +//! language server. + +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; +use pmacs::editor::EditorState; +use pmacs::protocol::FrontendId; + +fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent { + KeyEvent { + code, + modifiers: mods, + kind: KeyEventKind::Press, + state: KeyEventState::NONE, + } +} + +fn ctrl(s: &mut EditorState, c: char) { + s.dispatch_key( + FrontendId::LOCAL, + key(KeyCode::Char(c), KeyModifiers::CONTROL), + ); +} + +fn press(s: &mut EditorState, code: KeyCode) { + s.dispatch_key(FrontendId::LOCAL, key(code, KeyModifiers::NONE)); +} + +fn type_str(s: &mut EditorState, text: &str) { + for ch in text.chars() { + s.dispatch_key( + FrontendId::LOCAL, + key(KeyCode::Char(ch), KeyModifiers::NONE), + ); + } +} + +fn exec(s: &EditorState, src: &str) { + s.lua_host.lua().load(src.to_string()).exec().unwrap(); +} + +fn eval(s: &EditorState, src: &str) -> T { + s.lua_host.lua().load(src.to_string()).eval().unwrap() +} + +/// Open the find-file prompt through the real `C-x C-f` binding. +fn open_prompt(s: &mut EditorState) { + ctrl(s, 'x'); + ctrl(s, 'f'); + assert!( + eval::(s, "return pmacs.minibuffer.is_active()"), + "C-x C-f must open a minibuffer prompt" + ); +} + +/// The active buffer's backing path, or `None`. +fn active_path(s: &EditorState) -> Option { + eval::>( + s, + "local b = pmacs.window.buffer()\n\ + if b == nil then return nil end\n\ + local ok, p = pcall(function() return b:path() end)\n\ + if ok then return p end\n\ + return nil", + ) +} + +fn candidates(s: &EditorState) -> Vec { + eval::>(s, "return pmacs.minibuffer.candidates()") +} + +fn status(s: &EditorState) -> String { + s.core.borrow().status.clone() +} + +/// An editor whose active buffer is a real file inside `dir`, so +/// find-file's root resolves to that directory. +fn editor_in(dir: &std::path::Path) -> EditorState { + let anchor = dir.join("anchor.txt"); + std::fs::write(&anchor, b"anchor\n").expect("write anchor"); + let state = EditorState::new(); + state.lua_host.reopen_init_phase_for_testing(); + let anchor_str = anchor.display().to_string(); + exec(&state, &format!("pmacs.buffer.find_or_open({anchor_str:?})")); + state +} + +/// 0a --- completion is flat: it offers the root's own entries and +/// never descends into a subdirectory. +#[test] +fn find_file_completion_lists_the_root_only_and_does_not_descend() { + let td = tempfile::tempdir().expect("tempdir"); + std::fs::write(td.path().join("alpha.txt"), b"a").expect("write"); + std::fs::create_dir(td.path().join("sub")).expect("mkdir"); + std::fs::write(td.path().join("sub").join("inner.txt"), b"i").expect("write"); + + let mut s = editor_in(td.path()); + open_prompt(&mut s); + + let cands = candidates(&s); + assert!( + cands.iter().any(|c| c == "alpha.txt"), + "root entry must be offered; got {cands:?}" + ); + assert!( + cands.iter().any(|c| c == "sub"), + "the subdirectory itself must be offered; got {cands:?}" + ); + assert!( + !cands.iter().any(|c| c == "inner.txt"), + "completion must NOT descend into subdirectories; got {cands:?}" + ); +} + +/// 0b --- free text carries the deeper case. `sub/inner.txt` matches no +/// bare-basename candidate, so it reaches `on_accept` verbatim and is +/// joined onto the prompt's root. +#[test] +fn find_file_free_text_opens_a_path_below_the_root() { + let td = tempfile::tempdir().expect("tempdir"); + std::fs::create_dir(td.path().join("sub")).expect("mkdir"); + let inner = td.path().join("sub").join("inner.txt"); + std::fs::write(&inner, b"deep contents\n").expect("write"); + + let mut s = editor_in(td.path()); + open_prompt(&mut s); + type_str(&mut s, "sub/inner.txt"); + + assert!( + candidates(&s).is_empty(), + "a needle containing '/' must filter every basename candidate away, \ + or the selection would shadow the typed text" + ); + + press(&mut s, KeyCode::Enter); + + let path = active_path(&s).expect("a file must be open"); + assert_eq!( + std::fs::canonicalize(&path).expect("canonicalize opened"), + std::fs::canonicalize(&inner).expect("canonicalize fixture"), + "free text must open the deeper path" + ); + let text: String = eval(&s, "return pmacs.window.buffer():slice(0, 13)"); + assert_eq!(text, "deep contents", "the file's real contents must load"); +} + +/// 0c --- a path that does not exist creates a `[new file]` buffer +/// bound to it, rather than erroring. The name contains a `/` so the +/// candidate list is empty and the typed text is what arrives (see +/// `find_file_selected_candidate_shadows_typed_text` for the other +/// half of that rule). +#[test] +fn find_file_nonexistent_path_creates_a_new_file_buffer() { + let td = tempfile::tempdir().expect("tempdir"); + std::fs::create_dir(td.path().join("sub")).expect("mkdir"); + let fresh = td.path().join("sub").join("brand-new.txt"); + assert!(!fresh.exists(), "fixture must not exist yet"); + + let mut s = editor_in(td.path()); + open_prompt(&mut s); + type_str(&mut s, "sub/brand-new.txt"); + press(&mut s, KeyCode::Enter); + + let path = active_path(&s).expect("a buffer must be bound to the new path"); + assert!( + path.ends_with("sub/brand-new.txt"), + "the buffer must be bound to the typed path; got {path}" + ); + let len: usize = eval(&s, "return pmacs.window.buffer():len()"); + assert_eq!(len, 0, "a new-file buffer starts empty"); + assert!( + !fresh.exists(), + "find-file must not create the file on disk --- only the buffer" + ); + let line = status(&s); + assert!( + line.contains("[new file]"), + "the new-file status must surface; got {line:?}" + ); +} + +/// 0d --- with no backing path, the prompt roots at the process cwd +/// (`source_root` is omitted, and the Rust side defaults to "."). +/// The test crate's cwd is the crate root, so `Cargo.toml` is a +/// stable, real candidate there. +#[test] +fn find_file_without_a_backing_path_roots_at_the_process_cwd() { + let mut s = EditorState::new(); + s.lua_host.reopen_init_phase_for_testing(); + assert!( + active_path(&s).is_none(), + "the scratch buffer must have no backing path" + ); + + open_prompt(&mut s); + + let cands = candidates(&s); + assert!( + cands.iter().any(|c| c == "Cargo.toml"), + "a pathless buffer must root the prompt at the process cwd; got {cands:?}" + ); + // The field must start EMPTY. Any prefill (e.g. Emacs's + // directory-in-the-field) would contain a `/`, which filters every + // basename candidate away and silently disables completion --- the + // reason the root is named in the prompt string instead. + let typed: String = eval(&s, "return pmacs.minibuffer.contents()"); + assert_eq!( + typed, "", + "the prompt field must start empty or completion is dead on arrival" + ); +} + +/// The documented hole in Q#DR11, pinned so it is a decision rather +/// than an accident: `recompute_candidates` selects index 0 whenever +/// the list is non-empty and `resolve_accepted_value` returns the +/// SELECTED CANDIDATE over the typed text, so typing a new bare name +/// that is a subsequence of an existing entry opens the existing file. +/// Fixing this needs a Rust change to accept semantics, which Stage 0 +/// deliberately does not make. +#[test] +fn find_file_selected_candidate_shadows_typed_text() { + let td = tempfile::tempdir().expect("tempdir"); + std::fs::write(td.path().join("notes.md"), b"existing\n").expect("write"); + + let mut s = editor_in(td.path()); + open_prompt(&mut s); + // "nots" is a subsequence of "notes.md", so the candidate survives + // the filter and shadows the typed name. + type_str(&mut s, "nots"); + assert_eq!( + candidates(&s), + vec!["notes.md".to_string()], + "the fixture depends on 'nots' matching 'notes.md'" + ); + + press(&mut s, KeyCode::Enter); + + let path = active_path(&s).expect("a file must be open"); + assert!( + path.ends_with("notes.md"), + "documented behavior: the selected candidate wins over typed text; got {path}" + ); +} + +/// A leading `~` is expanded before the path reaches the core. This +/// matters because `get_or_load_buffer` normalizes the path it STORES +/// but loads from the RAW one, so an unexpanded `~/...` would dedup +/// against an open buffer yet fail to load a file that is not open. +#[test] +fn find_file_expands_a_leading_tilde() { + let Some(home) = std::env::var_os("HOME") else { + eprintln!("HOME unset; skipping tilde expansion pin"); + return; + }; + let home = home.to_string_lossy().into_owned(); + if home.is_empty() || !std::path::Path::new(&home).is_dir() { + eprintln!("HOME is not a usable directory; skipping"); + return; + } + + let mut s = EditorState::new(); + s.lua_host.reopen_init_phase_for_testing(); + open_prompt(&mut s); + // Contains a '/', so the typed text reaches on_accept verbatim. + // The leaf does not exist, so this lands on the new-file path and + // touches no disk state. + type_str(&mut s, "~/pmacs-find-file-tilde-probe.txt"); + press(&mut s, KeyCode::Enter); + + let path = active_path(&s).expect("a buffer must be bound"); + assert!( + !path.contains('~'), + "the tilde must be expanded, not passed through; got {path}" + ); + assert!( + path.starts_with(&home), + "the expansion must use $HOME; got {path} with HOME={home}" + ); +} From 4a2aa925107aea73964aaf40e21991cdcc70bacb Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 11:13:57 -0400 Subject: [PATCH 19/22] style: rustfmt the find-file acceptance harness --- tests/find_file_acceptance.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/find_file_acceptance.rs b/tests/find_file_acceptance.rs index d975cd8..ad4c5b2 100644 --- a/tests/find_file_acceptance.rs +++ b/tests/find_file_acceptance.rs @@ -93,7 +93,10 @@ fn editor_in(dir: &std::path::Path) -> EditorState { let state = EditorState::new(); state.lua_host.reopen_init_phase_for_testing(); let anchor_str = anchor.display().to_string(); - exec(&state, &format!("pmacs.buffer.find_or_open({anchor_str:?})")); + exec( + &state, + &format!("pmacs.buffer.find_or_open({anchor_str:?})"), + ); state } From 0b0d5acd81b6b21a0984a938dac33961e6633d1b Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 11:33:46 -0400 Subject: [PATCH 20/22] fix(find-file): review round 1 -- name the real test, pin two gaps Three of the five review findings land here; the other two are recorded as named deferrals in the framing on the dired branch. Finding 1: the command comment cited "acc4", a name from a draft scheme that no test carries. It now names the real test, and the comment splits the shadowing consequence into the two cases that actually exist -- a new bare name that matches an entry (shadowed) versus one that matches nothing (creates normally) -- each pointing at its test. Finding 2: the everyday new-file flow had no test. Typing a bare name that is not a subsequence of any entry is the path users hit first, and the only route combining free text with a relative join; every existing new-file test used a name containing a separator. find_file_bare_new_name_creates_in_the_root covers it, asserting the parent is the prompt's root so the join itself is pinned. Finding 3: the failure arm was never exercised, and as the review noted, deleting the pcall would have passed the whole suite. Accepting a directory candidate reaches display_file, whose load fails because File::open on a directory succeeds and the read returns EISDIR; find_file_accepting_a_directory_reports_instead_of_raising pins that this surfaces as the command's status message, leaves the active buffer alone, and closes the prompt. Verified by manual revert: with the pcall replaced by a direct call, that test and only that test fails. scripts/bite could not isolate it, since the guard and its test have no separating commit. Finding 4 is documented at the command rather than left implicit: accepting on empty input opens the first-sorted candidate, because fuzzy_score returns Some(0) for an empty needle and filter_and_sort breaks the tie lexicographically, so dotfiles lead and a directory can lead. M-x and switch-buffer share the mechanism, so it is inherited rather than introduced, and it is listed in the framing beside the accept-semantics change that would close it. --- builtin/commands/default.lua | 21 ++++++++-- tests/find_file_acceptance.rs | 79 +++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 4 deletions(-) diff --git a/builtin/commands/default.lua b/builtin/commands/default.lua index bc04e91..2a13c21 100644 --- a/builtin/commands/default.lua +++ b/builtin/commands/default.lua @@ -637,10 +637,23 @@ cmd { name = "editor.switch-buffer", -- are basenames and the filter is a subsequence match, is exactly -- when the input contains a `/`. That makes the deeper-path case work -- (`sub/inner.txt` matches no basename, so it arrives verbatim) and --- leaves one documented hole: typing a NEW bare name that happens to --- be a subsequence of an existing entry opens the existing file --- instead of creating the new one. `acc4` pins that as a known --- behavior rather than letting it be an accident. +-- leaves TWO documented consequences, each pinned by a test rather +-- than left to be rediscovered: +-- +-- (a) typing a NEW bare name that happens to be a subsequence of an +-- existing entry opens the existing file instead of creating the +-- new one --- `find_file_selected_candidate_shadows_typed_text`. +-- A new bare name that matches nothing is unaffected and creates +-- normally (`find_file_bare_new_name_creates_in_the_root`). +-- (b) accepting on EMPTY input opens the first candidate in sort +-- order. `fuzzy_score` returns `Some(0)` for an empty needle, so +-- everything ties and `filter_and_sort` falls back to +-- lexicographic order --- which puts dotfiles first, and can put +-- a DIRECTORY first, in which case the open fails and reports. +-- This is the same mechanism `M-x` and `switch-buffer` already +-- have, so it is inherited rather than introduced; it is recorded +-- as decided, not overlooked, and listed in the framing's +-- deferrals beside the accept-semantics fix that would close it. -- -- The root is the active buffer's directory, or the process cwd when the -- buffer has no backing path (`source_root` defaults to "." Rust-side, diff --git a/tests/find_file_acceptance.rs b/tests/find_file_acceptance.rs index ad4c5b2..fb793e3 100644 --- a/tests/find_file_acceptance.rs +++ b/tests/find_file_acceptance.rs @@ -194,6 +194,85 @@ fn find_file_nonexistent_path_creates_a_new_file_buffer() { ); } +/// The everyday new-file flow: a BARE name, no separator, matching no +/// existing entry. The candidate list empties on its own, so the typed +/// text arrives and joins onto the root. This is the path users hit +/// first, and it is the only route through `find_file_resolve` that +/// combines free text with a relative join. +#[test] +fn find_file_bare_new_name_creates_in_the_root() { + let td = tempfile::tempdir().expect("tempdir"); + let fresh = td.path().join("zzz.txt"); + + let mut s = editor_in(td.path()); + open_prompt(&mut s); + // "zzz.txt" is not a subsequence of "anchor.txt" (no 'z' in it), so + // nothing survives the filter and the typed name is what accepts. + type_str(&mut s, "zzz.txt"); + assert!( + candidates(&s).is_empty(), + "fixture premise: a bare non-matching name must empty the list; got {:?}", + candidates(&s) + ); + + press(&mut s, KeyCode::Enter); + + let path = active_path(&s).expect("a buffer must be bound to the new path"); + assert_eq!( + std::path::Path::new(&path).parent(), + Some(td.path()), + "a bare name must join onto the prompt's root; got {path}" + ); + assert!( + path.ends_with("zzz.txt"), + "the buffer must carry the typed name; got {path}" + ); + let len: usize = eval(&s, "return pmacs.window.buffer():len()"); + assert_eq!(len, 0, "a new-file buffer starts empty"); + assert!(!fresh.exists(), "nothing is written to disk until save"); +} + +/// The failure arm. Accepting a DIRECTORY candidate reaches +/// `display_file`, whose load fails (opening a directory succeeds, the +/// read does not), and the command's `pcall` must turn that into a +/// status message rather than letting the error escape mid-dispatch. +/// Without the `pcall` this test fails, which is the point --- the +/// guard is pinned through the real accept path, not asserted directly. +#[test] +fn find_file_accepting_a_directory_reports_instead_of_raising() { + let td = tempfile::tempdir().expect("tempdir"); + std::fs::create_dir(td.path().join("sub")).expect("mkdir"); + + let mut s = editor_in(td.path()); + let before = active_path(&s).expect("the anchor must be open"); + + open_prompt(&mut s); + // Only the directory matches: "anchor.txt" contains no 's'. + type_str(&mut s, "sub"); + assert_eq!( + candidates(&s), + vec!["sub".to_string()], + "fixture premise: the directory must be the sole candidate" + ); + + press(&mut s, KeyCode::Enter); + + let line = status(&s); + assert!( + line.starts_with("find-file: "), + "the failure must surface as this command's status message; got {line:?}" + ); + assert_eq!( + active_path(&s).as_deref(), + Some(before.as_str()), + "a failed open must leave the active buffer alone" + ); + assert!( + !eval::(&s, "return pmacs.minibuffer.is_active()"), + "the prompt must have closed even though the open failed" + ); +} + /// 0d --- with no backing path, the prompt roots at the process cwd /// (`source_root` is omitted, and the Rust side defaults to "."). /// The test crate's cwd is the crate root, so `Cargo.toml` is a From 066b8652b8778fcca11dfeeb62943a00ab047b3b Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 11:37:21 -0400 Subject: [PATCH 21/22] docs: add COHERENCE.md as a required doc, audited against the codebase COHERENCE.md states the product-coherence thesis (pmacs should be immediately excellent, progressively understandable, completely inspectable, and ultimately replaceable) and, per-section, the audited ground truth of how the codebase measures against it: a scorecard across 19 concerns, the golden-journey verdict table (breaks at "open a real project" -- `pmacs .` exits 1), the six hardcoded key-interception shadows with no transient-keymap mechanism to migrate them to, the discoverability substrate-without-surface gap, the package/worker identity gap, and three cross-cutting patterns (substrate without surface, the silence asymmetry, per-arc coherence debt) that explain most of the individual findings. CLAUDE.md and AGENTS.md now list it as required reading alongside agent-handoff.md and active-work.md, and ask new framing docs to state their coherence impact. No runtime code changes. --- AGENTS.md | 16 +- CLAUDE.md | 16 +- COHERENCE.md | 1547 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 1571 insertions(+), 8 deletions(-) create mode 100644 COHERENCE.md diff --git a/AGENTS.md b/AGENTS.md index 58c28f9..823b6e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,11 +1,15 @@ # pmacs agent instructions -**Start here: read `docs/agent-handoff.md`, then +**Start here: read `docs/agent-handoff.md`, then `COHERENCE.md`, then `docs/active-work.md`, before taking on any work.** The handoff carries durable project state, working method, substrate invariants, and the -standing backlog. The active-work ledger carries volatile branches, +standing backlog. `COHERENCE.md` carries the product-coherence thesis +and its audited ground truth (scorecard, per-concern gaps, priority +order) — it is the standard new work gets evaluated against, not just a +backlog item; read it before framing anything and cite the section a +framing doc serves. The active-work ledger carries volatile branches, checkpoints, verification, and exact cross-machine recovery commands. -Keep both updated according to their own update protocols. +Keep all three updated according to their own update protocols. Always true, independent of the handoff: @@ -14,7 +18,11 @@ Always true, independent of the handoff: (`pmacs-protocol`). `#![forbid(unsafe_code)]`. - Workflow: framing doc in `docs/` -> user approval -> branch -> implement -> full gate suite -> PR -> user review rounds -> user says when to - merge. Never merge unprompted. One feature, one branch, one PR. + merge. Never merge unprompted. One feature, one branch, one PR. A + framing doc for coherence-affecting work should state its coherence + impact (journey steps touched, interaction islands added, config + registry adoption, background-work attribution) per `COHERENCE.md` + §20. - Gates before any PR: `cargo fmt --check`; `cargo clippy --workspace --all-targets -- -D warnings` (as its own step); `cargo test --lib`; `cargo test --lib --features crdt`; the touched acceptance suites; diff --git a/CLAUDE.md b/CLAUDE.md index 58c28f9..823b6e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,11 +1,15 @@ # pmacs agent instructions -**Start here: read `docs/agent-handoff.md`, then +**Start here: read `docs/agent-handoff.md`, then `COHERENCE.md`, then `docs/active-work.md`, before taking on any work.** The handoff carries durable project state, working method, substrate invariants, and the -standing backlog. The active-work ledger carries volatile branches, +standing backlog. `COHERENCE.md` carries the product-coherence thesis +and its audited ground truth (scorecard, per-concern gaps, priority +order) — it is the standard new work gets evaluated against, not just a +backlog item; read it before framing anything and cite the section a +framing doc serves. The active-work ledger carries volatile branches, checkpoints, verification, and exact cross-machine recovery commands. -Keep both updated according to their own update protocols. +Keep all three updated according to their own update protocols. Always true, independent of the handoff: @@ -14,7 +18,11 @@ Always true, independent of the handoff: (`pmacs-protocol`). `#![forbid(unsafe_code)]`. - Workflow: framing doc in `docs/` -> user approval -> branch -> implement -> full gate suite -> PR -> user review rounds -> user says when to - merge. Never merge unprompted. One feature, one branch, one PR. + merge. Never merge unprompted. One feature, one branch, one PR. A + framing doc for coherence-affecting work should state its coherence + impact (journey steps touched, interaction islands added, config + registry adoption, background-work attribution) per `COHERENCE.md` + §20. - Gates before any PR: `cargo fmt --check`; `cargo clippy --workspace --all-targets -- -D warnings` (as its own step); `cargo test --lib`; `cargo test --lib --features crdt`; the touched acceptance suites; diff --git a/COHERENCE.md b/COHERENCE.md new file mode 100644 index 0000000..1594b69 --- /dev/null +++ b/COHERENCE.md @@ -0,0 +1,1547 @@ +# Product Coherence for Pmacs + +## Status of this document + +This document has two jobs. It states the **product-coherence thesis** +for pmacs, and it records the **audited ground truth** of how the +codebase measures against that thesis, so that no future agent or +contributor has to re-excavate it. + +- The vision prose is durable. The **Ground truth** subsections were + established **2026-07-25** by a four-lane code audit (discoverability, + interaction islands, packages/workers, first-run journey) plus a + distribution check, on branch `lsp-multi-root-affinity` + (= `main` @ `0827dd1` plus the multi-root LSP work). +- Citations name **symbols first, `file:line` second**. Line numbers + drift with the tree — `docs/keybindings.md` drifted by 250–1000 lines + within days of its "last verified" stamp (§24) — so treat the symbol + name and the structural claim as authoritative and the line number as + a hint. Re-grep before relying on a number. +- Grades used below: **Strong / Partial / Weak / Missing** (and + **Broken** where something actively fails). +- Update protocol is §25. When a PR changes any audited claim here, + updating this file rides that PR, the same way `docs/agent-handoff.md` + does. + +Relationship to the other required documents: `docs/agent-handoff.md` +carries durable project state and working method; `docs/active-work.md` +carries volatile branches and recovery; `docs/side-quest-backlog.md` +carries item-level deferrals. This document carries **product direction +and the measured distance to it**. It is not a second backlog; it is the +standard the backlog gets ranked against. + +--- + +## Purpose + +Pmacs already has an unusually strong technical foundation for an editor +at its stage of development. Its daemon/frontend split, semantic +rendering protocol, CRDT-based editing, structured worker runtime, Lua +programmability, language tooling, package resolver, terminal support, +and remote-capable architecture all point toward a system with genuine +long-term differentiation. + +The next challenge is not primarily adding more isolated capabilities. +It is making the existing and planned capabilities converge into a +coherent product. + +Visual Studio Code is used throughout this document as a reference point +because it is an exceptionally successful modern editor. Pmacs is +obviously not trying to become VS Code. Its goals are substantially +different: live programmability, inspectability, stronger concurrency +semantics, frontend plurality, and deeper user control are central to +Pmacs in ways they are not central to VS Code. The useful lesson is +therefore not to copy VS Code's interface or architecture wholesale, but +to understand how a technically complex system can become immediately +useful, progressively discoverable, and easy to adopt. + +The deeper reference point is Emacs. Emacs's beauty comes from its +ontological unity: the editor is text, Lisp, commands, buffers, and a +running system that the user can interrogate and change. Its enduring +achievement is not any single feature, but that it created the kind of +environment in which generations of users could build almost anything. + +Pmacs should preserve that unity while correcting the accidental +historical constraints beneath it: cooperative rather than general +parallelism, unclear ownership, global mutation, difficult unloading, +rendering coupled too closely to the core, opaque latency, implicit +remote context, and inconsistent package lifecycle. + +Pmacs does not need to contain everything Emacs contains before it can +be considered a successor. It must instead remain the kind of system in +which everything Emacs contains could eventually be built — with clearer +ownership, stronger concurrency, richer frontends, explicit execution +locations, and fewer historical traps. + +That places the VS Code comparison in its proper role. VS Code +demonstrates how a complex development environment can be coherent, +approachable, and immediately useful. Emacs demonstrates how an editor +can become a live, fertile, user-transformable world. Pmacs should +combine the adoption discipline of the former with the programmability +and unity of the latter. + +The core product objective should be: + +> **Pmacs should be immediately excellent, progressively understandable, +> completely inspectable, and ultimately replaceable.** + +A user should receive a polished workstation before they become an +editor engineer. If they choose to become one, the entire system should +remain open to them. + +--- + +## 0. Scorecard (audited 2026-07-25) + +| § | Concern | Grade | One-line state | +|---|---|---|---| +| 2 | Golden product journey | **Broken at entry** | `pmacs .` exits 1; only "launch" and "edit" pass cleanly zero-config | +| 3 | Zero-configuration state | **Partial** | Defaults genuinely strong; missing-tool failure is silent, not graceful | +| 4 | Progressive disclosure | **Inverted** | The advanced level is real; the beginner level is the missing one | +| 5 | Unified discoverability | **Substrate without surface** | Best-in-class registration metadata; almost no way for a user to reach it | +| 6 | Interaction islands | **Weak, and growing** | Six hardcoded key-interception shadows; no transient-keymap mechanism exists | +| 7 | First-class workspaces | **Missing (conventions only)** | Marker walk + four independent consumers; no workspace object | +| 8 | Execution locations | **Missing (architecture ready)** | SSH attach works; "location" is not a value anywhere | +| 9 | Worker ownership | **Mechanism without identity** | Cancellation solid; no owner/purpose/hierarchy; four disjoint activity views | +| 10 | Extension trust classes | **Missing (one class)** | Shared Lua state, `__index = _G`; MCP is the one out-of-process seam | +| 11 | Config layering + provenance | **Partial (foundation only)** | Typed registry is right; 5 settings live in it; no value provenance | +| 12 | Profiles | **Missing** | One hardcoded default keymap; not a named concept | +| 13 | Package lifecycle UX | **Resolution without lifecycle** | Mature resolver/lockfile; init-only install; no uninstall/disable/search | +| 14 | Workbench primitives | **Partial (best trajectory)** | Listview is a real shared primitive; bottom panel landed (#155) | +| 15 | Contextual affordances | **Weak** | Right-click menu only; code actions apply first-blindly; no git integration at all | +| 16 | Semantic frontend | **Strong** | v6..=v20 negotiated protocol; degradation practiced; TUI/GPU share the model | +| 17 | Distribution | **Missing** | CI is test-only; no binaries, channels, checksums, or update path | +| 18 | Onboarding | **Missing** | No welcome, no tutorial; `C-h` deletes a word; `M-x` is the only door in | +| 19 | Coherence acceptance tests | **Missing (culture ready)** | Superb per-arc acceptance discipline; zero cross-subsystem journey tests | + +Three cross-cutting patterns explain most of the table; they are +detailed in §1.1–§1.3: **substrate without surface**, **the silence +asymmetry**, and **per-arc coherence debt**. + +Coherence-shaped work already in flight at audit time: find-file / +dired Stage 0 (`C-x C-f`, PR #162, `docs/dired-framing.md`), bottom +panel Stage 1 (merged #155), multi-root LSP affinity (branch +`lsp-multi-root-affinity`), the config registry foundation (merged +#127). + +--- + +## 1. The Product Problem + +Pmacs is building many difficult things correctly and in parallel. That +is appropriate for an early systems project. The risk is that the +project succeeds architecturally while remaining fragmented +experientially. + +A technically sophisticated editor can still feel incoherent when: + +- installation requires repository knowledge; +- capabilities exist but are difficult to discover; +- subsystems expose unrelated interaction conventions; +- project, process, terminal, language-server, and remote state are + modeled separately; +- configuration is powerful but provenance is unclear; +- packages can extend the editor but cannot be understood, controlled, + or attributed; +- background work is concurrent but not meaningfully owned; +- new users must configure the system before they can experience its + strengths. + +The relevant distinction is between **capability completeness** and +**product coherence**. Capability completeness asks "can pmacs do X?". +Product coherence asks whether a user naturally encounters X at the +right time, whether X behaves by shared conventions, whether the user +can understand why X is active, and whether X feels like part of one +editor rather than an adjacent demonstration. + +Pmacs is well on its way toward capability completeness in several major +areas. Product coherence must now become an explicit development track +rather than an emergent consequence of subsystem work. The 2026-07-25 +audit found that every one of the eight bullet points above is true of +pmacs today, and that they share three structural causes. + +### 1.1 Ground truth: substrate without surface + +The single most consistent audit finding, appearing independently in all +four lanes: **the mechanism layer is disciplined, often best-in-class; +the product surface that would make it perceptible is missing.** The +July 2026 roadmap named an instance of this "dark matter — built but +unwired" and treated it as a one-time backlog. It is not one-time; it is +the project's default failure mode. The audited inventory of complete, +working, unreachable capability: + +- **The entire rich help system.** `src/help.rs` implements a + self-navigable `*help*` buffer with `[command:]` / `[key:]` / + `[mode:]` / `[hook:]` / `[buffer:]` / `[view:]` cross-reference links + and `follow_link_at`, installed as `pmacs.help.show_command` / + `show_key` / `show_buffer` / `show_mode` / `show_hook` / `show_view` + (`install_help_module`, `src/lua_bindings/mod.rs:5597`). `grep -rn + "pmacs.help" builtin/` returns **zero hits** — no command, no + keybinding, no caller. +- **File-name completion.** `CompletionSource::Files { root }` + (`src/minibuffer.rs:589`) is reachable from Lua as `source = "files"` + + `source_root` — zero builtin callers. +- **Command availability.** `Command.predicate` is stored on every + command and **never evaluated** by `invoke`, `invoke_interactive`, + keymap dispatch, M-x filtering, or the menu (§5). +- **Package ownership.** `CurrentlyLoadingPackage` is a stack correctly + pushed/popped around every package chunk + (`src/lua_bindings/mod.rs:3953-3966`) and consulted by exactly one + binding (`on_unload`'s fallback). Every registrar ignores it (§13). +- **LSP health.** `LspManager::status_buffer_text()` (`src/lsp.rs:1204`) + is Lua-bound; no builtin command opens `*lsp*` (§2, §9). +- **Interactive file opening.** `pmacs.buffer.find_or_open` + (`src/lua_bindings/mod.rs:3103`) had no interactive caller at audit + time; a complete 1,384-line dired exists as a frozen test fixture + (`tests/fixtures/pmacs-dired/init.lua`). Being fixed now: dired Stage + 0 (PR #162). + +The strategic consequence: **most coherence gaps in pmacs are doors, +not engines** — deliberately deferred surface, not design error. That is +the cheap kind of gap, and it should change how the remaining work is +costed. + +### 1.2 Ground truth: the silence asymmetry + +Synchronous, user-initiated failures report well: `M-x` errors surface +as `"M-x error: "` (`builtin/commands/default.lua:633-641`), +compile spawn failures print in-buffer and on the status line +(`builtin/runtime/compile.lua:850-855`), and `pmacs --gpu` with no +`pmacs-gpu` binary produces the best missing-tool message in the +codebase — it names both the sibling path it tried and the PATH fallback +(`src/main.rs:367-379`). + +Automatic, background failures are swallowed. The canonical case, hit on +**every file open** when a language server is preconfigured but not +installed: `Command::spawn` ENOENT propagates up through +`LspManager::spawn` and raises in Lua — where `ensure_server` `pcall`s +it and returns nil (`builtin/runtime/lsp.lua:614-626`), and the +`buffer.after-load` hook `pcall`s the whole attach +(`builtin/runtime/lsp.lua:895-897`). Net user-visible result: nothing. +No status message, no `*errors*` entry, no modeline marker (the LSP +segment is gated on an attachment record existing, so absence is +indistinguishable from "unsupported file type"). Working tree-sitter +highlighting **actively masks** the failure — the user sees colored text +and assumes language intelligence is on. Post-crash is the same shape: +`LspEventKind::Crashed` is pushed (`src/lsp.rs:2394`) and no builtin +subscriber surfaces it. + +This directly contradicts the product thesis (§23): the "without +freezing" half is delivered; the "without becoming opaque" half is +currently false for exactly the failures a new user will hit first. + +**Rule to adopt:** anything that fails automatically must leave a +user-visible trace with a named owner. A `pcall` around background +wiring must log attributed failure, never discard it. + +### 1.3 Ground truth: coherence debt compounds per-arc + +Three audited growth patterns show subsystem work accruing coherence +debt with no counter-pressure: + +- Each new modal UI **extended the shadow family** instead of building + the keymap-layer mechanism (menu → completion → query-replace, §6) — + and each addition must hand-sync three guard lists (`dispatch_key`, + `dispatch_idle_for`, `dispatch_paste`). +- Each new subsystem **added its own activity view** (`*workers*`, + `pmacs.process.list`, `*lsp*`, the terminal-private id set, §9), + because no common identity key exists to join them. +- Each new option **individually decides** whether to adopt the config + registry; five have, everything else has not (§11). + +The framing-doc workflow (scout → framing → approval → acceptance +criteria → bite-verified review) is exactly the right tool to reverse +this — no framing has ever carried a product-coherence acceptance +criterion. Adding them is a process change, not an engineering arc, and +it is what makes this document *required* rather than advisory. + +--- + +## 2. The Golden Product Journey + +Pmacs should maintain one protected end-to-end experience against which +all major work is tested: + +1. Install Pmacs. +2. Launch it without prior configuration. +3. Open a real project. +4. Understand the visible interface. +5. Edit immediately. +6. Receive language intelligence. +7. Find a symbol or file. +8. Open a terminal. +9. Build or test the project. +10. Inspect and act on an error. +11. Understand what background work is running. +12. Close and later restore the workspace. + +This does not need to exercise every advanced feature. It exists to +prove that the editor's components form a usable whole. A strong initial +target is a Rust project, because Rust stresses many of pmacs's intended +strengths: project detection, toolchain discovery, language-server +lifecycle, async diagnostics, build/test integration, terminal use, +large compilation workloads, symbol search, background indexing, +structured error presentation. + +```text +Install Pmacs + ↓ +Run `pmacs .` + ↓ +Project root detected + ↓ +Rust mode activated + ↓ +rust-analyzer found or installation guidance shown + ↓ +Files, diagnostics, terminal, and project actions available + ↓ +Build or test command discoverable + ↓ +Errors become navigable structured results +``` + +This journey should become a release gate. New architectural work should +be evaluated partly by whether it improves, preserves, or complicates +the journey. + +### Ground truth: the journey today + +**Grade: broken at step 3.** Verified empirically at audit time: + +``` +$ ./target/release/pmacs . +pmacs: Is a directory (os error 21) +EXIT=1 +``` + +The literal first arrow of the diagram above fails. `load_file` +(`src/file_io.rs:81-87`) does `File::open` (succeeds on a directory) +then `read_to_end` → EISDIR, which is not `NotFound`, so +`EditorState::open` returns `Err` and `main` prints and exits +(`src/main.rs:411-414`). Multiple file arguments are also rejected +(`"multiple files not yet supported"`, `src/main.rs:227`). Everything +from step 6 onward is gated on a file being open, and the only +zero-config way to open one is naming it on the command line — which +requires already knowing the path. + +Full verdict table: + +| # | Step | Verdict | Evidence | +|---|---|---|---| +| 1 | Install | **Partial** | Source build only: `cargo build --release --workspace --features pmacs/crdt` (`README.md`). No binaries, no packaging. Runtime deps (`/bin/sh`, git, tar, coreutils) documented, never checked at runtime | +| 2 | Launch unconfigured | **Works** | `EditorState::new()` → empty `*scratch*`; missing config is not an error (`src/config.rs:7-9`); recentf/saveplace/autosave default-on | +| 3 | Open real project | **Missing** | `pmacs .` exits 1 (above). No directory handling anywhere | +| 4 | Understand interface | **Partial** | Mode line gives name/modified/L:C/scroll + mode/LSP/terminal segments; but no welcome text (`EditorCore::new` sets `status: String::new()`), no cheat sheet, and `C-h` deletes a word (§18) | +| 5 | Edit | **Works** | Full CUA + Emacs keymap in 161 lines (`builtin/keymaps/default.lua`); isearch, query-replace, kill ring, undo/redo, auto-indent/pair/comment, atomic save. Genuinely excellent zero-config | +| 6 | Language intelligence | **Partial** | Rust grammar bundled and auto-attaches; rust-analyzer preconfigured (`builtin/runtime/lsp.lua:44-52`) — but a missing binary fails silently (§1.2) and highlighting masks it. No LSP status command exists to diagnose | +| 7 | Find symbol / file | **File: missing → in flight (PR #162). Symbol: works but undiscoverable** | No find-file/dired/picker existed at audit; `M-.`/`M-?`/`C-c o` bound but advertised nowhere and server-gated; no workspace-symbol command; `pmacs.index.*` has no UI | +| 8 | Open terminal | **Works but undiscoverable** | Full PTY with scrollback + modeline segment — reachable only as `M-x terminal`, no keybinding | +| 9 | Build / test | **Partial** | `M-x compile.run` works, defaults cwd to detected project root, parses Rust `-->` errors — but no keybinding, an **empty first prompt** (`initial = last and last.cmdline or ""`, `builtin/runtime/compile.lua:1134-1138`), and no `cargo build`/`cargo test` suggestion despite `ProjectKind::Cargo` existing (`src/project.rs:77`) | +| 10 | Inspect error | **Partial (good once reached)** | `E:n W:n` modeline counts, underlines, `M-g n/p` + ``C-x ` `` walking a unified compile/grep/diag source, message echo, `RET` visits. Gated entirely on step 6 or 9 succeeding first | +| 11 | See background work | **Works but undiscoverable** | `*workers*` view via `M-x editor.list-workers`; `C-c C-k` cancel-at-point. No keybinding, no statusline spinner/progress indicator anywhere (§9) | +| 12 | Close + restore | **Partial** | Per-file cursor+scroll (saveplace), recent files, minibuffer history, autosave recovery all restore zero-config. Open-buffer set and window layout do **not**: desktop-save is opt-in (`pmacs.session.desktop_mode(true)`) *and* a documented no-op under a daemon (`src/desktop.rs:323-326`, `:353-356`, Q#DS9) | + +A journey observation worth keeping verbatim from the audit: +**keybinding coverage is inverted relative to frequency** — `C-c @ +C-M-s` opens all folds, while opening a file, opening a terminal, and +running a build have no bindings at all. + +--- + +## 3. A Strong Zero-Configuration State + +Pmacs should not require configuration before it becomes pleasant. The +default experience should demonstrate the editor's thesis: responsive +editing, visible asynchronous work, coherent project awareness, language +intelligence, integrated terminal and task execution, helpful +diagnostics, discoverable commands, graceful failure when external tools +are absent. + +Configuration should be an escalation path: + +1. The editor works. +2. The user notices a preference. +3. The relevant setting or command is easy to find. +4. The user changes it. +5. The editor explains where the effective value came from. +6. Advanced users can replace the behavior entirely. + +### Recommended default surface + +The graphical frontend should have a deliberate default workspace with a +restrained number of visible regions: main editor area; compact +statusline; optional project/files surface; bottom panel for terminal, +build output, diagnostics, and other transient tools; command palette; +contextual actions; unobtrusive background activity indicator. The TUI +should express the same conceptual model within terminal constraints. +The goal is not identical geometry across frontends — it is shared +nouns, commands, lifecycle, and state. + +### Ground truth + +**Grade: partial — the defaults half is strong, the graceful-failure +half fails.** + +What already works with zero configuration, and is a real asset: + +- Missing config is **not an error by contract** (`src/config.rs:7-9`); + no config directory is created or required; a *broken* `init.lua` + does not block startup — the error lands in `*errors*` and the status + line (`src/config.rs:10-13`). +- Default-on persistence: recentf (`builtin/runtime/recentf.lua`, cap + 50, `C-x C-r`), saveplace (`builtin/runtime/saveplace.lua`, restores + cursor + view on `after-load`), autosave every 30 s with next-session + recovery (`builtin/runtime/autosave.lua:24`), per-bucket minibuffer + history. State root: `PMACS_STATE_HOME` → `$XDG_STATE_HOME/pmacs` → + `~/.local/state/pmacs` (`user_state_dir`, `src/state.rs:54-70`), + wired only in real entry points (`install_state_dirs`) so tests stay + hermetic. +- Atomic saves, full editing surface, bundled grammars for every + preconfigured LSP language. + +What fails the escalation path: + +- Step 3 ("easy to find") fails for both settings and commands (§5). +- Step 5 ("explains where the value came from") is **unanswerable + today**: config overrides are stored as bare values with no source + (§11). +- "Graceful failure when external tools are absent" is the silence + asymmetry (§1.2). The `--gpu` message (`src/main.rs:367-379`) is the + pattern to replicate; LSP auto-attach is the anti-pattern. + +--- + +## 4. Progressive Disclosure + +Pmacs should support several levels of use without requiring users to +inhabit the most advanced one. These levels should be different +presentations of the same underlying objects — a command selected from a +context menu, invoked through `M-x`, bound to a key, called from Lua, or +triggered by an agent should be the same command object. + +### Ground truth + +**Grade: inverted.** The advanced level is largely real; the beginner +level is the one missing. Audited level-by-level: + +**Beginner** (should see: files, buffers, search, diagnostics, terminal, +build actions, menus, missing-tool guidance): + +- files ✗ (no find-file at audit; PR #162 in flight) · buffers ✓ (`C-x + b`, `*buffer-list*`) · search ✓ (`C-s`/`C-r`/`C-M-s`; project.search + is M-x-only) · diagnostics ✓ once a server runs · terminal ✓ but + M-x-only · build ✓ but M-x-only with empty prompt · menus △ + (right-click only, 11 items) · missing-tool guidance ✗ (§1.2). + +**Intermediate** (should discover: palette, keybinding search, workspace +settings, profiles, package management, task definitions, +frontend/language settings): + +- palette △ (`M-x` fuzzy over bare names, §5) · keybinding search ✗ (no + list-keybindings/where-is commands) · workspace settings ✗ (no + workspace scope, §11) · profiles ✗ (§12) · package management ✗ + in-session (§13) · task definitions ✗ · frontend customization △ + (themes, `pmacs.gpu.set_font`, statusline providers — all Lua-only) · + language settings △ (raw Lua tables, outside the registry). + +**Advanced** (should be able to: inspect implementations, redefine live, +create packages, new views, providers, keymap layers, workspace policy, +orchestrate workers, replace interaction models): + +- inspect ✓ (SourceLocation on everything; no jump-to-source command + though) · redefine live ✓ (`unregister` + `define`) · packages ✓ + (authoring is real, §13) · new views ✓ (listview is Lua-usable) · + providers ✓ (statusline; completion/minibuffer sources are a fixed + Rust vocabulary) · keymap layers ✗ (§6 — the mechanism does not + exist) · workspace policy ✗ · orchestrate workers △ + (`pmacs.workers.register` funnels into builtin dispatchers, §9) · + replace interaction models ✗ (the shadows, §6). + +The "same command object" principle largely holds where surfaces exist — +menu items, keybindings, and M-x all resolve command names into the one +registry — with one caveat: menu items are a **parallel registry of +labels** whose command references are unvalidated (§5). + +--- + +## 5. Unify Discoverability + +Pmacs already has the beginnings of a strong command registry. This +should become the center of a broader discovery model. Every meaningful +action should eventually expose: stable symbolic identity, title, +description, category, aliases, current keybindings, provenance, +applicability predicate (with an explanation when unavailable), argument +schema, destructive/asynchronous/reversible flags, locality, related +commands and settings, and source location. Settings should expose name, +type, description, default, effective value, provenance, scope, +validation rules, listeners, related commands. Packages and workers +should expose the analogous sets (§13, §9). + +This suggests a general pmacs principle: + +> **Anything that can affect the user should be discoverable as a +> structured object with identity, provenance, ownership, and +> lifecycle.** + +### Ground truth + +**Grade: substrate without surface — the sharpest instance of §1.1.** + +**What the substrate already has (genuinely strong):** + +- `Command` (`src/command.rs:66-79`) = `{ name, description, source, + body, predicate }`. Description is **mandatory and validated** (R42); + duplicate names are a hard error, not an overwrite; `SourceLocation + { file, line }` is auto-captured from Lua debug info on **every** + command, hook, menu item, config definition, config listener, and + keybinding — the user cannot forge it. ~147 `pmacs.command.define` + sites across `builtin/`. +- `ConfigDefinition` (`src/config_registry.rs:396-410`) is **richer + than `Command`**: name, mandatory description, `ConfigKind` + (Boolean/Integer/Number/String/Enum with bounds, choices, + allow_empty), default, `Live`/`StartupOnly` mutability, source. + `pmacs.config.list()` returns full descriptor tables. +- Reverse keybinding lookup exists as data: `KeymapStack::iter_all()` + (`src/keymap_stack.rs:295-311`) enumerates every binding; + `pmacs.describe.command(name).key_bindings` computes where-is on + demand. +- `pmacs.describe.*` (`src/lua_bindings/mod.rs:6042-6162`) returns + structured tables for command/key/buffer/view/mode/hook, and + `describe.key` resolves against the **active buffer + major mode**. +- M-x matching is fuzzy (case-insensitive subsequence with + boundary/consecutive bonuses, `fuzzy_score`, + `src/minibuffer.rs:637-666`). + +**What is missing, itemized:** + +- `Command` has **no title, no category, no aliases, no argument + schema, no destructive/async/reversible flags**. The dotted-name + prefix (`buffer.`, `lsp.`) is convention, not data. MCP tooling works + around the missing schema by stuffing rendered JSON schema text into + the description string. +- **`Command.predicate` is dead metadata.** It is read in exactly two + places (a literal line in the unreachable help renderer, and a test) + and **never evaluated** by `invoke`, `invoke_interactive`, dispatch, + M-x filtering, or the menu. The doc comment's claim that "the command + palette (T M2.7) uses it to gray out unavailable entries" describes + something that never shipped. +- **M-x shows bare name strings.** `CompletionSource::Commands` returns + `Vec` of names; the wire type `MinibufferPrompt.candidates` + is `Vec` (`pmacs-protocol/src/message.rs:994-1006`). No + description, no keybinding, no category alongside candidates — while + `CompletionPopupRow` (`:1231`) already carries `kind` and `detail`, + proving richer rows are a solved wire problem in this codebase. +- **The entire Rust help layer is orphaned** (§1.1). Consequence: two + parallel `*help*` implementations exist — `help.rs`'s + cross-referenced renderer and the Lua `show_help_text` in + `builtin/commands/default.lua:1103-1136` — and the one users can + actually reach (`M-x editor.describe-command`) renders **less** than + the unreachable one (no source, no scope, no predicate note). +- **Missing as commands entirely:** describe-key, describe-mode, + describe-hook, describe-buffer, where-is, list-commands, + list-settings, list-keybindings, apropos. What exists: + `editor.describe-command`, `editor.describe-setting`, + `editor.describe-instance[-buffer]`, `editor.list-buffers`, + `editor.list-workers`. `M-x describe-setting` prompts **free-text + with no completion source** (deliberately skipped — + `builtin/commands/default.lua:1180-1185`); a typo yields a status + line error. +- **No help prefix key.** `C-h` is `buffer.delete-word-backward` + (`builtin/keymaps/default.lua:86`, with a comment noting the key "was + free"). No `F1`, no `C-h k/f/b`. +- **Settings value provenance is absent.** Overrides are stored as bare + values (`global: HashMap`, + `src/config_registry.rs:693-708`); `describe-setting`'s "Source:" is + the *definition* site. "Why is this setting 4 and who set it?" is + unanswerable (§11). +- **Menu items are a parallel registry.** `MenuItem` + (`src/menu.rs:56-78`) carries its own hand-written `label` duplicating + the command's description, with a lazily-resolved `command` name + string that is **never validated to exist** — a typo'd item silently + does nothing when clicked. The wire row is label + separator only + (`MenuPromptRow`): no key hints, no grayed state. Note the asymmetry: + `pmacs.menu.list` reports `has_predicate`; `pmacs.describe.command` + does not. +- **The two key-lookup APIs disagree.** `pmacs.keymap.lookup` is + global-only (it resolves with no buffer and no modes, + `src/lua_bindings/mod.rs:6294-6307`) while `pmacs.describe.key` is + context-aware. `pmacs.keymap.list` erases `source` and renders + `Scope::Buffer(id)` as bare `"buffer"` (id erased), so full-fidelity + enumeration requires per-command `describe.command` calls. There is no + which-key-style prefix surface. + +**Shape of the fix:** roughly (a) three metadata additions on `Command` +(title, category, predicate actually evaluated + reported), (b) value +provenance in the config registry, (c) a dozen interactive commands and +richer M-x candidate rows over introspection that **already exists**. +This is the highest payoff-per-effort concern in the document. + +--- + +## 6. Eliminate Hardcoded Interaction Islands + +Pmacs's public programmability story will be strongest when all major +interaction layers pass through ordinary registries and extension +points. Temporary or modal interfaces — incremental search, query +replace, minibuffer prompts, completion menus, context menus, transient +selectors — should eventually use inspectable keymap layers rather than +special Rust-level interception. A general transient keymap model +includes priority, activation condition, owner, lifetime, fallback +behavior, discoverability, help labels, and cancellation behavior. + +### Ground truth + +**Grade: weak, and growing by one island per modal feature.** + +Everything funnels through one function: `EditorInstance::dispatch_key` +(`src/editor.rs:901`), a single input-precedence state machine (its own +`#[allow(too_many_lines)]` says as much). The audited precedence order: + +| # | Surface | Guard site | Decoder | Kind | +|---|---|---|---|---| +| 0 | popup-vs-modal auto-close | `editor.rs:917-925` | — | pre-step | +| 1 | Context menu | `editor.rs:933` | `MenuKey::from_chord` (`editor.rs:3005`) | **full shadow** | +| 2 | isearch | `editor.rs:939` | `SearchKey::from_chord` (`editor.rs:2902`) | **full shadow** | +| 3 | query-replace | `editor.rs:945` | `QueryReplaceKey::from_chord` (`editor.rs:2967`) | **full shadow** | +| 4 | Minibuffer | `editor.rs:951` | `MinibufferAction::from_chord` (`src/minibuffer.rs:468`) | **full shadow** | +| 5 | Completion popup | `editor.rs:958-971` | `CompletionPopupKey::from_chord` (`editor.rs:3056`) | **partial shadow** (control chords only; skipped while a multi-key prefix is pending) | +| 6 | Terminal transport + `C-c` escape | `editor.rs:973-1010` | `is_terminal_escape_chord` (`editor.rs:4355`) | **partial, transport-level** | +| 7 | Ordinary dispatch | `editor.rs:1018-1032` | `KeymapStack::resolve` | the only inspectable layer | + +Facts that define the gap: + +- **Full shadows eat every key**, including unrecognized ones (each + decoder has an `Ignore`/`Dismiss` fallback arm). While a terminal + buffer is focused and unescaped, *all* keys encode to the child — + `C-c`-leading user bindings are **structurally unreachable** in a + terminal buffer. +- **No transient-keymap mechanism exists to migrate to.** `KeymapStack` + has exactly three fixed scopes — `Buffer(BufferId)`, `Mode(String)`, + `Global` (`src/keymap_stack.rs:37-44`); resolution order buffer → + mode → global with cooperative prefix-pending across scopes + (`resolve`, `keymap_stack.rs:235-291`). No layer stack, no push/pop, + no priority, no lifetime. The Lua scope accept-list hard-rejects + anything else. So this is not "migrate the shadows to the layer + system" — **the layer system must be built first.** (`active_modes` + is also at most one mode today; minor modes are unbuilt.) +- **`describe-key` lies while a shadow is active.** With the completion + popup open, `describe-key C-n` reports `cursor.down @global`; the + literal arm `'n' => Some(Self::Next)` fires instead. Introspection + has zero awareness of the shadows; Lua can observe only a boolean per + surface (`popup_visible`, `search_active`, `query_replace_active`, + minibuffer-active). +- **This is deliberate and documented** — rationale R51 + (`docs/keybindings.md`, `src/minibuffer.rs:470`): the shadows are + intentionally not user-configurable. The completion framing + considered and rejected buffer-local binds on teardown-lifecycle + grounds (`docs/in-buffer-completion-framing.md:93-105`) — the + objection was a *leaked binding outliving its session*, which is an + argument for a lifetime-owning layer handle, not against layers. +- **Three hand-synced guard lists** must be updated per shadow: + `dispatch_key`, `dispatch_idle_for` (`editor.rs:791` — deliberately + omits the partial popup shadow; load-bearing for CRDT frontends' + optimistic-apply correctness), and `dispatch_paste` + (`editor.rs:1129-1140`). +- Off-path hardcodes: client-side **F12 detach** (`is_detach_key`, + `src/attach.rs:997-1006`) and the GPU **optimistic key classifier** + (`crate::optimistic::classify_key`) — the latter is classification, + not routing, and is kept honest by `dispatch_idle_for`. + +**The counter-example that proves the idiom:** the entire picker/panel +family — listview (references, outline), project-search, buffer-list, +compile-mode, REPL, terminal scroll commands — uses ordinary +**buffer-local keymaps** via `pmacs.keymap.bind { scope = "buffer" }` +(`builtin/runtime/listview.lua:76-88` and siblings). These are +inspectable, correctly reported by describe-key, and rebindable from +`init.lua`. Roughly half the transient UI already lives on the right +side of the line. + +**The concrete missing primitive** is small and well-scoped: a transient +overlay consulted before buffer scope (a `Scope::Transient` or an +overlay `Vec`), with (a) push/pop tied to session lifetime via a +lifetime-owning handle (RAII on the Rust side), (b) a full-shadow vs +partial-shadow flag (isearch eats everything and falls back to +search-self-insert; the popup intercepts eight chords and falls +through), and (c) `dispatch_idle_for` **derived** from the stack ("any +active layer is full-shadow") instead of hand-maintained. With that, the +six ladder rungs collapse into "session pushes a layer on open, pops on +close," and describe-key becomes truthful for free. + +--- + +## 7. First-Class Workspaces + +Project-root detection is useful, but pmacs needs a richer workspace +object. A project answers "which root contains this file?"; a workspace +answers "which persistent development environment owns this set of +activity?" A workspace should eventually own: + +```text +Workspace +├── identity +├── one or more roots +├── execution location +├── environment and toolchain +├── configuration layers +├── trust policy +├── enabled packages +├── language-server instances +├── indexes +├── terminals and processes +├── tasks +├── debugger sessions +├── open buffers and views +├── frontend layout state +└── persistence and restoration policy +``` + +This matters for multi-root language servers, monorepos, generated +files, remote projects, containers, HPC environments, per-project +packages, task ownership, session restoration, and project-specific +trust. The workspace should be a core runtime entity, not an informal +convention shared across unrelated subsystems. + +### Ground truth + +**Grade: missing — what exists is a marker walk plus four independent +per-subsystem conventions.** + +- **Detection**: `src/project.rs` — `default_markers()` is + `Cargo.toml`, `go.mod`, `package.json`, `.git` (directory), with the + deliberate rule that **language markers outrank `.git`** at the same + ancestor level; upward walk, innermost wins; `set_search_boundary` + honored; `ProjectKind` (e.g. `Cargo`) exists and is **consumed by + nothing** user-facing. +- **Four independent consumers**, each resolving on its own: + LSP root (`project_root_for`, `builtin/runtime/lsp.lua:554-570`: + config override → marker walk → file's own directory, returning + `root, source` where source ∈ config/detected/fallback); compile cwd + (`project_root_of_active`, `builtin/runtime/compile.lua:600-608`); + project-search root (`resolve_search_root`, + `builtin/commands/default.lua:843-857`, falls back to `"."`); the + project symbol index (`.pmacs/index.json`, `src/project_index.rs`). +- **There is no "current project" independent of the active buffer's + path.** With only `*scratch*` open, every consumer above returns + nil/`"."`. Nothing owns the set {roots, servers, terminals, tasks, + layout} — which is why desktop-save under a daemon had nothing + principled to attach to (Q#DS9, §2 step 12). +- **First slice in flight**: the multi-root LSP server-affinity work + (branch `lsp-multi-root-affinity`) makes *(language, found-root)* the + server identity — the first time a root functions as an identity key + rather than a spawn parameter. Note it is again per-subsystem: LSP + learns roots; compile, search, index, and trust do not share the + object. + +A workspace entity is a **model gap** (real arc), not wiring. It is also +the prerequisite that keeps §8 (locations), §9 (task ownership), §11 +(workspace config scope), and step 12 of the journey from each inventing +their own ownership story. + +--- + +## 8. First-Class Execution Locations + +Pmacs's daemon/frontend architecture gives it an excellent basis for +remote development. The next step is to model execution location +explicitly — a value that can be inspected and assigned, not an +implementation detail hidden inside file access or process spawning: + +```text +Location +├── local +├── ssh://host +├── container://name +├── slurm://allocation +├── daemon://session +└── custom provider +``` + +Filesystem roots, processes, terminals, language servers, workers, +debuggers, indexers, package services, and build/test tasks should all +carry a location. That makes answerable: where is this server running? +where will this build execute? is this terminal local? can this worker +migrate? what happens if the remote daemon disconnects? + +### Ground truth + +**Grade: missing as a model; the architecture half already works.** + +What exists: `pmacs --attach user@host` (remote TUI over SSH), +`ssh:user@host/instance` / `local:/path.sock` addressing, mosh-modeled +reconnect-on-drop, and the daemon/frontend split itself — i.e. +`daemon://session` exists implicitly and robustly. What does not exist: +any `Location` value. Every `ProcessSpec` spawn, LSP server, terminal +PTY, and worker is implicitly daemon-local; no resource carries a +location field; nothing can be asked "where is this running?". No +container/slurm/provider concept anywhere. + +This concern is deliberately *after* §7 in dependency order: a location +without a workspace to scope it has nothing to attach to. For the +research/HPC ambition (§12's Research Workstation profile), this pair is +the long-lead differentiator — nothing else in the editor market models +it well. + +--- + +## 9. Extend the Worker Model into Structured Concurrency + +Pmacs's worker system is one of its most distinctive strengths. +Cancellation, supersession, streaming, frame-aware draining, and the +`*workers*` view provide a strong basis. The next step is ownership and +hierarchy: every substantial task should have an owner, a workspace, an +optional buffer/view, a parent, children, a latency class, a +cancellation scope, a resource budget, an execution location, progress, +and failure attribution. + +```text +Workspace: pmacs +└── Command: project-build + ├── Task: save-dirty-buffers + ├── Task: cargo-check + │ ├── Process: cargo + │ └── Stream: compiler-diagnostics + └── Task: refresh-diagnostics +``` + +Cancelling `project-build` should cancel its children. Closing a +workspace should terminate or detach workspace-owned work. Reloading a +package should stop package-owned tasks. The activity view should answer +what is running, why, who owns it, where, what depends on it, and what +cancellation will affect. That turns parallelism into a product feature +rather than an implementation claim. + +### Ground truth + +**Grade: mechanism without identity.** + +**The mechanism layer is solid:** cooperative per-job cancellation +tokens with panic isolation (`src/worker.rs:13-28`); supersession with +correct settle-time pruning (`src/async_runtime.rs:688-701`) — a +genuinely good primitive; a completions ring (cap 64); `register_external` +so non-pool work (LSP requests, MCP) appears uniformly; one shared +`ProcessSupervisor` under everything (`src/editor.rs:341`); the +`*workers*` view (`src/workers_buffer.rs`, opened by `M-x +editor.list-workers`, auto-refreshing, `C-c C-k` cancel-at-point). + +**The identity layer is absent:** + +- `PendingJob` (`src/async_runtime.rs:365-392`) carries `{cancel, + state, supersede_key, stream_buffer, max_batch, kind, + dispatched_at}`. **No owner. No purpose string. No + workspace/buffer association. No parent.** The one buffer link that + exists (parse job → buffer) lives in a `SyntaxCoordinator` side map, + invisible to the workers view. +- `JobKind` is a **closed 12-variant enum** (Sleep, ComputeSum, EmitN, + Grep, Parse, FsReadDir, FsStat, FsRename, FsChmod, FsRemove, + McpRequest, LspRequest). `pmacs.workers.register` funnels Lua jobs + into existing Rust dispatchers, so **every third-party job renders + under a builtin's label**. +- Supersession is opt-in per dispatch site and underused: `"search"` + (grep) and `lsp:{method}:{sid}:{uri}` use it; **parse jobs and all + MCP requests pass `None`** — a fast typist stacks parse jobs. +- Cancellation scopes: per-id and per-key only. No cancel-all, + by-kind, by-buffer, by-owner, or by-subtree — there is no scope to + range over. +- **Four disjoint activity planes with no join key:** + +| Plane | Surface | What it misses | +|---|---|---| +| Async jobs | `*workers*` | processes, servers, terminals | +| OS processes | `pmacs.process.list` (no buffer view exists) | **filters to `LineOriented` only — terminal PTYs are invisible**; `spawn_terminal` bypasses the public path entirely | +| LSP servers | `*lsp*` status text | **no builtin command opens it**; LSP sets `RestartPolicy::Never` on the supervisor and runs its own restart logic | +| Terminals | private id set drained after the supervisor tick | user-visible in none of the above | + + A terminal PTY appears in **no** user-visible activity view. An LSP + server appears in `*lsp*` (unreachable) and `list()`; its requests + appear in `*workers*`; nothing joins them. +- **No progress indicator exists anywhere** — no statusline spinner, + no busy count (grep for progress/spinner/busy in `src/statusline.rs` + is empty). "Visible asynchronous work" (§3) is currently false unless + the user knows to run `M-x editor.list-workers`. +- `ProcessSpec.label` is the nearest thing to attribution: caller- + supplied, unvalidated convention (`lsp:{name}`, terminal buffer + name). + +The audit's conclusion, worth preserving verbatim: *because identity is +missing, scoped cancellation has nothing to scope over and a unified +activity view has nothing to group by — the four views exist precisely +because there is no common key to merge them on.* Owner/purpose/parent +fields on the job and process specs are the prerequisite; the unified +view and the ownership tree fall out of them. + +--- + +## 10. Define Extension Trust and Isolation Classes + +Pmacs should preserve live, low-friction programmability — it should not +force all extensions into rigid out-of-process APIs. At the same time, +namespace isolation inside a shared Lua state is not enough for fault +containment, security, latency containment, memory accounting, +native-code isolation, reliable unloading, or project-local trust. Pmacs +should define extension classes before the ecosystem becomes large: + +- **10.1 Trusted core packages** — in-process, deep API access, + distributed with pmacs or explicitly trusted. +- **10.2 Normal Lua packages** — shared/managed runtime, declared + capabilities, owned registrations and workers, execution budgets, + measurable latency, reloadable lifecycle, package-level error + attribution. +- **10.3 Isolated service extensions** — separate process, typed RPC, + crash recovery, resource accounting, explicit fs/process/network + permissions. +- **10.4 Project-local / untrusted** — explicit approval, restricted + capabilities, strong isolation, workspace-scoped trust, easy + revocation. + +### Ground truth + +**Grade: missing — one class exists.** + +Every package today is a 10.1/10.2 hybrid with none of 10.2's +machinery: in-process, per-package `_ENV` with `__index = _G` +(namespace hygiene, not containment), full API access, no capability +declarations, no budgets, no latency measurement, no owned-registration +lifecycle (§13). The only containment primitive in the tree is the +instruction-count hook that can cancel a hot-looping main-thread chunk +(`src/lua_isolation.rs:1-39`) — a runaway guard, not an isolation class. + +Two real assets to build on: the loader's `exports` gating (the package +searcher is deliberately inserted at position 1 of `package.searchers` +so exports are enforceable, `src/lua_bindings/mod.rs:3891-3900`), and +**MCP as the existing 10.3 seam** — packages can already spawn MCP +servers and consume their tools over a typed transport +(`docs/mcp-for-package-authors.md`), which is exactly the +separate-process/typed-RPC shape 10.3 asks for. Project-local trust +(10.4) has a natural anchor once §7's workspace exists. + +Sequencing note: 10.2's "owned registrations, reloadable lifecycle, +error attribution" is the same work as §13's ownership gap — do it once, +under one arc. + +--- + +## 11. Configuration as Typed, Layered Data + +Pmacs's typed configuration registry is the correct foundation. It +should develop into a layered system with explicit provenance. Likely +layers: built-in defaults; profile defaults; user settings; +machine-local; remote-location; workspace; root/folder; language/mode; +buffer-local; session overrides. A setting inspection view should show +the full chain and the active source: + +```text +setting: editor.tab-width +effective value: 4 +type: integer +scope: workspace + +defined by: + built-in default: 8 + Rust profile: 4 + user setting: 2 + workspace override: 4 + +active source: + ~/src/pmacs/.pmacs/settings.lua +``` + +Pmacs should also preserve three distinct levels — **settings** (typed +declarative data), **behavioral customization** (commands, hooks, +keymaps, Lua), **package construction** (new capabilities) — so that +users do not need executable Lua for ordinary preferences, while +advanced users can still replace the mechanism. + +### Ground truth + +**Grade: partial — the foundation shipped (#127) and is correct; the +layering, provenance, and adoption have not followed.** + +- The registry is typed, described, duplicate-rejected, freeze-aware + (`StartupOnly`), listener-bearing, and introspectable — see §5. Its + design decisions (always-store overrides, explicit buffer, no ambient + scope) are recorded in `docs/config-registry-framing.md`. +- **Two scopes exist** of the ten layers listed above: global and + buffer-local. Per-language and per-project are patterns (a hook + calling `set_local`), not scopes. No profile, workspace, machine, or + remote layer. +- **Value provenance is absent** (§5): overrides are bare + `ConfigValue`s; `describe-setting`'s "Source:" names where `define()` + ran. The inspection view sketched above is currently impossible to + render. +- **Adoption is five settings**: `editing.auto-pair` (pair.lua), + `editing.trim-on-save` (editops.lua), `autosave.interval-ms` + (autosave.lua), `window.panel-height` + `window.min-height` + (window.lua). Everything else a user might set — theme, fonts, LSP + server config, killring size, recentf/saveplace/desktop enables, + pair sets, comment strings, `pmacs.parse.*` — lives in raw Lua + outside the registry and is therefore invisible to `describe-setting` + and any future settings UI. The migration list is already written: + `docs/config-registry-framing.md` "named deferrals" (table-valued + settings are the hard prerequisite for LSP/pair/comment tables). +- **No persistence**: settings changed at runtime do not survive + restart (the `custom-file` split-brain question is a named deferral). +- The three-level separation holds in principle today (registry / + hooks+keymaps / packages), but with five settings registered, level 1 + is effectively empty — users need executable Lua for nearly every + ordinary preference, which is the exact failure the section warns + about. + +--- + +## 12. Profiles as Product-Level Bundles + +Pmacs should offer a small number of official profiles bundling default +keymaps, visible interface regions, package recommendations, settings, +task conventions, discovery hints, and onboarding: **Pmacs Standard** +(approachable graphical workstation), **Emacs** (familiar bindings, +minibuffer-centered), **Minimal**, and later **Research Workstation** +(terminals, remote machines, Slurm, proof assistants, long-running +builds). Profiles must not create separate products — they exercise the +same registries and primitives. + +### Ground truth + +**Grade: missing.** Not a named concept anywhere in the tree. There is +one hardcoded default: a single 161-line keymap +(`builtin/keymaps/default.lua`) that is already a de-facto hybrid of the +"Standard" and "Emacs" profiles (CUA selection + Emacs kill/yank/isearch +chords). No profile object, no bundle format, no selection mechanism, no +per-profile defaults layer (§11's missing profile scope is the same +gap). Prerequisites: the config profile layer, and enough registry +adoption that a profile has something declarative to set. + +--- + +## 13. Package Experience, Not Merely Package Resolution + +Pmacs already has serious package-resolution machinery. Product +coherence requires a package *lifecycle* experience: search, +installation, updates, disable, reload, uninstall, version inspection, +dependency graph, compatibility warnings, capability declarations, +ownership inspection, error history, active-worker inspection, resource +use, trust state. Installation should work during a running session. +Users should be able to install coherent capability bundles ("Rust +Development") rather than individual packages. Marketplace sequencing: +stable format → ownership/reload lifecycle → in-editor manager → curated +registry → bundles → publisher identity → public marketplace. + +### Ground truth + +**Grade: resolution without lifecycle — the artifact layer is mature, +the lifecycle layer assumes a single author iterating on their own +package.** + +**Mature (keep):** `pmacs.toml` manifest (validated name, semver, +`pmacs_required`, dependencies/conflicts, entry, exports); git-address +installs (`github:`/`gitlab:`/URL; auth delegated to git config; no +registry service); iterate-to-fixed-point resolver with deterministic +ordering and honest unsatisfiability errors (documented no-backtracking +tradeoff); merged SHA-256 lockfile; per-package `_ENV`; `exports` +enforced by a position-1 searcher; bundled packages through the +identical path. + +**The lifecycle facts:** + +| Operation | State | +|---|---| +| `install` / `install_project` / `install_local` / `update` | exist, **init.lua-only** — `require_init_phase` raises `InitOnlyApi` mid-session; the error text admits there is no CLI equivalent ("restart with an updated init.lua") | +| `reload(name)` | **works in-session and is well-built**: unload hooks → loaded-table invalidation (name + `name.` prefixes) → env clear → re-require | +| `installed()` / `describe(name)` / `load(name)` / `on_unload(fn)` | work in-session; `describe` returns manifest metadata only | +| uninstall / remove | **absent** — the documented procedure is `rm` in a shell (`src/packages/installer.rs:1178-1180`) | +| disable / enable | **absent** — no concept | +| search / list-available | **absent** — no registry, no index; you must already know a git URL | +| inspect contributions | **absent** — `describe` cannot say which commands/hooks/keys/settings a package contributed; no `*packages*` view exists | + +**Structural findings that any lifecycle arc must address:** + +- **The roster is in-memory per session**, rebuilt from `init.lua` + calls. A package on disk that init.lua doesn't `install` is invisible + to `require`/`installed()`. And because `do_install` unconditionally + runs the resolver, **every startup runs `git fetch --prune --tags` + per package** before the idempotent fast-path can trigger — a + first-launch latency and offline-use problem. (The Rust-side + `UpdatePolicy::Frozen` that would fix offline installs is + **unreachable from Lua** — dead code.) +- **Ownership is not tracked.** `SourceLocation` is path attribution, + not package attribution (for `install_local` the path may be the dev + tree, not the install root); nothing indexes registrations by source; + there is no "what did package X register" query and no + bulk-unregister. The correct signal (`CurrentlyLoadingPackage`) + already exists and is ignored by every registrar (§1.1). +- **The teardown surface is incomplete in a way that makes the + documented convention unsatisfiable: `pmacs.hook.remove` does not + exist** (`install_hook_module` exposes define/add/list/run; + `HookRegistry` has no removal method at all). A package that calls + `pmacs.hook.add` leaks a callback on every reload, permanently. The + package-author guide's hand-rolled `OWNED = {}` cleanup pattern + (`docs/package-author-guide.md:379-415`) cannot be followed for + hooks. (Independently rediscovered by the Lean 4 arc scout.) +- **Error attribution exists on exactly one code path** — + `packages.load` wraps require and logs `[package ] load failed` + to `*errors*` — **and nothing in `builtin/` uses it**. Plain + `require` from init.lua attributes only by traceback; a failing + `install` aborts the whole init.lua with no per-package isolation. +- Install-root directory names are the manifest name's last segment, so + same-basename packages collide on disk (knowingly accepted, + `installer.rs:44-50`). + +Sequencing: ownership + `hook.remove` + attribution is the same work as +§10's class 10.2 and is the prerequisite for disable/uninstall/inspect; +in-session install requires reworking the init-phase gate; search/ +bundles/marketplace remain correctly last. + +--- + +## 14. Coherent Workbench Primitives + +Pmacs should resist implementing each subsystem with a custom UI +vocabulary. It should provide a small set of reusable view primitives — +editable text view, virtual list, tree, structured table, inspector, +output channel, diagnostics collection, task/progress view, diff view, +transient selector, contextual popup, side panel, bottom panel, +help/documentation view — and packages should provide structured models +to them. Git status, project files, symbol outlines, package +dependencies, and worker trees should share one tree model with +consistent selection, expansion, filtering, action discovery, mouse and +keyboard behavior, persistence, and accessibility. + +### Ground truth + +**Grade: partial, with the best trajectory of any concern.** + +Primitive-by-primitive against the list above: + +- **Editable text view** ✓ — the buffer itself, everywhere. +- **List** ✓ — **listview is a real shared primitive**, the strongest + coherence asset in the UI layer: references, outline, buffer-list, + and project-search all use it, with a shared buffer-local keymap + idiom (RET/SPC visit, n/p, g refresh, q quit) that is inspectable and + rebindable (§6's counter-example). +- **Output channel** ✓ — the compile-mode `*compilation*` model + (streamed, intercept-read-only, error-rule parsing), reused by grep + and shell-command. +- **Diagnostics collection** ✓ — `DiagnosticStore` + signs + unified + `error.next` source. +- **Transient selector** ✓ — the minibuffer (though its `source` + vocabulary is fixed Rust-side). +- **Contextual popup** ✓ — completion popup, context menu (each a + shadow, §6). +- **Bottom/side panel** ✓ — landed as bottom-panel Stage 1 (#155): + `WindowParams` side/fixed_rows/dedicated, `display = "current" | + "panel"` adopted by listview/compile/terminal, quit-action, divider + drag. Stage 2 (GPU band) pending its own framing. +- **Task/progress view** △ — `*workers*` exists but joins nothing + (§9). +- **Help view** △ — exists twice (§5); needs unification, not + invention. +- **Tree** ✗ — none. The named future consumers (project files, symbol + hierarchy, package dependency graph, worker trees, git status) will + each need it; building it once *before* dired's directory view and + the workers tree harden their own conventions is exactly this + section's point. +- **Structured table / inspector / diff view** ✗ — none. (`describe.*` + tables are the inspector's data model without a view; the + wire-declared `ResourceOffer` family was reserved for diff/blame + sources and remains unproduced.) + +--- + +## 15. Contextual Affordances + +Pmacs should remain excellent for keyboard-driven users while making +capabilities visible to users who do not know their names: a diagnostic +should offer code actions; a test definition run/debug; a Git change +stage/revert/diff; a missing formatter configuration guidance; a symbol +references/rename/definition/documentation; a remote workspace its +location; a long-running task progress and cancellation. Affordances +should invoke ordinary commands, never separate logic paths. + +### Ground truth + +**Grade: weak.** + +What exists: the right-click context menu — 11 items in 4 groups +(edit/symbol/diagnostic/history, `builtin/menus/default.lua:117-142`), +with a closed context vocabulary (`always`/`selection`/`symbol`/ +`diagnostic`, `src/menu.rs:44`) and per-item predicates that *are* +evaluated (unlike command predicates). It correctly invokes ordinary +commands by name. Its limits: right-click only (no keyboard path in), +no key hints on rows, invisible items filtered rather than grayed, and +the unvalidated command references of §5. + +What does not: + +- **Code actions apply the first action blindly** — no picker (a + roadmap "dark matter" item still true at audit). +- **There is no Git integration at all** — no status, stage, diff, + blame, or gutter markers anywhere in the tree (gutter git riders and + the `ResourceOffer` diff/blame family are named deferrals). The Git + affordance list above has nothing to attach to yet. +- No test run/debug affordances (DAP is a future arc, + `docs/dap-debugging-framing.md`). +- No missing-tool guidance affordances (§1.2 — the diagnostic that + *should* say "rust-analyzer not found — install with rustup" says + nothing). +- No remote-location display (§8 — nothing carries a location). +- Task progress/cancellation affordances exist only inside `*workers*` + (§9); a long-running task shows nothing at the point of origin. + +--- + +## 16. Productize the Semantic Frontend Architecture + +The semantic protocol should be visible as a product advantage: native +frontend rendering, frontend-specific typography, high-quality +decorations, efficient incremental updates, accessible semantic +information, multiple simultaneous frontends, stable remote attachment, +frontend experimentation without reimplementing editor semantics. To +preserve coherence: core commands frontend-neutral; stable semantic +identities; explicit capability negotiation; graceful degradation; +layout state separated from semantic state; no frontend becoming the de +facto privileged implementation. + +### Ground truth + +**Grade: strong — the healthiest concern in this document, and most of +its asks are already practiced.** + +- Versioned, negotiated protocol `SUPPORTED=[6..=20]` with deliberate + encoding-breaking bumps, both-frontends support required per bump, + and byte-pin discipline for appended variants (handoff §4). +- Two genuine frontends share the conceptual model; CRDT concurrent + editing with presence across them; remote attach + reconnect. +- **Graceful per-frontend degradation is practiced, not aspirational**: + fold projection is per-frontend (`FrontendView.fold_projection`, + selected from the negotiated `semantic_render` bit) so a grid + frontend collapses folds while a simultaneous GPU session does not + skip lines (#149/#148). +- The GPU frontend exceeds the TUI (minimap, squiggles, typography) + without the TUI losing the model — the "no privileged frontend" rule + is holding under real divergence pressure. + +Remaining, honestly small relative to the section's ambition: capability +negotiation is per-bit rather than a first-class declared capability +set; layout state vs semantic state separation is partial (window layout +is daemon-side; desktop restore under a daemon is unresolved, §2 step +12); and the advantage is invisible as *product* because §17 means +nobody outside the repo can try it. + +--- + +## 17. Distribution Is Part of the Product + +Pmacs should eventually be installable without repository familiarity: +reproducible release builds, Linux and macOS binaries, checksums and +signatures, stable and nightly channels, one-command update, rollback, +protocol- and package-API compatibility reporting. First launch should +create/locate config directories, explain the default profile, identify +optional external tools, and let the user open a project immediately. + +### Ground truth + +**Grade: missing — zero release machinery exists.** + +`.github/workflows/` contains exactly one workflow, `ci.yml`, and it is +test-only (fmt/clippy/test matrix; the only `release` strings in it are +`cargo test --release` flags). No release job, no artifact upload, no +tags-to-binaries path, no checksums, no channels, no update or rollback +mechanism. Installation is `git clone` + `cargo build --release +--workspace --features pmacs/crdt` (README), which additionally requires +knowing the feature-flag matrix (luajit vs lua54 × crdt). Runtime +dependencies (`/bin/sh`, `stty`, git, tar) are documented in the README +and never checked at runtime. First launch creates nothing and explains +nothing (§18) — though by design it also *requires* nothing (§3), which +is the right half to have. + +This concern is independent of every other arc and can start anytime; +until it does, every other coherence improvement is invisible outside +the repository. + +--- + +## 18. Onboarding + +Pmacs needs onboarding that teaches concepts through use: open a +project → command palette → find a file → terminal → inspect a +diagnostic → view workers → change a setting → inspect where it came +from → Lua REPL → redefine a command. That sequence communicates the +whole thesis: already useful, discoverable, visible computation, +explainable settings, programmable internals. It should be an ordinary, +restartable help workspace, not a one-time modal wizard. + +### Ground truth + +**Grade: missing entirely.** + +No welcome buffer, no tutorial, no first-run detection, no cheat sheet +reachable from inside the editor (`docs/keybindings.md` exists on disk +only). `C-h` is `buffer.delete-word-backward`; there is no help prefix +key and no `F1`. The sole discovery affordance is knowing to press +`M-x` (`builtin/keymaps/default.lua:141` — whose own header comment +calls it the "command palette"). The empty `*scratch*` buffer that +greets a new user says nothing (`EditorCore::new` sets an empty +status). + +Note the dependency: five of the ten onboarding steps above currently +lead somewhere broken or invisible (find a file — in flight; inspect a +diagnostic — silent-failure risk; view workers — undiscoverable; +setting provenance — unanswerable). Onboarding is correctly sequenced +*after* the P1/P4 fixes, but the cheap floor — a welcome buffer in +`*scratch*` naming `M-x`, the keybinding cheat sheet as a help buffer, +and a help prefix decision — has no prerequisites at all. + +--- + +## 19. Product Coherence Acceptance Tests + +Pmacs should add acceptance tests that exercise product behavior across +subsystems, complementing (not replacing) subsystem tests: + +- **Installation/first launch** — no config, open a directory, usable + workspace, actionable guidance for missing tools. +- **Command discovery** — search by title and synonym; display + keybinding, provenance, availability; invoke from palette and menu + through the same object. +- **Workspace lifecycle** — multi-root open, servers, terminal, build, + close, restore, ownership cleanup. +- **Worker ownership** — start completion/search/build, inspect, + cancel a parent, confirm child cancellation and UI recovery. +- **Package lifecycle** — install in-session, inspect contributions, + disable, confirm disappearance, reload, uninstall cleanly. +- **Remote execution** — attach to remote daemon, edit optimistically, + remote terminal and server, disconnect/reconnect, coherent state. + +### Ground truth + +**Grade: missing — but the culture that would make them excellent is the +project's strongest process asset.** + +Zero cross-subsystem journey tests exist. Every acceptance suite in the +tree pins one subsystem's contract (superbly — bite-verified, +falsified-by-revert, vacuity-checked). Several of the scenarios above +are currently *untestable* because the behavior doesn't exist (install +in-session, disable, open a directory); the ones that are testable +(first launch, command discovery, worker cancellation, remote +attach/reconnect) could be written today and would immediately pin the +journey against regression. The first coherence acceptance suite should +be the §2 journey itself, growing a step at a time as steps become +real — that is how "the journey is a release gate" stops being +aspirational. + +(Related lesson already in the handoff: `compile_mode_acceptance` +accidentally reads the real user config — an *unintentional* +whole-product test that keeps catching real coherence bugs. That is +evidence this class of test has teeth.) + +--- + +## 20. Recommended Priority Order + +Each priority is annotated with its audited state and whether the gap is +**wiring** (surface over existing machinery — cheap) or **model** (a +missing runtime entity — a real arc). + +### Priority 1: Protect the golden product journey + +Establish the end-to-end workflow; treat regressions as release +blockers. **State: broken at step 3 (§2). Mostly wiring, and unusually +cheap:** directory-argument handling; a find-file surface (in flight, +PR #162); surfacing the LSP spawn failure with guidance (§1.2); a +compile keybinding + `cargo build`/`test` default from the existing +`ProjectKind::Cargo`; a terminal keybinding; a welcome buffer. The +journey acceptance suite (§19) is the ratchet that keeps it fixed. + +### Priority 2: Make workspace and location explicit + +Otherwise project, LSP, remote, task, and persistence accumulate +incompatible ownership models — the audit confirms four have already +diverged (§7). **State: missing; first slice in flight (multi-root LSP +affinity). Model gap:** the Workspace entity (§7), then Location values +(§8). This is the long-lead arc; start it before the fifth and sixth +subsystems grow their own root conventions. + +### Priority 3: Strengthen extension ownership and isolation + +**State: missing; prerequisite-shaped. Model gap, with one bug-sized +prerequisite: `pmacs.hook.remove` does not exist (§13).** The work +unit: registrations carry their owning package (the +`CurrentlyLoadingPackage` signal already exists), removal APIs complete +the set, error attribution becomes default rather than opt-in. This +single arc unblocks §13's disable/uninstall/inspect, §10's class 10.2, +and package-scoped task cancellation in §9. + +### Priority 4: Unify discovery + +**State: substrate without surface. Almost pure wiring — the best +payoff-per-effort in this document (§5):** a dozen interactive commands +over existing introspection, richer M-x rows (the wire pattern already +exists), title/category on `Command`, predicate evaluation, help-layer +unification, a help prefix key. Most of P1's "understand the interface" +and §18's floor ride on this. + +### Priority 5: Finish the workbench convergence + +**State: partial and moving (§14) — bottom panel Stage 1 landed, GPU +band pending; listview proven.** Remaining: the tree primitive (build +it before dired and the worker tree invent two), table/inspector/diff, +help unification. Wiring plus one modest model piece (the tree model). + +### Priority 6: Productize configuration + +**State: foundation only (§11). Model-lite:** value provenance in the +registry, then layering (profile/workspace scopes — depends on P2 for +workspace, §12 for profiles), then adoption migration (table-valued +settings are the hard prerequisite), then persistence. + +### Priority 7: Build package lifecycle UX + +**State: not started; correctly sequenced after P3.** In-session +install (init-gate rework), disable/uninstall over P3's ownership, +`*packages*` view over P5's primitives, then bundles and registry +sequencing per §13. + +### Priority 8: Ship binaries and release channels + +**State: zero (§17). Independent of everything — can start anytime.** +The editor becomes testable by users who are not repository +contributors; every other priority's value is invisible until this one +exists. + +### How this maps to arcs + +Candidate arc cuts, honoring one-feature-one-branch-one-PR and the +framing workflow (each needs its own scout + framing before any +implementation — this list is direction, not commitment): + +1. **Journey Stage 1** (P1): directory open + compile defaults + + LSP-failure surfacing + bindings + welcome buffer + the first + journey acceptance suite. Rides alongside the in-flight dired arc. +2. **Discovery surface** (P4): the describe/list/where-is command + family, M-x rich rows, help unification, help prefix. +3. **Transient keymap layer** (§6): the overlay scope + lifetime + handle + derived `dispatch_idle`, then migrate shadows one per PR. +4. **Extension ownership** (P3): `hook.remove`, owner-carrying + registrations, attribution-by-default. +5. **Worker identity** (§9): owner/purpose/parent on jobs and + processes, join the four planes, statusline activity indicator. +6. **Workspace entity** (P2): the object, then location values. +7. **Config provenance + adoption** (P6). +8. **Package lifecycle** (P7, after 4). +9. **Distribution** (P8, anytime). + +A standing process change accompanies all of them (§1.3): **every new +framing doc must state its coherence impact** — which journey steps it +touches, whether it adds an interaction island, whether its options +enter the config registry, whether its background work is attributed — +so the debt stops compounding silently. + +--- + +## 21. What Pmacs Should Borrow + +Proven adoption-cost reducers from successful modern editors, with +audited status: immediate usefulness (△ — editing yes, journey no); +strong defaults (✓ where they exist, §3); progressive disclosure (✗ +inverted, §4); searchable commands (△ names-only, §5); integrated +language tooling (✓ data layer / △ surface); project awareness (△ +conventions, §7); visible contextual actions (△ §15); coherent +task/terminal integration (✓ mechanics / ✗ visibility, §9); package +discoverability (✗, §13); configuration layering (△ foundation, §11); +remote development as core workflow (△ works, unmodeled, §8); smooth +distribution and updates (✗, §17); consistent interface primitives (△ +best trajectory, §14); explicit missing-tool guidance (✗ except +`--gpu`, §1.2). + +--- + +## 22. What Pmacs Should Preserve and Deepen + +Pmacs should not trade away the qualities that justify its existence — +and the audit confirms these are today's genuine strengths: live +programmability (redefine/unregister at runtime, per-package envs); +implementation inspectability (SourceLocation on every registration, +mandatory descriptions); replaceable interaction models (aspirational — +§6 is the gap); multiple genuine frontends and semantic rendering (✓, +§16 — the strongest concern); explicit parallel work with +cancellability and observability (mechanics ✓, product visibility ✗, +§9); remote daemon architecture (✓); user control over the editor as a +running system (✓). + +The goal is not to make pmacs less powerful so that it becomes +approachable. The goal is to make power **progressively available**. + +--- + +## 23. Product Thesis + +Emacs offers: *the editor is a programmable environment, and the user +may transform it completely.* VS Code offers: *the editor is already a +coherent development workstation, and extensions fill in the remaining +gaps.* Pmacs should offer: + +> **The editor is already an excellent workstation, and every part of +> that workstation remains inspectable, programmable, concurrent, and +> replaceable.** + +Its strongest distinctive proposition is not "Emacs in Rust" or "Emacs +with threads": + +> **Pmacs is a live-programmable editor in which computation, +> interfaces, ownership, and execution locations are explicit — allowing +> local, remote, interactive, and background work to coexist without +> freezing or becoming opaque.** + +The audit's one-line verdict on the thesis: **"without freezing" is +delivered; "without becoming opaque" is not yet true** — for the +failures a new user meets first (§1.2), for background work (§9), for +settings (§11), and for what a key will do while a modal surface is +active (§6). Product coherence is what will make the architecture +perceptible. Without it, pmacs risks becoming an impressive collection +of subsystems. With it, pmacs becomes a workstation whose complexity is +available without being imposed. + +--- + +## 24. Known documentation drift (as of 2026-07-25) + +Found during the audit; fix opportunistically, ideally before this +document is wired into CLAUDE.md/AGENTS.md as required reading: + +- `docs/keybindings.md` — every `src/editor.rs` line citation in §3 is + stale by ~250–1000 lines despite a "last verified @ `f8096ff` + (2026-07-20)" stamp; its shadow list also omits the terminal `C-c` + escape (reports 5 shadows, actual 6). +- `builtin/api/packages.lua` (EmmyLua annotations) — missing + `install_local`, `reload`, `load`, `describe`, `on_unload`; claims + `update` is unimplemented (it is implemented). +- `CHANGELOG.md` (~line 300) — claims a `describe-key` command for + self-introspection; no such command ever shipped (the Lua API + `pmacs.describe.key` exists; the interactive command does not). +- `docs/config-registry-framing.md` (~658) — claims `describe-setting` + renders through `src/help.rs`; it hand-builds its own text in + `builtin/commands/default.lua`. +- `src/workers_buffer.rs` module doc — says the completions ring caps + at 32; `COMPLETED_RING_CAP` is 64. +- `src/command.rs` doc comment on `predicate` — describes palette + gray-out behavior (T M2.7) that never shipped. + +--- + +## 25. Update protocol for this document + +- **When a PR changes any audited claim here, updating this file rides + that PR** — flip the grade, rewrite the fact, note the PR number. + Same discipline as `docs/agent-handoff.md`. +- Line numbers are hints; symbols are authoritative. When touching a + section anyway, re-verify its citations; do not let this document + accumulate the drift §24 catalogs in others. +- Grades change only with evidence (a landed PR, a re-audit), never + aspirationally. +- The **Ground truth** subsections are a snapshot dated 2026-07-25. If + a future comprehensive re-audit is performed, update the date in the + header and prune superseded facts rather than appending — this is a + briefing, not a log. +- Framing docs for coherence-affecting work should cite the section + they serve (e.g. "COHERENCE §6") and state their coherence impact per + §20's standing process change. From 7c01c9322650606adb6ef64e7b4d8da20d1b3bc2 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 25 Jul 2026 14:09:49 -0400 Subject: [PATCH 22/22] docs: dired arc framing (revision 5) + post-merge doc refresh Lands the approved dired framing on main as its own docs PR, and brings the two required docs current after find-file merged as #162. The framing was approved after two review rounds (seven findings, then six) and revised twice more since: revision 4 recorded what implementing Stage 0 falsified in the approved text, and revision 5 adds the coherence impact statement that #163 made mandatory for every framing. The coherence statement is new work, not a restatement. COHERENCE.md section 20 Priority 1 already names this arc -- a find-file surface and directory-argument handling -- so the framing now states which journey steps it touches (7, and partially 3), that it adds no interaction island because its keys are a mode-scoped keymap through the ordinary registry and wdired is a mode swap rather than a modal layer, that it adopts the config registry for dired.kill-when-opening, and that it inherits the worker-attribution gap for its read_dir jobs without worsening it. It also draws the boundary against the adjacent Journey Stage 1 arc: CLI directory handling belongs there, the two meet at resolve_target_buffer, and dired supplies the buffer a directory should resolve to rather than growing a second directory surface. One convergence worth recording: section 2 grades the golden journey broken at step 3 because pmacs on a directory exits 1, and the mechanism it cites -- File::open succeeding on a directory, then read_to_end returning EISDIR -- is the same one Stage 0 pinned in its accepting-a-directory test, where the pcall turns it into a status message instead. The handoff snapshot was stale through eight merges. It now anchors on main at 2af1ab3, records COHERENCE.md as required reading and a required framing input, and carries the two minibuffer facts find-file established: a custom completion source cannot descend directories, and a selected candidate shadows typed text -- both of which apply to M-x and switch-buffer, not just find-file. The ledger gains the dired lane with Stage 1's scope, the reason its one Rust change cannot be done in Lua, and the rebase note for the dired branch, whose framing commits become redundant when this lands. --- docs/active-work.md | 41 +- docs/agent-handoff.md | 59 +- docs/dired-framing.md | 1209 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 1304 insertions(+), 5 deletions(-) create mode 100644 docs/dired-framing.md diff --git a/docs/active-work.md b/docs/active-work.md index f55627e..6dc235f 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -1,6 +1,6 @@ # Active work — cross-machine resume ledger -**Snapshot: 2026-07-24.** This file records volatile work that has not +**Snapshot: 2026-07-25.** This file records volatile work that has not landed on `main`. Read it after `docs/agent-handoff.md`. Remove completed entries when their PR merges; do not let this become a second permanent backlog. @@ -124,6 +124,45 @@ If it does not, stop and repair the remote/fetch configuration. or markerless scratch files fragment into one server per directory for every language. +## Dired lane — framing APPROVED; Stage 0 MERGED, Stage 1 next + +- Approved framing: `docs/dired-framing.md` (revision 5), landing as its + own docs PR off `githubsucks/main` @ `2af1ab3`, branch + `githubsucks/dired-framing`, worktree `../pmacs-dired-framing`. The + repo's `-framing`-branch convention (`vterm-framing`, + `gpu-initial-target-framing`, `tab-width-parity-framing`). +- **Stage 0 (`C-x C-f` find-file) MERGED as #162** (`main` @ `2af1ab3`, + 2026-07-25, one review round, 12/12 CI green). Durable facts moved to + `docs/agent-handoff.md` §1 per rule 3 below. +- **Stage 1 (the dired view) is next and unstarted.** Branch `dired` + (worktree `../pmacs-dired-arc`) carries the framing commits only and is + based on the now-superseded `0827dd1`; **rebase it onto the `main` + resulting from the framing PR before implementing**, or cut a fresh + branch — its framing commits become redundant once the docs PR lands. +- Stage 1's scope, from the framing §10: `builtin/runtime/dired.lua`; the + `dired` major mode + mode keymap; buffer-per-directory with lexical + canonicalization and the ownership check; read-only intercept + + `set_round_trip_input`; visit routing through `window.display_file`; + parent/sort/revert/quit; `C-x d` (with the `display` opt) / `C-x C-j`; + cursor preservation by basename; the `dired.kill-when-opening` config + key; **and the tolerant `read_dir` opt** — the only Rust in the stage. +- The one Rust change is load-bearing and is why Stage 1 is not + pure-Lua: `read_dir_blocking` (`src/fs.rs:201`) fails the **entire + listing** on any of five per-entry conditions, and the tolerant wrapper + its own module doc delegates to package authors **cannot be written in + Lua** — the primitive returns one error and no partial vec. +- Coherence (framing §0.5, required since #163): serves `COHERENCE.md` + §20 Priority 1, which names this work explicitly; journey steps 7 and + (partially) 3; **adds no interaction island** — keys are a mode-scoped + keymap, and wdired is a mode swap; adopts `pmacs.config` for + `dired.kill-when-opening`; inherits §9's worker-attribution gap for its + `read_dir` jobs without worsening it. +- **Boundary with the Journey Stage 1 arc** (`COHERENCE.md` §20 arc-cut + 1): CLI directory-argument handling (`pmacs .` exits 1) belongs there, + not here. The two meet at `resolve_target_buffer`; dired supplies the + buffer a directory should resolve *to*, and `pmacs .` should route into + it rather than growing a second directory surface. + ## Bottom-panel lane (window placement + side windows) — Stage 1 IN REVIEW - Portable branch: `githubsucks/bottom-panel`, worktree diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index fbdf4df..a844230 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -1,7 +1,12 @@ # Agent handoff — cross-machine continuity -**Last updated: 2026-07-24, after GPU initial-target (#148, protocol v20) -landed, following folding Stage 2 (#149) and its landed-doc refresh (#150), +**Last updated: 2026-07-25, after find-file (#162) landed — the dired +arc's Stage 0 — following COHERENCE.md (#163), Lean 4 Stage 1 (#160), the +minimap blank-slab fix (#159), bottom-panel Stage 1 (#155), the +inline-math re-scout (#154), the vterm PTY-flake fix (#153), and the +GPU initial-target doc refresh (#152); and before that GPU +initial-target (#148, protocol v20), +following folding Stage 2 (#149) and its landed-doc refresh (#150), web grammars HTML+CSS (#146), the LaTeX Stage 1 / inline-math framing pair (#144/#145), folding Stage 1 (#142), one-command GPU invocation (#141), the documentation refresh (#140), Vterm Stage 3 (#135), tab-width rendering @@ -19,9 +24,55 @@ reads it the way you just did. For volatile branches, checkpoints, verification, and recovery commands, read `docs/active-work.md` immediately after this file. -## 1. Where the project stands (2026-07-24) +## 1. Where the project stands (2026-07-25) -- `main` @ `0dd16a5` (GPU initial-target #148 atop folding Stage 2 landed-doc +- `main` @ `2af1ab3` (find-file #162 atop COHERENCE.md #163, Lean 4 Stage 1 + #160, minimap blank-slab #159, bottom-panel Stage 1 #155, inline-math + re-scout #154, vterm PTY-flake #153, and doc refresh #152). Protocol + unchanged at **v20**. The bullets below describe the arcs in their own + terms; this line is the head-of-`main` anchor. +- **`COHERENCE.md` is now required reading and a required framing input + — #163.** It carries the product-coherence thesis, an audited + scorecard, per-concern gaps, and §20's priority order, and it is the + standard new work is evaluated against. Per `CLAUDE.md`, **every new + framing doc must state its coherence impact** — journey steps touched, + interaction islands added, config-registry adoption, background-work + attribution. Its §2 grades the golden journey **broken at step 3** + (`pmacs .` exits 1). +- **find-file LANDED — #162** (`docs/dired-framing.md` §10, Q#DR11; merge + `2af1ab3`; one review round). `C-x C-f` is the dired arc's **Stage 0**: + pmacs previously had no discoverable way to open a file by path — no + such command existed and `pmacs.buffer.find_or_open` had no interactive + caller. Pure Lua in `builtin/commands/default.lua`, one keymap line, an + 8-test dispatch-driven acceptance suite; no Rust, no protocol change. + Two substrate facts it documents, both worth knowing before touching + any minibuffer prompt: + - **Completion over files is flat and cannot be made hierarchical from + Lua.** A custom `source` function is called with **zero arguments** + (`minibuffer.rs:591`) and runs synchronously outside any coroutine, + where `Handle:await()` raises — so it can neither see the input to + re-root on nor list a directory. Only the Rust + `CompletionSource::Files { root }` can list, and it is + single-directory and 1024-capped. + - **A selected candidate SHADOWS typed text.** `recompute_candidates` + sets `selected = Some(0)` whenever the list is non-empty + (`minibuffer.rs:372-377`) and `resolve_accepted_value` returns the + candidate over the typed contents (`:564-574`). So free-text accept + fires only when the input filters every candidate away — for + basename candidates under a subsequence filter, when it contains a + `/`. This applies to `M-x` and `switch-buffer` too. Consequences are + pinned as decisions, including the hole where a new bare name that is + a subsequence of an existing entry opens the existing file, and the + empty-input case (`fuzzy_score` gives `Some(0)` for an empty needle + and ties break lexicographically, so dotfiles lead). + - Also: `get_or_load_buffer` computes a normalized path but **loads + from the raw one** (`editor_core.rs:842-856`), so a `~/…` path dedups + against an open buffer yet fails to load one that is not open — + find-file expands the tilde Lua-side. Loading through the normalized + path is a named deferral. +- **GPU initial target LANDED — #148** + (`docs/gpu-initial-target-framing.md` rev 3; merge `0dd16a5`; two review + rounds). `pmacs --gpu [--socket NAME|PATH] FILE` transports exact Unix path refresh #150, folding Stage 2 #149, ledger refresh #147, web grammars #146, LaTeX Stage 1 #144 / inline-math framing #145, and folding Stage 1 #142), protocol **v20** (`SUPPORTED=[6..=20]`; v16 = `ThemeFacts`, v17 = diff --git a/docs/dired-framing.md b/docs/dired-framing.md new file mode 100644 index 0000000..87b4573 --- /dev/null +++ b/docs/dired-framing.md @@ -0,0 +1,1209 @@ +# Dired — framing + +**Revision 5 — 2026-07-25. Status: APPROVED; Stage 0 MERGED as #162.** +Rev 1 passed a ground-truth review; rev 2 fixed round 1's seven findings; +rev 3 fixed round 2's six and was approved; rev 4 recorded what Stage 0's +implementation falsified in the approved text (§0); rev 5 adds the +**coherence impact** statement now required of every framing +(`CLAUDE.md`, `COHERENCE.md` §20) — see §0.5. Deliberately +unnumbered: the roadmap's Arc 8 is GPU +structural parity but `docs/lean4-mode-framing.md` also claims Arc 8, so +the arc space is already forked in uncommitted work. (Rev 2 also cited +`docs/dap-debugging-framing.md` as part of that fork — wrong: its Arc 7 +*matches* the roadmap's Arc 7 = Debugging. R2-5.) Numbering this one +would mint a third claim; it is ranked when the roadmap is next +reconciled. Not on `docs/roadmap-2026-07.md` and not in +`docs/side-quest-backlog.md` — this framing proposes the work as well as +its design. + +## 0. Revision history + +### Round 1 (rev 1 → rev 2) + +- **F1 (load-bearing).** §3's rationale for freezing the M8 fixture was + **false**. Rev 1 claimed the 47 tests pin the package system — + `install_local`, `on_unload` unregistration, `DuplicateName`, per-package + `require` scoping. Verified: `install_local` appears only in the shared + `editor_with_dired()` harness and in doc comments, never in an + assertion; `on_unload`, `DuplicateName`, and require-scoping are + asserted **nowhere** in either file; `dired_source_size_under_audit_ceiling` + is a `lines < 1500` lint. Exactly **one** of 47 + (`dired_package_reload_is_safe_after_init_complete`, + `m8_2_acceptance.rs:1190`) exercises package mechanics; the other 45 + assert dired/wdired behavior. §3 rewritten on honest grounds; the + "shrink the fixture" follow-up repositioned from *if drift appears* to + **scheduled after Stage 3**, because it is now known to be cheap. +- **F2 (load-bearing).** `RET` must not use bare `find_or_open`. + `window_panel.rs:373-376` documents why: `find_or_open` "switches the + ACTIVE window in both branches before firing hooks, so a visit to a + previously unopened file would replace a focused panel." A `RET` in a + panel-displayed dired would swallow the panel. Visits now route through + `pmacs.window.display_file` (new Q#DR10), which also dedups by + *normalized* path. §2 gains the window-primitive ground truth it was + missing entirely, and the `dired` command gains the `display` opt that + acceptance 11 was already testing without. +- **F3 (load-bearing).** Stage 0's completion mechanism does not work as + described. A function source is re-called per keystroke but invoked as + `f.call(())` — **zero arguments** (`src/minibuffer.rs:591`) — and the + callback runs synchronously from Rust dispatch, outside any + `pmacs.async` coroutine, where `Handle:await()` raises + (`async.lua:76-79`). There is no synchronous directory listing in Lua, + so a function source **cannot descend into directories**. Stage 0 + rewritten around the existing Rust `CompletionSource::Files` + (`minibuffer.rs:589`) plus free-text accept; hierarchical completion is + a named deferral (new Q#DR11). Rev 1's "a file you have never opened is + unreachable" is corrected to *undiscoverable and uncompleted* — recentf's + prompt already passes free text to `find_or_open`. +- **F4 (design gap).** Q#DR5(a) as specced did not cover dired's own hard + case. `apply_resource_op`'s rebind uses `find_by_path` + (`buffer_registry.rs:168-174`) — **exact `Path` equality, first match + only** — so a *directory* rename strands every buffer beneath it, and + `R` on a directory line is an ordinary dired operation. Worse, that arm + looks up with the **raw** path (`mod.rs:3248`) while stored paths are + normalized on write (`EditorCore::set_buffer_path`, `editor_core.rs:819`) + and the normalizing wrapper `find_buffer_for_path` (`:864-867`) exists and + is bypassed — so a non-normalized old path silently fails to match today. + Q#DR5 widened to include rebind *semantics*, with new evidence: + **`pmacs.fs.rename` has zero production callers**, so changing the + primitive's contract breaks nobody. +- **F5 (spec fix).** Non-UTF-8 **symlink targets** are fatal too + (`fs.rs:227`), and differ in kind from names: the entry's own name is + fine and nothing needs to pass a target back through `rename`. Tolerant + mode now carries `readlink` failures and target-encoding failures in the + per-entry channel; only **names** stay fatal. +- **F6 / F7 (Q#DR2 gaps).** Name-keyed dedup needs a canonical path form + (`/tmp`, `/tmp/`, `/tmp/../tmp` would mint three buffers), and + found-by-name must verify **dired ownership** before painting into a + buffer through `bypass_intercept`. Both folded into Q#DR2. +- **Minors.** Arc number dropped; `C-x C-r` attributed to `recentf.lua:85` + rather than the default keymap; the tolerant opt must **validate** + unknown keys (`supersede_key`, `fs.lua:73-83`, silently ignores them, so + a typo'd `tolerant` would degrade to fatal mode unnoticed); acceptance 13 + carries the fixture's macOS ignore gate (`m8_2_acceptance.rs:211-213`); + and §8 footnotes one per-entry failure that is *already* tolerated — + a failing `metadata.modified()` yields mtime 0 rather than an error + (`fs.rs:463-476`). + +### Round 2 (rev 2 → rev 3) + +- **R2-1.** Q#DR5's "at reply-settle time" named no seam, and the obvious + one is wrong. The fs ops are fire-and-forget-capable — nothing obliges a + caller to `await` or attach `on_complete` — so a rebind implemented + where results are *consumed* (`_take_result`, `mod.rs:6758`) misses any + rename whose handle is never taken: the rename lands on disk and the + buffer is never rebound. The trap survives one layer down, and an + acceptance that awaits would pass while the fire-and-forget path stayed + broken (the pin-through-the-real-path class again). §7 now names the + **main-thread completion drain** `AsyncRuntime::tick` + (`async_runtime.rs:991`) as the seam, unconditional on success — plus a + fact that makes the implementation non-obvious: **rename settles as an + undifferentiated `ReplyKind::FsUnit`**, the same reply chmod and remove + produce (`:1022-1025` maps `Sleep | FsUnit` alike to `JobResult::Unit`; + there is no `Rename` variant). The drain therefore cannot key on the + reply — it must key on the pending job's own `JobKind::FsRename`, and + the job must **retain from/to** so the paths exist at settle. Stage 2's + acceptance includes a **no-await** rename. +- **R2-2.** The `errors` row shape cannot always carry a name. A per-entry + `readdir` iterator error (`fs.rs:215-218`) has no filename — the entry + never materialized, and the error is wrapped with the *parent* path. §8 + makes `name` optional for that arm; the footer counts it without naming + it. +- **R2-3.** §2's `pmacs.window` inventory listed five exports; there are + **eight** — also `display_target` (`:425`), `panel` (`:440`), and + `set_params` (`:544`). The omission was the relevant one: + `display_target` is "the non-side window a visit from a panel should + address", i.e. the mechanism behind `display_file`'s panel-safety. §9's + loosest sentence — directory descent in a panel-displayed dired, left as + "(or `display`, when dired was itself panel-displayed)" — is now + specified, with the dedication question answered. +- **R2-4.** The Lua mirror of `normalize_buffer_path` is a second + implementation of a canonical form — the tab-width-constants class in + miniature. If the mirror and the Rust normalizer disagree on an edge + (`//tmp`, `~` with `HOME` unset, root's trailing slash), dired's + name-dedup and `display_file`'s `find_buffer_for_path` dedup diverge + **silently**: two buffers, no error. §4 now carries a parity obligation, + and records that the "binding whose only caller is dired" argument + undercounts — Q#DR5's fix touches the same normalizer, so a Lua-exposed + canonicalize has at least two consumers by Stage 2. +- **R2-5.** Header nit: only Lean 4 forks the arc numbering; DAP's Arc 7 + matches the roadmap. Conclusion unchanged, evidence corrected. +- **R2-6.** Stage 0 was "recommended first" with no acceptance and no + ruling on nonexistent paths. `display_file` routes through + `resolve_target_buffer` (`editor_core.rs:885-898`), which on + `ErrorKind::NotFound` **creates** the buffer, binds the path, and sets + status `"[new file]"` — Emacs parity, now stated rather than inherited. + §14 gains four Stage 0 acceptance items. + +### Stage 0 implementation notes (rev 3 → rev 4) + +Implementing Stage 0 falsified one thing the approved text asserted, and +the correction belongs here rather than only in the code. + +- **S0-1. Flat completion and free-text accept do not compose the way + Q#DR11 described.** Rev 3 said Stage 0 is "the flat Rust `Files` source + rooted at the current buffer's directory, plus free-text accept", as + though the two were independent and always both available. They are + not: `recompute_candidates` sets `selected = Some(0)` **whenever the + candidate list is non-empty** (`minibuffer.rs:372-377`), and + `resolve_accepted_value` (`:564-574`) returns the **selected candidate** + in preference to the typed contents. So typed text reaches `on_accept` + **only when the input filters every candidate away** — which, since + candidates are bare basenames and the filter is a case-insensitive + subsequence match, means *when the input contains a `/`*. + Consequences, all now pinned by `tests/find_file_acceptance.rs`: + - the deeper-path case works (`sub/inner.txt` matches no basename, so + it arrives verbatim) — which is what acceptance 0b actually tests; + - the new-file case works **only for names containing a separator**, so + acceptance 0c uses one; + - and there is a genuine hole: typing a **new bare name that is a + subsequence of an existing entry** opens the existing file instead of + creating the new one. `find_file_selected_candidate_shadows_typed_text` + pins that as a decision rather than an accident. + + Closing the hole needs a Rust change to accept semantics — prefer typed + text over the selection when the two differ and the user has not + explicitly moved the selection — which would change `M-x` and + `switch-buffer` too, and so is deliberately **not** made in Stage 0. It + joins the hierarchical-completion deferral in §13. +- **S0-4. Accepting on empty input opens the first-sorted candidate.** + A consequence of S0-1 with an empty needle: `fuzzy_score` returns + `Some(0)` for every entry (`minibuffer.rs:637-640`) and + `filter_and_sort` breaks the resulting tie lexicographically (`:678`), + so an immediate RET opens whatever sorts first — dotfiles lead, and a + directory can lead, in which case the open fails and reports (S0-6). + `M-x` and `switch-buffer` share the mechanism, so this is inherited + rather than introduced; it is documented at the command and listed in + §13 beside the accept-semantics fix that would close it. +- **S0-5. Minibuffer history stores the pre-join value.** `accept` + pushes the resolved value into the history bucket **before** `on_accept` + joins it onto the root, so a `C-p` recall of a root-relative entry + under a different root resolves somewhere else. Rust-side, so Stage 0 + cannot fix it; §13. +- **S0-6. The failure arm is a real path and is pinned.** Accepting a + *directory* candidate reaches `display_file`, whose load fails + (`File::open` on a directory succeeds; the read returns EISDIR), so the + command's `pcall` turns it into a status message instead of letting the + error escape mid-dispatch. + `find_file_accepting_a_directory_reports_instead_of_raising` pins it + through the real accept path and fails when the `pcall` is removed. +- **S0-2. The prompt field must start empty.** Emacs prefills find-file's + field with the directory. Here any prefill contains a `/`, which by + S0-1 filters every candidate away and silently disables completion — + so the root is named in the *prompt string* instead, and the empty + field is pinned by acceptance 0d. +- **S0-3. A leading `~` must be expanded before the path reaches the + core.** `get_or_load_buffer` (`editor_core.rs:842-856`) computes a + normalized path but calls `load_file` with the **raw** one (`:847`), so + a `~/…` path deduplicates against an already-open buffer (dedup goes + through the normalizing `find_buffer_for_path`) yet fails to load a + file that is not open yet. Stage 0 expands the tilde in Lua, which + makes both halves agree without changing core load semantics for the + CLI, LSP, and bootstrap callers. **Using the normalized path for the + load is the better fix and is now a named deferral** (§13) — it is the + same normalize-before-lookup family as Q#DR5's `apply_resource_op` + correction. + +## 0.5. Coherence impact (`COHERENCE.md` §20) + +Required of every framing since #163. This arc was scouted and approved +before that rule existed; the statement is added here rather than +backfilled silently. + +**Section served: §20 Priority 1 — protect the golden product journey**, +which already names this work: *"a find-file surface (in flight, PR +#162)"* and *"directory-argument handling"*. Secondary: §5 (unify +discoverability) and §14 (coherent workbench primitives). + +**Journey steps touched (§2).** + +- **Step 7, "find a symbol or file"** — the file half, which had no + surface at all. Stage 0 (`C-x C-f`, merged as #162) covers opening a + known path; Stages 1–3 cover browsing, which is the half a user + reaches for when they do *not* already know the path. +- **Step 3, "open a real project"** — partially, and the boundary + matters. §2's ground truth grades the journey *broken at step 3* + because `pmacs .` exits 1: `load_file` (`src/file_io.rs:81-87`) does + `File::open` (which succeeds on a directory) then `read_to_end` → + EISDIR, which is not `NotFound`, so `resolve_target_buffer`'s + create-a-`[new file]` arm never fires and the error escapes. + **That is the same mechanism Stage 0 pinned** in + `find_file_accepting_a_directory_reports_instead_of_raising` — where + the `pcall` turns it into a status message instead. Dired Stage 1 is + what makes a directory *open into something* rather than merely fail + politely. + **Boundary with the adjacent arc:** §20's arc-cut list puts CLI + directory-argument handling in "Journey Stage 1", noted as riding + alongside this arc. The two meet at `resolve_target_buffer`. This + framing does **not** claim the CLI path; it supplies the buffer a + directory should resolve *to*, and Journey Stage 1 should route + `pmacs .` into it rather than inventing a second directory surface. +- **Step 4, "understand the visible interface"** — marginally, via the + `dired` major mode showing in the statusline (Q#DR8). +- Steps 1–2, 5–6, 8–12: untouched. + +**Interaction islands added (§6): none — deliberately.** §6 grades this +area "weak, and growing by one island per modal feature", with every +modal surface funnelling through `EditorInstance::dispatch_key`'s +precedence machine. Dired adds no Rust-level interception: its keys are +an ordinary **mode-scoped keymap** through the existing `pmacs.keymap` +registry (Q#DR8), so they are introspectable by `describe.key` and +rebindable like any other binding. Stage 3's wdired is a **major-mode +swap**, not a modal layer — which is the reason Q#DR3 chose a mode swap +over an edit-mode flag. Two existing islands are *consumed* (the +minibuffer prompt for `C-x d`, and Stage 0's), neither added by this +arc. This arc therefore moves §6's count sideways, not up. + +**Config registry adoption (§11): yes.** `dired.kill-when-opening` is +defined through `pmacs.config` with a type, default, and +`mutability = "live"` (Q#DR2), not a bare Lua global — matching the +#127 adopters. Sort mode is deliberately *not* a setting in Stage 1: it +is per-buffer session state, and promoting it would need the +buffer-local scope plus a persistence story the registry does not have +yet (its own named deferral). + +**Background-work attribution (§9): inherited debt, not fixed here.** +Every listing runs as a `pmacs.fs.read_dir` worker job, and those jobs +carry no owner or purpose — §9's gap. A dired refresh will therefore +show up in the activity planes exactly as anonymously as every other fs +job does today. Stage 1 does not fix that and does not make it worse; +when §20's "worker identity" arc lands, dired's jobs are ordinary +consumers of it. Naming it here so the debt is visible rather than +silently compounded (§1.3). + +**Net.** One journey step goes from *no surface* to *a surface*; one +more moves from *fails* toward *resolves*; no island added; one setting +enters the registry; one attribution gap inherited and named. + +## 1. Problem and what ships + +Two separate facts collide here, and the second is why this is worth more +than "a file browser would be nice". + +**Fact one: a complete dired already exists, and ships to nobody.** +`tests/fixtures/pmacs-dired/init.lua` is 1,384 lines of Lua implementing +the read-only directory view (T M8.2) and the wdired editable +rename/chmod layer (T M8.3), pinned by **47 acceptance tests** (15 in +`tests/m8_2_acceptance.rs`, 32 in `tests/m8_3_acceptance.rs`). It was +built as one of M8's three "universality proof" packages — the evidence +that a buffer can be a projection of external state. It lives under +`tests/fixtures/`, and `rg pmacs-dired` outside `tests/` returns zero +hits. Nothing installs it; no user can reach it. + +**Fact two: pmacs has no discoverable way to open a file by path.** There +is no `find-file` command and no `C-x C-f` binding. The complete list of +builtin command names contains nothing matching `file` or `open`; +`pmacs.buffer.find_or_open` (`src/lua_bindings/mod.rs:3104`) is a Lua API +with no interactive caller of its own. A file enters a session via the +CLI (`pmacs FILE`, `pmacs --gpu FILE`), an LSP jump, a project-search +visit, or `C-x C-r` recent-files — whose prompt *does* pass free text +through to `find_or_open` (`recentf.lua:74-80`), so an arbitrary path is +technically reachable, but only by typing it blind into a prompt labelled +"Recent file:" with no completion and no discoverability. +`editor.switch-buffer` (`builtin/commands/default.lua:594`) completes over +*already-open buffers* and reports `no buffer: ` for anything else. + +So dired is not a convenience rider on an existing file surface. **Dired +is the file surface.** That reframes both its value and its risk: it is +the first thing a new user needs, and the last place we can afford a +listing that refuses to render. + +**What ships**, staged (§10): + +- **Stage 1 — the dired view.** A builtin `builtin/runtime/dired.lua`: + read-only listing, navigation, `RET` to visit (files open through + `display_file`, directories descend), sort modes, revert, quit, + `C-x d` / `C-x C-j`, a `dired` major mode with mode-scoped keys, cursor + preservation across refresh — plus the one Rust change Stage 1 needs, a + **per-entry-tolerant `read_dir`** (Q#DR6). +- **Stage 2 — marks and operations.** `m`/`u`/`U`/`t`, deletion flags + `d`/`x`, immediate `D`, `R` rename, `C` copy, `+` mkdir — and the three + filesystem primitives that do not exist yet, plus the rename/rebind fix. +- **Stage 3 — wdired.** The editable layer, carrying over the fixture's + hard-won commit logic. + +`find-file` itself (`C-x C-f`) is separable and is Stage 0 (§10). + +## 2. Ground truth (scouted 2026-07-25, `main` @ `e745068`; verified across review rounds 1 and 2; re-verified against `main` @ `0827dd1`) + +**Base note.** `main` moved from `e745068` to `0827dd1` (Lean 4 Stage 1, +#160) between the scout and approval. The diff touches exactly one file +this framing cites — `builtin/runtime/syntax.lua`, which gained a +`lean = "lean4"` modeline alias — and nothing else in the ground truth +below. The only consequence is a line drift: `set_major_mode` is now +`syntax.lua:497`, not `:492`. Every other citation is unchanged. + +### The existing fixture + +- `tests/fixtures/pmacs-dired/init.lua` (1,384 lines) defines eight + commands: `open-line`, `parent`, `sort-name`, `sort-mtime`, + `sort-size`, `wdired-edit`, `wdired-abandon`, `wdired-commit`. It binds + `RET` and `Backspace` **buffer-locally** at open (`:381-388`), paints by + wholesale `buf:replace` behind a `painting` passthrough flag + (`:294-302`), and keys per-buffer handles by linear scan over + `BufferIdLua.__eq` with a liveness compaction (`:53-67`). +- Its wdired layer is the valuable part and is not naive: a + column-classifying `intercept_edit` (`:640-712`), fixed-width perms with + positional validation (`:520-542`), `\\`/`\n`/`\r`/`\t`/`\xNN` filename + escaping with an **exact inverse** so a no-op commit cannot fire a + spurious rename (`:140-211`), field-by-field external-change detection + including `mtime_nsec` (`:854-902`), duplicate-final-name rejection + before any syscall, and a **two-phase rename through unique temp names** + so swaps and chains commit safely (`:1176-1216`). +- **What the 47 tests actually assert (F1).** 45 assert dired/wdired + *behavior*: rendered listing shape, sort order, escaping round-trips, + intercept column rejection, on-disk chmod/rename effects, the two-phase + swap, external-change detection, partial-application reporting. One + (`m8_2:1190`) exercises package mechanics — reload safety after + `set_init_complete`. One (`m8_2:1245`) is a source-line-count lint. + `install_local` appears only in the shared `editor_with_dired()` harness + as a setup precondition and in doc comments; `on_unload`, + `DuplicateName`, and per-package `require` scoping are asserted nowhere. +- **Two of its own stated limitations are now false.** + - `open-line` on a non-directory errors with "requires the + buffer-from-file API (not yet exposed)" (`:948-961`). + `pmacs.buffer.from_file` (`mod.rs:3054`) and `find_or_open` (`:3104`) + both exist and ship. + - The test seam claims "the v0.1 buffer surface doesn't expose + move_to_byte yet, so tests can't reliably position the cursor" + (`:1355`). `pmacs.editor.goto_byte` (`mod.rs:12765`) and + `move_to_line` (`:12526`) landed with editops (#111). +- **A real defect in its model:** navigation mutates `handle.path` and + repaints, but the buffer was named `*dired:*` at creation and + **there is no `pmacs.buffer.set_name`** — the `pmacs.buffer` table + exports exactly `create`, `from_bytes`, `from_file`, `find_or_open`, + `list`, `kill`, `remove`, `on_removed`, `major_mode`, `set_major_mode`, + `set_round_trip_input`, `mark_create`, `add_intercept`, + `remove_intercept`, `apply_resource_op`, and the style-overlay family. + (`Buffer::set_name` exists Rust-side, unexposed.) So after one `RET` the + buffer name names a directory it is no longer showing. Q#DR2 answers this. + +### The filesystem surface + +- `pmacs.fs` is exactly five worker-dispatched ops — `read_dir`, `stat`, + `rename`, `chmod`, `remove` (the complete `_dispatch_fs_*` set) — plus a + Lua-side polling `fs.watch` (`builtin/runtime/fs.lua:226`). **No + `mkdir`, no `copy`, no symlink-create, no recursive remove.** +- **`read_dir` is all-or-nothing, and this is the load-bearing gap.** + `read_dir_blocking` (`src/fs.rs:201`) returns + `Result, FsError>`. **Five** per-entry conditions fail the + **entire listing**: a per-entry `readdir` error, a failed + `symlink_metadata`, a failed `read_link` (`:228-234`), a non-UTF-8 + symlink target (`:227`), and a non-UTF-8 name (`:238`). The module doc + acknowledges the shape and says "dired-class will likely want a + per-entry-tolerant wrapper but that's the package's job, not the + primitive's" (`:196-200`) — **that wrapper cannot be written in Lua.** + The primitive hands Lua one structured error and no partial vec; there is + nothing to be tolerant *with*. Three concrete failure modes, all + ordinary: + 1. a directory readable but not searchable (`r` without `x`) — `readdir` + succeeds, every child `lstat` fails; + 2. a file unlinked between `readdir` and `lstat` — ENOENT, i.e. a plain + refresh of a busy directory (`/tmp`, a build tree) can just fail; + 3. any single non-UTF-8 filename or symlink target in the directory. + One per-entry failure is *already* tolerated: a failing + `metadata.modified()` yields mtime 0 rather than an error (`:463-476`). +- `read_dir` already takes `(path, opts)` and the opts parser + (`supersede_key`, `fs.lua:73-83`) reads only `opts.supersede` and + **silently ignores unknown keys** — signature-natural for Q#DR6's opt, + but a typo'd `tolerant` would degrade to fatal mode unnoticed. +- `chmod` **follows symlinks** (`src/fs.rs:370`; `fs.lua:104`) while + `read_dir`/`stat` use `lstat`. The fixture rejects symlink perms edits + at intercept time for exactly this reason (`init.lua:629-638`) — that + decision carries over unchanged. +- **`pmacs.fs.rename` has zero production callers** — only its own + definition (`fs.lua:126`), `m8_1`/`m8_3` acceptance, and the fixture. +- `pmacs.fs.rename` does **not** rebind an open buffer's path. + `pmacs.buffer.apply_resource_op` (`mod.rs:3206`) — the LSP + workspace-edit applier — does, but by **exact first match**: its + `"rename"` arm calls `reg.borrow().find_by_path(&from)` (`:3248`), which + is exact `Path` equality over insertion order + (`buffer_registry.rs:168-174`). It also uses the **raw** path while + stored paths are normalized on write + (`EditorCore::set_buffer_path`, `editor_core.rs:819`) and the normalizing + lookup `find_buffer_for_path` (`:864-867`) exists and is bypassed. So the + model rev 1 proposed copying is itself subtly wrong, and has no prefix + rebind anywhere. `apply_resource_op` is also **synchronous and blocking + on the main thread**, unlike every `pmacs.fs` op. Q#DR5. + +### Windows, panels, and how anything gets displayed + +- `pmacs.window` exports **eight** functions: `display` + (`window_panel.rs:356`), `display_file` (`:379`), `display_target` + (`:425`), `panel` (`:440`), `quit` (`:453`), `params` (`:499`), + `set_params` (`:544`), and `resize` (`:591`) — alongside the pre-arc + `switch_buffer`. **`display_target` is "the non-side window a visit from + a panel should address"**, i.e. the mechanism that makes `display_file` + panel-safe; it is what Q#DR10 rests on, and it is the reason a file + visit and a directory descent take different routes (§9). +- **`find_or_open` is panel-hostile, by documented design.** + `window_panel.rs:373-376`: `find_or_open` "switches the ACTIVE window in + both branches before firing hooks, so a visit to a previously unopened + file would replace a focused panel before any display policy could + help." `display_file` is the Q#BP11b answer — a side-effect-free dedup + via the **normalizing** `find_buffer_for_path` *before* any I/O, then + destination resolution before the read, so a dedicated origin cannot + force load-before-failure. LSP visits and compile already route through + it. +- `pmacs.listview` (`builtin/runtime/listview.lua`) implements the + disciplines a read-only panel needs: a read-only `add_intercept` + (`:101-104`), `set_round_trip_input` (`:106`), a buffer-local keymap, a + line→item map, `q`-restores-previous, and the `display = "current" | + "panel"` opt-in (`:132-142`). **But** its keymap is a fixed set — + `RET`/`SPC`/`n`/`p`/`g`/`q` (`:76-88`) — with no extension point, and + its read-only intercept is installed once at panel creation and **never + removed** (`:101`), which wdired requires. Three panels already depend + on this module (references, outline, project-search). +- `set_round_trip_input` (`mod.rs:3085`) is what makes single-key bindings + work on a semantic/GPU frontend: while a marked buffer is active + `dispatch_idle` reports false, so optimistic-apply stays off and `d` + reaches the binding instead of landing as a CRDT insert. The same gate + (`dispatch_idle_for`, `editor.rs:818`) *also* disables optimistic apply + for any focused side window (`!window.is_side()`, Q#BP14a), so a + panel-displayed dired is covered twice. +- **There is no real `read_only` buffer flag** — a standing backlog item + (`docs/side-quest-backlog.md`, cross-cutting substrate). The intercept + idiom is what every generated buffer uses today. + +### Minibuffer completion + +- A `source` **function** is re-called on every keystroke + (`recompute_candidates`, `minibuffer.rs:361`) but invoked as + `f.call(())` — **zero arguments** (`:591`). `pmacs.minibuffer.contents()` + exists, but the callback runs synchronously from Rust dispatch, outside + any `pmacs.async` coroutine, and `Handle:await()` explicitly raises + there (`async.lua:76-79`). There is **no synchronous directory listing in + Lua**, so a function source cannot descend into directories. +- `CompletionSource::Files { root }` exists Rust-side + (`minibuffer.rs:589` → `list_directory(root)`), reached from Lua as + `source = "files"` with `source_root` (`mod.rs:13286`). It is flat, + single-directory, capped at 1024 candidates, and **currently used by + nothing outside a unit test**. +- Free text accepts: `resolve_accepted_value` (`minibuffer.rs:564`) + returns the raw typed string when no candidate is selected. + +### Buffers, modes, keys + +- Mode-scoped keymaps exist since #129: `pmacs.keymap.bind { scope = + "mode", mode = "", … }` (`mod.rs:13432`), resolving buffer-local → + mode → global. `syntax.lua:497` is the **only** `set_major_mode` caller + in `builtin/`, firing on `buffer.after-load`; a `buffer.create`d dired + buffer has no path and fires no `after-load`, so nothing contends. +- `find_or_open` on a directory reaches `file_io::load_file` → EISDIR. + Dired must dispatch on `entry.kind` itself. +- **Keybinding space.** `C-x d`, `C-x C-j`, `C-x C-f`, and `C-x C-q` are + unbound **repo-wide**. `C-x C-r` is bound to recent-files in + `recentf.lua:85` (not the default keymap). `C-c ` is fully taken + by LSP (`lsp.lua:2248-2256`); `C-c @` is the folding prefix + (`fold.lua:48-52`); `C-c C-k` is bound **buffer-locally** by compile + (`compile.lua:231`) and async, not globally. + +## 3. Where dired lives (Q#DR1) + +**A builtin runtime module, `builtin/runtime/dired.lua`, written fresh. +The M8 fixture stays exactly where it is, frozen.** + +Builtin rather than a package, because a surface that is the primary way +to open a file cannot be gated on the user installing something, and +because builtin modules get the load-order and config-registry guarantees +a package does not (the `pair.lua`-before-`lsp.lua` precedent). + +**Why the fixture is not promoted.** Rev 1 argued the 47 tests pin the +package system and would be voided by re-pointing. That was false (F1) — +45 of them assert dired behavior, and behavior transfers. The honest +argument is narrower and rests on three things: + +1. **The harness route is itself the proof.** 46 of 47 tests reach dired + through `install_local` + `require`, and that *routing* — a third-party + package, in its own environment, driving buffers, intercepts, marks, + commands, and keymaps — is the M8 universality claim. A builtin loaded + by the runtime demonstrates nothing about packages. Re-pointing the + suites keeps every behavioral assertion and silently deletes the claim + they were written to support. +2. **The builtin diverges structurally**, so the tests cannot transfer + verbatim anyway: the mark column (Q#DR4) shifts every column offset the + wdired tests hardcode through `_test.NAME_START`; mode-scoped keys + (Q#DR8) replace the buffer-local `RET`/`Backspace` binds + `dired_ret_and_backspace_keybindings_navigate` drives; + buffer-per-directory (Q#DR2) replaces the in-place repaint that + `dired_parent_command_navigates_up_one_level` asserts; and the whole + `M._test` seam is package-shaped. +3. **The behavior gets re-pinned regardless**, by the builtin's own + acceptance (§14), which is where those 45 assertions are owed a home. + +The cost is real: roughly 900 lines of rendering, escaping, and commit +logic will exist in two places. The mitigation is that the fixture is +**frozen** — a proof artifact, not a maintained feature, already fully +pinned. + +**Named follow-up, scheduled rather than conditional (F1 corollary).** +Because the fixture's package-system value concentrates in exactly one +test plus the harness routing, shrinking it to the minimum payload that +still proves universality is cheap — far cheaper than rev 1 implied. It +is scheduled **after Stage 3**, when the builtin owns every behavior the +45 tests currently cover, not left to "if drift shows up". + +The fixture remains a *reference* for the parts that were hard — +escaping with an exact inverse, two-phase rename, external-change +detection, the symlink rules — and those carry over as decided design, +not as re-litigated questions. + +## 4. Buffer model and navigation (Q#DR2) + +**One buffer per directory, found-or-created by canonical name; +navigation opens the target's buffer rather than mutating the current +one.** + +This is Emacs's actual behavior (`dired-find-file` on a directory yields a +dired buffer for that directory), and it answers the fixture's stale +buffer-name defect without adding a `pmacs.buffer.set_name` binding: +nothing is ever renamed, because a buffer's name always describes the +directory it was created for. It also makes `C-x d` on an +already-visited directory and `C-x C-j` dedup for free. + +**Canonicalization (F6).** `/tmp`, `/tmp/`, and `/tmp/../tmp` are all +absolute and would otherwise mint three buffers. The rule is **lexical +normalization before naming and before lookup**: expand a leading `~`, +absolutize, collapse `.` and `..`, strip a trailing slash except at root. +Symlinks are **deliberately not resolved** — Emacs parity, and resolving +them would make `..` from a symlinked directory jump somewhere the user +did not navigate from. The core already has exactly this shape in +`normalize_buffer_path` (`editor_core.rs:4790`); it is not Lua-exposed, +so Stage 1 either mirrors it in Lua or exposes it. + +**A mirror is a second implementation of a canonical form, and needs a +parity pin (R2-4).** This is the tab-width-constants class in miniature: +if the Lua mirror and the Rust normalizer disagree on an edge — `//tmp` +(POSIX gives a leading double slash implementation-defined meaning), `~` +with `HOME` unset, root's trailing slash, a `..` that would escape root — +then dired's name-dedup and `display_file`'s `find_buffer_for_path` dedup +**diverge silently**: two buffers for one directory, no error anywhere. +So whichever route Stage 1 takes, it carries a **parity acceptance** that +drives both implementations over one shared edge-case list and asserts +identical output (acceptance 3b). + +Rev 2's "avoids a binding whose only caller is dired" also **undercounts**: +Q#DR5's rename fix touches the same normalizer, so a Lua-exposed +`pmacs.path.canonicalize` (or equivalent) has at least two consumers by +Stage 2. Exposing it and deleting the mirror is therefore the better +end state; Stage 1 may still mirror if exposure turns out to drag in +`EditorCore` borrow plumbing it does not otherwise need, but the parity +acceptance is required either way, and the mirror is then a **named +Stage 2 removal**, not a permanent duplicate. + +**Ownership check (F7).** `pmacs.buffer.create` takes any caller-chosen +name, so a foreign buffer named `*dired:/tmp*` would be "found" by name +and then painted into through `bypass_intercept`. Found-by-name must +confirm the buffer is dired-owned — present in dired's handle table, or +`pmacs.buffer.major_mode(buf) == "dired"` — and otherwise create a fresh +buffer under a disambiguated name rather than clobbering it. + +Buffer name: `*dired:*`. + +The cost is buffer accumulation when walking a deep tree. Emacs users +live with this; Emacs 28 added an opt-out, and we mirror it as a config +key rather than a hardcoded policy: + +- `dired.kill-when-opening` (boolean, default `false`, live) — when true, + descending or ascending kills the dired buffer being left. + +## 5. Read-only discipline and the wdired seam (Q#DR3) + +The dired buffer is read-only by the listview idiom — an `add_intercept` +that rejects every non-bypass edit, plus `set_round_trip_input(buf, +true)` so a GPU session's optimistic-apply cannot swallow single-key +bindings — and dired's own paints use `bypass_intercept = true`. + +**Dired owns its buffer directly; it is not built on `pmacs.listview`.** +Listview would have to grow a keymap extension point and a removable +read-only mode, and three shipped panels depend on it. Bending a module +into a shape its existing callers do not need is exactly the change class +that took CI red in #155: scoping `pmacs.window.buffer()`'s no-arg arm +"for consistency" made a total function partial and silently dropped +edits from six unpcall'd runtime callers. Dired reuses listview's +*disciplines*, not its code. Factoring the shared disciplines into a +common helper is a named follow-up, to be done once dired's real shape is +known rather than predicted. + +**Wdired (Stage 3) is a mode swap, not a flag.** `C-x C-q` removes the +read-only intercept, installs the column-classifying one, and calls +`set_major_mode(buf, "wdired")`; commit and abandon restore both. Because +keys are mode-scoped (Q#DR8), the entire keymap changes with the mode — +`m` means "mark" in dired and means "type an m" in wdired, with no +per-key bookkeeping. + +**Wdired refuses to open on a partially-listed directory.** If the +listing carried any per-entry error (Q#DR6), rename and chmod are refused +with that reason: a rename batch is planned against a snapshot, and you +cannot safely plan against a directory you could not fully see. + +## 6. Marks (Q#DR4) + +Emacs's mark column is column 0, so every other column shifts right by +two (`" "` or `"* "`). The builtin computes its offsets from the +constants rather than inheriting the fixture's `PERMS_START = 1` / +`NAME_START = 39`. + +**Marks are keyed by basename, never by line index.** A sort, a revert, +or an external change reorders lines; a line-indexed mark set would +silently retarget onto a different file — the same class of defect as +keying kill-ring state by index rather than by stable id (the Arc 2 +substrate rule). `*buffer-list*` keys its deletion marks by buffer id +(`builtin/commands/default.lua:507`) for the same reason. + +Two mark characters, following Emacs: `*` (general mark, consumed by +operations) and `D` (deletion flag, consumed by `x`). A basename that +disappears between marking and executing is dropped from the batch and +reported, not silently skipped. + +## 7. Operations and the missing primitives (Q#DR5) + +Stage 2's operations need three filesystem primitives that do not exist, +and one correctness fix. + +**New `pmacs.fs` ops** (worker-dispatched, matching the existing five; +mutating ops take no `supersede`, per `fs.lua:101-124`): + +- `pmacs.fs.mkdir(path, opts)` — `opts.parents` for `create_dir_all`. +- `pmacs.fs.copy(from, to, opts)` — regular files in v1; a directory + source is **refused** rather than silently shallow-copied. Preserves + mode bits; `opts.overwrite` defaults false and the op refuses an + existing target otherwise. +- `pmacs.fs.remove_dir_all(path)` — separate from `remove` rather than a + flag on it, so a recursive delete can never be reached by a caller that + meant the single-object op. + +**The rename rule — decided, with the semantics widened (F4).** A rename +must rebind open buffers, and it must do so at the **primitive**: +`pmacs.fs.rename` has **zero production callers**, so changing its +contract breaks nobody, and leaving the trap armed guarantees the next +caller rediscovers it the expensive way. The rebind is: + +- **prefix-aware**, not exact-match. `apply_resource_op`'s + `find_by_path` (`buffer_registry.rs:168-174`) is exact `Path` equality, + first match only — which strands every buffer beneath a renamed + *directory*, and `R` on a directory line is an ordinary dired + operation. The reconcile rebinds the renamed path itself **and** every + buffer whose path has it as a path-component prefix. +- **normalize-before-lookup.** Stored paths are normalized on write + (`editor_core.rs:819`) and the normalizing wrapper + `find_buffer_for_path` (`:864-867`) already exists; + `apply_resource_op` bypasses it with a raw lookup (`mod.rs:3248`), which + is a latent miss today. The new path goes through the wrapper. + `apply_resource_op`'s own raw lookup is fixed in the same change — it is + the same bug, one call site away. +- in the **main-thread completion drain**, `AsyncRuntime::tick` + (`async_runtime.rs:991`), unconditionally on success — **not** where + results are consumed. This is the load-bearing half of the decision + (R2-1). The fs ops are fire-and-forget-capable: nothing obliges a caller + to `await` or attach `on_complete`, so a rebind hung off `_take_result` + (`mod.rs:6758`) would miss every rename whose handle is never taken — + the rename lands on disk, the buffer is never rebound, and the trap + survives one layer below where we thought we fixed it. An acceptance + that awaits the rename would pass throughout, so **Stage 2's acceptance + includes a no-await rename** and bites against the drain. + + Two facts make this non-obvious to implement. **Rename settles as an + undifferentiated `ReplyKind::FsUnit`** — the same reply `chmod` and + `remove` produce; there is no `Rename` variant, and the drain arm maps + `Sleep | FsUnit` alike to `JobResult::Unit` (`async_runtime.rs:1022-1025`). + So the drain cannot key on the reply; it must key on the **pending job's + own `JobKind::FsRename`**. And the from/to paths live only in the + dispatch call today, so the pending job must **retain them** for the + drain to have anything to rebind with. Both are additive to + `async_runtime.rs`; neither changes the wire or the worker contract. + +**v1 supports `R` on a directory** — that is precisely what prefix-aware +rebinding buys, and refusing it while `C` refuses directory sources for a +different reason (no recursive copy primitive) would be an arbitrary +asymmetry. Stage 2's acceptance pins the directory case explicitly. + +**Confirmation.** Destructive operations (`x`, `D`, recursive delete, +overwriting copy) prompt. There is no `y_or_n` helper — a named +autosave-arc deferral — so Stage 2 adds one rather than repeating +`autosave.lua:219`'s two-element `minibuffer.read` at four call sites. + +## 8. Tolerant listing — the Stage 1 Rust change (Q#DR6) + +`read_dir` grows a per-entry error channel behind an **opt**. The Lua +result under `{ tolerant = true }` becomes: + +```lua +{ entries = { , ... }, errors = { { name = "..." | nil, message = "..." }, ... } } +``` + +with per-entry failures recorded and enumeration continuing. Errors on +the **parent** `read_dir` itself stay fatal — a directory you cannot open +has no partial answer. Dired renders a footer line (`N entries +unreadable`) and refuses wdired (§5). + +**`name` is optional (R2-2).** A per-entry `readdir` *iterator* error +(`fs.rs:215-218`) carries no filename — the entry never materialized, so +there is nothing to name, and the error is wrapped with the **parent** +path. That arm reports `name = nil`; the footer counts it without naming +it. Every other per-entry arm has an entry in hand and names it. + +**What moves into the per-entry channel (F5):** per-entry `readdir` +errors, `symlink_metadata` failures, `read_link` failures +(`fs.rs:228-234`), and **non-UTF-8 symlink targets** (`:227`). A +non-UTF-8 target differs in kind from a non-UTF-8 name: the entry's own +name is fine, the listing renders it with the target shown as unknown, +and nothing needs to pass the target back through `rename`. As it stands +today, one weird symlink in `/tmp` kills the entire listing — the exact +failure class this section exists to fix. + +**Non-UTF-8 names stay fatal**, and are a named deferral. Rendering them +tolerantly is not a listing problem but a *path representation* problem: +`FsDirEntry.name` is `String`, every `pmacs.fs` op takes a `String` path, +and `src/fs.rs:152-155` names byte-preserving paths as post-v0.1 work +that widens the whole surface. Doing it properly changes the type of +every path in the API; doing it improperly hands dired a name it cannot +pass back to `rename`. Stage 1 reports the directory as unlistable with +the offending bytes named, which `FsError::NonUtf8Path` already carries. + +**Why an opt rather than a shape change.** `read_dir` already takes +`(path, opts)`, so it is signature-natural; it keeps the change additive +for third-party packages; and it leaves the frozen fixture's bare-array +consumption (`init.lua:312`) untouched, which matters because a proof +artifact that must be edited to accommodate new work is not frozen. + +**The opts parser must validate (minor c).** `supersede_key` +(`fs.lua:73-83`) reads only `opts.supersede` and silently ignores every +other key, so a typo'd `tolerant` would degrade to fatal mode with no +signal. The tolerant change adds unknown-key rejection to the read ops' +opts parsing. + +**Already tolerated, for the record:** a failing `metadata.modified()` +yields mtime 0 rather than an error (`fs.rs:463-476`), so the per-entry +channel is not the first such concession — it is the first *explicit* one. + +## 9. Keybindings, display, and the major mode (Q#DR7, Q#DR8, Q#DR10) + +**Global** (both unbound repo-wide): + +- `C-x d` → `dired` — prompt for a directory, defaulting to the current + buffer's directory. Takes the standard `display = "current" | "panel"` + opt (Q#BP11b), defaulting to `"current"` in Stages 1–2 like every other + adopter. +- `C-x C-j` → `dired-jump` — dired on the current buffer's file's + directory, cursor seated on that file. + +**Visit routing (Q#DR10, F2).** A `RET` on a **file** line goes through +`pmacs.window.display_file(path, { select = true })`, never bare +`find_or_open`. `find_or_open` switches the active window in both +branches before firing hooks (`window_panel.rs:373-376`), so a `RET` in a +panel-displayed dired would replace the panel with the visited file — +the panel swallows itself. `display_file` is the Q#BP11b answer: it dedups +side-effect-free through the **normalizing** `find_buffer_for_path` +before any I/O, resolves the destination before the read, and is what LSP +visits and compile already use. Underneath, its panel-safety comes from +`display_target` (`window_panel.rs:425`) — "the non-side window a visit +from a panel should address". + +**Directory descent routes differently, and deliberately (R2-3).** A +`RET` on a **directory** line replaces the dired buffer **in the window +dired already occupies**: `switch_buffer` when dired is in a document +window, and `pmacs.window.display(buf, { side = , select = +true })` when dired is panel-displayed. This is Emacs behavior — walking +a tree in a side window keeps the side window — and it is the opposite +routing from a file visit for a principled reason: a *file* is not a +dired buffer and belongs in the document area (hence `display_target`), +while the next *directory* is the same kind of thing as the current one +and belongs in the same slot. **Dedication is a property of the slot, not +the buffer**, so a dedicated dired panel stays dedicated across descent +and the new dired buffer inherits it; Stage 1's acceptance pins that +rather than assuming it, since it is a `Layout`/`WindowParams` behavior +this framing does not otherwise touch. + +**Mode-scoped on `dired`** (Q#DR8: `scope = "mode", mode = "dired"`, +bound once at load rather than per buffer — dired is the first real +consumer of #129's mode keymaps beyond language detection): + +| Key | Command | Stage | +|-----|---------|-------| +| `RET`, `f` | visit (dir → descend, file → `display_file`) | 1 | +| `^` | parent directory | 1 | +| `n` / `p`, `` / `` | move by line | 1 | +| `g` | revert (re-read, preserve cursor and marks) | 1 | +| `q` | quit (restore previous buffer / `window.quit` in a side window) | 1 | +| `s` | cycle sort mode (name → mtime → size) | 1 | +| `m` / `u` / `U` / `t` | mark / unmark / unmark-all / toggle | 2 | +| `d` / `x` | flag for deletion / execute flagged | 2 | +| `D` | delete now (confirms) | 2 | +| `R` / `C` / `+` | rename / copy / mkdir | 2 | +| `w` | copy filename to the kill ring | 2 | +| `C-x C-q` | toggle wdired | 3 | + +**Mode-scoped on `wdired`:** `C-c C-c` commit, `C-c C-k` abandon — +matching compile's buffer-local `C-c C-k` idiom without colliding with +it, since dired buffers are never compilation buffers. + +Everything follows the `M-;` / `M-%` / `C-c @` precedent of shipping the +faithful Emacs default; users rebind through `pmacs.keymap`. + +**Cursor preservation (Q#DR9)** is a Stage 1 requirement, not a nicety: +`g`, a sort, and every Stage 2 operation repaint wholesale, and a dired +that drops you to line 0 after each mark is unusable. The cursor is +re-seated by **basename**, falling back to the nearest surviving line +index when the file is gone — `pmacs.editor.move_to_line` +(`mod.rs:12526`) makes this exact rather than the `move_down`-in-a-loop +walk `listview.lua:68-74` uses. + +## 10. Staging and scope + +- **Stage 0 (separable, recommended first) — `find-file` (Q#DR11).** + `C-x C-f` → `pmacs.minibuffer.read` with `source = "files"` and + `source_root` set to the current buffer's directory, accepting free text + (`resolve_accepted_value`, `minibuffer.rs:564`) into + `pmacs.window.display_file`. **Completion is flat and does not + descend**: a function source cannot list a directory (it is called with + zero arguments and cannot `await`, F3), and the Rust `Files` source is + single-directory and 1024-capped. Typing a full path still works via + free-text accept; typing a *prefix* completes only within the root. + Hierarchical completion is a named Rust change (§13) — either pass the + current input to custom sources, or re-root the `Files` source per + keystroke. **A nonexistent path creates a `[new file]` buffer** rather + than erroring: `display_file` routes through `resolve_target_buffer` + (`editor_core.rs:885-898`), which on `ErrorKind::NotFound` creates the + buffer, binds the path, and sets that status. This is Emacs parity and + is stated rather than inherited (R2-6). Stage 0 carries its own + acceptance (§14) — it is small but no longer trivial to describe + honestly, which is itself an argument for taking it as its own PR. It is + not required by any later stage; Stage 1's `RET` opens files directly. + **Say if you want it folded into Stage 1 instead; it is one branch + either way.** +- **Stage 1 — the dired view. Approval-critical.** + `builtin/runtime/dired.lua`; the `dired` major mode and mode keymap; + buffer-per-directory with canonical naming and the ownership check; + read-only intercept + round-trip input; visit routing through + `display_file`; parent / sort / revert / quit; `C-x d` (with the + `display` opt) / `C-x C-j`; cursor preservation across repaint; the + `dired.kill-when-opening` config key; **and the tolerant `read_dir` opt + plus its unknown-key validation** (Q#DR6) — the only Rust in this stage. + No wire change; no protocol bump. +- **Stage 2 — marks and operations.** The mark column and basename-keyed + mark set; `m`/`u`/`U`/`t`/`d`/`x`/`D`/`R`/`C`/`+`/`w`; + `pmacs.fs.mkdir` / `copy` / `remove_dir_all`; the **prefix-aware, + normalized rename rebind in the completion drain** and the matching + `apply_resource_op` raw-lookup fix (Q#DR5), pinned by a **no-await** + rename and by a directory rename that must not strand the buffers + beneath it; a `y_or_n` confirm helper; and removal of the Lua + canonicalization mirror if Stage 1 shipped one (Q#DR2). +- **Stage 3 — wdired.** `C-x C-q` mode swap; the column-classifying + intercept over the mark-shifted layout; escape/unescape round-trip; + duplicate-name and NUL/slash rejection pre-syscall; two-phase rename; + field-by-field external-change detection; the symlink perms and symlink + target rules; partial-application reporting. +- **After Stage 3 — shrink the M8 fixture** to the minimum payload that + still proves package universality (§3). + +Stages 2 and 3 are sketched here and each gets its own detailed framing +after the prior stage lands, per the folding-arc precedent. **This +framing asks approval for the architecture and Stage 1's detail.** + +## 11. Numbered decisions + +- **Q#DR1** Dired ships as `builtin/runtime/dired.lua`, written fresh; + the M8 fixture stays frozen under `tests/fixtures/` because the + **harness routing** (46/47 tests reaching dired through `install_local` + + `require`) *is* the universality proof and dies if re-pointed, and + because the builtin diverges structurally. Its 45 behavioral assertions + are re-pinned by §14. Shrinking the fixture is **scheduled after + Stage 3**. (§3) +- **Q#DR2** One buffer per directory, `*dired:*`, + found-or-created by name; navigation opens the target's buffer rather + than renaming the current one (there is no `pmacs.buffer.set_name`). + Names and lookups are **lexically normalized** (tilde, absolutize, + `.`/`..`, trailing slash) with **symlinks deliberately unresolved**; + found-by-name **verifies dired ownership** before painting. + A Lua mirror of `normalize_buffer_path` is a second canonical form and + requires a **parity acceptance** against the Rust normalizer; exposing + the normalizer instead is the preferred end state, since Q#DR5 gives it + a second consumer. `dired.kill-when-opening` (default `false`) mirrors + Emacs 28's opt-out. (§4) +- **Q#DR3** Read-only via `add_intercept` + `set_round_trip_input`, with + dired's own paints using `bypass_intercept`; dired owns its buffer and + does **not** extend `pmacs.listview`; wdired is a major-mode swap, and + refuses to open on a partially-listed directory. (§5) +- **Q#DR4** Mark column at column 0 shifts all offsets; marks are keyed + by **basename**, never line index; `*` and `D` are the two mark + characters; a vanished basename is dropped from a batch and reported. + (§6) +- **Q#DR5** Stage 2 adds `pmacs.fs.mkdir` / `copy` / `remove_dir_all`. + Rename rebinding is fixed **at the primitive** (zero production callers + to break), **prefix-aware** (a directory rename must not strand the + buffers beneath it), **normalize-before-lookup** (fixing + `apply_resource_op`'s raw `find_by_path` in the same change), and in the + **main-thread completion drain** `AsyncRuntime::tick` — never in the + take/await path, which a fire-and-forget rename never reaches. Because + rename settles as an undifferentiated `ReplyKind::FsUnit`, the drain + keys on the pending job's `JobKind::FsRename` and the job retains + from/to. `R` on a directory is supported in v1; `C` refuses directory + sources. Destructive ops confirm via a new `y_or_n` helper. (§7) +- **Q#DR6** `read_dir` becomes per-entry tolerant behind an **opt** + (`{ tolerant = true }`), carrying per-entry `readdir`/`lstat`/`readlink` + failures **and non-UTF-8 symlink targets** in an `errors` channel; + parent-level failures stay fatal; non-UTF-8 **names** stay fatal, + deferred to a byte-preserving path surface. The read ops' opts parsing + gains unknown-key rejection. (§8) +- **Q#DR7** Emacs-parity bindings: global `C-x d` (with the `display` + opt) / `C-x C-j`; mode-scoped in-buffer keys per the §9 table; wdired on + `C-x C-q` with `C-c C-c` / `C-c C-k`. (§9) +- **Q#DR8** Keys are **mode-scoped** (`scope = "mode", mode = "dired"`), + not buffer-local — bound once at load, and the wdired swap changes the + whole keymap with the mode. Dired is #129's first non-detection + consumer. (§9) +- **Q#DR9** Cursor is re-seated by **basename** after every repaint, + falling back to the nearest surviving index, via + `pmacs.editor.move_to_line`. (§9) +- **Q#DR10** File visits route through `pmacs.window.display_file` + (panel-safe via `display_target`), never bare `find_or_open`, which + switches the active window before hooks and would let a `RET` replace + the panel dired is displayed in. **Directory descent instead reuses + dired's own window** — `switch_buffer` in a document window, + `display { side = , select = true }` in a panel — because + the next directory is the same kind of thing as the current one. + Dedication is a slot property and follows across descent. (§9) +- **Q#DR11** Stage 0's completion is the flat Rust `Files` source rooted + at the current buffer's directory, plus free-text accept. Function + sources cannot descend (zero-argument call, no `await` in the dispatch + context); hierarchical path completion is a named Rust deferral. + **Amended by S0-1:** the two are not independent — a selected candidate + **shadows** typed text, so free-text accept is reached only when the + input filters every candidate away (in practice, when it contains a + `/`). The prompt field therefore starts empty (S0-2), and a leading + `~` is expanded Lua-side (S0-3). (§0, §10) + +## 12. Bets + +- **B1** The fixture's hard parts — escaping with an exact inverse, + two-phase rename, field-by-field external-change detection, the symlink + rules — transfer to the builtin as decided design. FALSIFIABLE at + Stage 3: if the mark-shifted layout or the mode swap forces a different + commit model, the fixture stops being a reference and Stage 3 is + re-framed from scratch. +- **B2** Tolerant `read_dir` is the *only* Rust change Stage 1 needs — + `display_file`, mode keymaps, `move_to_line`, and + `set_round_trip_input` all already exist. FALSIFIABLE during + implementation; the most likely miss is **scroll** preservation, since + the daemon owns `view_top` and "viewport facts on the wire" is a + standing backlog gap — if preserving scroll (not just cursor) across a + repaint needs a new fact, Stage 1 grows. +- **B3** Mode-scoped single-key bindings survive both frontends, because + `set_round_trip_input` keeps optimistic-apply off — and, when dired sits + in a panel, the `!window.is_side()` arm of the same gate + (`editor.rs:818`) covers it a second time. FALSIFIABLE on a real GPU + session: pressing `d` must flag, never insert. +- **B4** Buffer-per-directory does not produce clutter users complain + about (Emacs parity), and `dired.kill-when-opening` is a sufficient + escape hatch. Falsifiable only by use. +- **B5** Flat, non-descending completion is acceptable for Stage 0 + because free-text accept covers the full-path case. FALSIFIABLE + immediately by use: if typing full paths blind is what people actually + do, hierarchical completion stops being a deferral and becomes Stage 0's + real scope. + +## 13. Deferred (named) + +- **Hierarchical path completion** — pass the current minibuffer input to + custom sources, or re-root `CompletionSource::Files` per keystroke + (Q#DR11). +- **Typed text vs. a selected candidate on accept** (S0-1) — prefer the + typed contents when they differ from the selection and the user has not + explicitly moved it. Closes Stage 0's "a new bare name that is a + subsequence of an existing entry opens the existing file" hole, but + changes `M-x` and `switch-buffer` accept semantics too, so it needs its + own reasoning and gates. **It would also close the empty-input case** + (S0-4): `fuzzy_score` returns `Some(0)` for an empty needle + (`minibuffer.rs:637-640`) and `filter_and_sort` breaks ties + lexicographically (`:678`), so accepting immediately opens the + first-sorted entry — dotfiles first, and possibly a directory, which + then fails and reports. Inherited from the shared minibuffer, not + introduced by find-file, and recorded as decided rather than + overlooked. +- **Minibuffer history stores the pre-join value** (S0-5) — + `Minibuffer::accept` pushes the *resolved* value (a bare basename, or a + root-relative path) into the history bucket Rust-side, **before** Lua + joins it onto the root. So recalling `sub/inner.txt` with `C-p` under a + *different* root resolves against the new root, and can silently create + a `[new file]` buffer somewhere else. Emacs's `file-name-history` stores + absolute paths. Lua cannot fix this — the push happens before + `on_accept` runs — so it belongs with the other Rust-side minibuffer + deferrals here. +- **Load through the normalized path** (S0-3) — `get_or_load_buffer` + computes a normalized path and then loads from the raw one + (`editor_core.rs:842-856`), so tilde paths dedup but do not load. Same + normalize-before-lookup family as Q#DR5's `apply_resource_op` fix. +- **Non-UTF-8 filenames** — needs byte-preserving `pmacs.fs` paths + (`src/fs.rs:152-155`); widens every path in the API. (Non-UTF-8 symlink + *targets* are handled in Stage 1, §8.) +- **Shrinking the M8 fixture** — scheduled after Stage 3 (§3). +- **Factoring the shared panel disciplines** out of dired and + `pmacs.listview` (§5). +- `o` / `C-o` visit-in-other-window — the GPU has no splits (GPU + structural parity, roadmap Arc 8). +- `!` / `&` shell command on marked files; `Q` query-replace across marked + files; `A` search across marked files. +- `i` insert-subdirectory (in-buffer recursive listing) and + `dired-hide-details`. +- Owner and group columns — no uid/gid → name primitive exists. +- Human-readable sizes; sort by extension; reverse-sort toggle. +- `%m` / `%d` regex mark family; `dired-omit-mode`. +- Recursive copy (`C` on a directory), which v1 refuses. +- Auto-revert on external change — `pmacs.fs.watch` exists and polls + (`fs.lua:226`), so this is wiring plus a policy decision about polling a + directory the user is not looking at. +- Dired buffers in the desktop session — covered by Arc 3's standing + "non-file buffers in the desktop" deferral. +- Remote / Tramp-style paths. +- Symlink creation and symlink-target editing (the fixture rejects target + edits at commit; `init.lua:821-840`). + +## 14. Acceptance + +### Stage 0 — `find-file` (R2-6) + +0a. **Flat completion within the root.** With the current buffer in a + temp directory, `C-x C-f` offers that directory's entries as + candidates and does **not** offer entries of a subdirectory — + documenting the flat-source limitation as intended behavior rather + than leaving it unpinned. +0b. **Free-text accept of a deeper path.** Typing a full path below the + root and accepting opens that file, with no candidate selected + (`resolve_accepted_value`'s raw-input path). +0c. **Nonexistent path creates.** Accepting a path that does not exist + yields a buffer bound to it, unmodified and empty, with the + `[new file]` status — not an error. +0d. **No-path origin.** From a buffer with no backing path, the prompt + roots at the process cwd rather than erroring or offering nothing. + +### Stage 1 — the dired view + +1. **Listing shape.** `C-x d` on a temp directory renders a header line + plus one line per entry, with kind char, perms, size, mtime, and name; + a symlink renders `l` with ` -> target`; the entry count matches + `read_dir`. +2. **Visit dispatches on kind, through the panel-safe primitive + (Q#DR10).** `RET` on a subdirectory line opens that directory's dired + buffer; `RET` on a **file** line opens the file bound to its path (the + fixture's "not yet exposed" error is gone). `RET` on the header does + nothing. **The panel case is the real assertion**: with dired opened + `display = "panel"`, a `RET` on a file line leaves the dired panel + alive and puts the file in the document window — **falsified by + swapping `display_file` for `find_or_open`**, which must make the panel + disappear. +3. **Buffer-per-directory and canonicalization (Q#DR2).** Descending + twice then ascending twice yields the *same* buffer ids as the first + visit; every dired buffer's name matches the directory it displays; + and `C-x d` on `/tmp`, `/tmp/`, and `/tmp/../tmp` (with a real temp + dir) yields **one** buffer, not three. With + `dired.kill-when-opening = true`, the departed buffer is gone. +3b. **Canonicalization parity (R2-4).** One shared edge-case list — + `//tmp`, a trailing slash, `~` with `HOME` set and unset, `.`/`..` + segments including a `..` that would escape root, a relative path — + driven through **both** dired's canonicalizer and the Rust + `normalize_buffer_path`, asserting identical output. If Stage 1 + exposes the normalizer instead of mirroring it, this degenerates to a + round-trip test and the mirror-removal follow-up is dropped. +3c. **Panel descent (Q#DR10, R2-3).** With dired opened + `display = "panel"`, `RET` on a **directory** line leaves dired in the + same side window showing the new directory — the panel is neither + replaced by a document window nor duplicated — and a dedicated panel + is still dedicated afterward. +4. **Ownership check (Q#DR2, F7).** A foreign `pmacs.buffer.create` + buffer named exactly `*dired:*` is **not** adopted: `C-x d` on + that path leaves the foreign buffer's contents byte-identical and + opens dired elsewhere. +5. **Read-only (Q#DR3).** A `buffer.self-insert` into a dired buffer is + rejected by the intercept and leaves the text byte-identical; dired's + own repaint succeeds through `bypass_intercept`. `set_round_trip_input` + is set, pinned **through the real dispatch path** so a semantic + frontend's `d` reaches the binding rather than optimistic-applying — + falsified by reverting the `set_round_trip_input` call, not by a + direct-call assertion. +6. **Mode keymap (Q#DR8).** The keys resolve through `scope = "mode"` + with no per-buffer binding: a *second* dired buffer, created without + any `keymap.bind` call of its own, still responds to `g` and `^`. + `pmacs.buffer.major_mode(buf)` is `"dired"`, and the mode shows in the + statusline. +7. **Cursor preservation (Q#DR9).** With the cursor on entry `k`, `g` + re-seats on the same **basename** after an external file was added + *above* it (so the line index changed); when that basename is deleted + externally, the cursor lands on the nearest surviving line, not line 0. +8. **Sort.** `s` cycles name → mtime → size → name; mtime sorts newest + first and size largest first, each with a stable name tiebreak; the + cursor stays on its basename across the reorder. +9. **Tolerant listing (Q#DR6).** In a directory containing a child whose + `lstat` fails, `{ tolerant = true }` returns the surviving entries plus + one `errors` row naming the child; dired renders every readable entry + plus the unreadable-count footer; and **the default (non-opt) call + still returns a bare array** — both forms called in one test, so the + fixture's contract cannot regress unnoticed. A failure on the parent + directory itself is still fatal. +10. **Tolerant symlink targets (Q#DR6, F5).** A directory containing a + symlink whose target is non-UTF-8 lists successfully under + `{ tolerant = true }`, with that entry present and its target reported + unknown — **falsified by reverting the `read_link`/target arm**, which + must take the whole listing down. +11. **Unknown opts rejected (minor c).** `read_dir(path, { tolerat = true })` + errors naming the unknown key rather than silently listing in fatal + mode. +12. **Non-UTF-8 names stay fatal, and say so.** A directory containing a + non-UTF-8 *name* reports the structured `NonUtf8Path` error with the + offending bytes; dired surfaces it as a status message and creates no + buffer. +13. **`dired-jump`.** From a file buffer, `C-x C-j` opens dired on that + file's directory with the cursor on that file's line. From a buffer + with no path, it reports that and creates nothing. +14. **Quit.** `q` restores the previously active buffer; in a side window + (`display = "panel"`) it routes through `pmacs.window.quit`, matching + `listview.quit`'s Q#BP11b split. +15. **Failure leaves nothing behind.** `C-x d` on a nonexistent or + unreadable directory creates no buffer, switches no window, and + reports the reason — the fixture's + `dired_open_failure_leaves_editor_unchanged` invariant. +16. **Scale.** A 10,000-entry directory renders within the fixture's + established 200 ms budget, on the builtin path — carrying the same + `cfg_attr(target_os = "macos", ignore)` gate the fixture's version + uses (`m8_2_acceptance.rs:211-213`), since hosted macOS debug runners + do not consistently satisfy it. +17. **The fixture still passes.** `m8_2_acceptance` 15/15 and + `m8_3_acceptance` 32/32 unchanged, proving the `read_dir` opt is + additive. + +Every behavioral claim above is bite-verified with `scripts/bite`. + +## 15. Gates (Stage 1) + +`cargo fmt --check`; `cargo clippy --workspace --all-targets -- -D +warnings` as its own step; `cargo test --lib`; `cargo test --lib +--features crdt`; `tests/dired_acceptance.rs` (default + CRDT); +`tests/m8_1_acceptance.rs`, `tests/m8_2_acceptance.rs`, and +`tests/m8_3_acceptance.rs` (the additivity proof — m8_1 because it +exercises `pmacs.fs.rename` and `read_dir` directly); `cargo test --test +m4_acceptance -- --skip basedpyright`; `PMACS_REQUIRE_GPU=1 cargo test -p +pmacs-gpu`; the workspace sweep **with an isolated `XDG_CONFIG_HOME`** +(the real `~/.config/pmacs/init.lua` on this desktop calls +`install_local`, which races every editor the sweep builds and leaks a +status message into frame-comparing suites); `git diff --check`. + +## 16. Branch and PR plan + +Branch `dired`, worktree `../pmacs-dired-arc` — **not** `../pmacs-dired`, +which would read as the fixture. **Based on canonical `githubsucks/main` +@ `0827dd1`** (Lean 4 Stage 1 #160), which is one merge ahead of the +scout's `e745068`; see §2's base note for why that movement does not +disturb the ground truth. + +**The shared checkout is not the place to cut this.** It currently has +`lean4-stage1` checked out with in-progress foreign work +(`src/highlight.rs` modified), and this framing is untracked in it. Per +the §5 ops rule, the branch is cut as a **sibling worktree off `main`** +and the framing is committed there as the branch's first commit, rather +than by switching the shared checkout. The framing does not travel until +that commit is pushed. + +Stage 1 implements on the same branch and opens as the first dired PR. +Stages 2 and 3 are separate branches and PRs off the `main` that results +from the prior stage, each with its own detailed framing. If Stage 0 +(`find-file`) is taken separately it goes first, on its own branch +`find-file`, and Stage 1 rebases onto the resulting `main`.