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. diff --git a/Cargo.lock b/Cargo.lock index 11b6fcc..b80dbae 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/builtin/commands/default.lua b/builtin/commands/default.lua index 04c49fe..2a13c21 100644 --- a/builtin/commands/default.lua +++ b/builtin/commands/default.lua @@ -611,6 +611,129 @@ 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 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, +-- 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/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/compile.lua b/builtin/runtime/compile.lua index e364de2..544c1d7 100644 --- a/builtin/runtime/compile.lua +++ b/builtin/runtime/compile.lua @@ -732,8 +732,29 @@ 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 + -- 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 +826,27 @@ 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 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. + -- + -- 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) + 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 +907,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 +1038,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..6a6d717 100644 --- a/builtin/runtime/listview.lua +++ b/builtin/runtime/listview.lua @@ -123,7 +123,23 @@ 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 + pmacs.window.display(p.buffer, { side = "bottom", select = true }) + else + pmacs.window.switch_buffer(p.buffer) + end seat_cursor(p, 1) end @@ -160,6 +176,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/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/builtin/runtime/window.lua b/builtin/runtime/window.lua new file mode 100644 index 0000000..37b459f --- /dev/null +++ b/builtin/runtime/window.lua @@ -0,0 +1,63 @@ +-- 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. +-- +-- 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 = 1, + 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/docs/active-work.md b/docs/active-work.md index e5aa3ba..4f17b2a 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. @@ -54,10 +54,30 @@ 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. -## Inline-math slice lane — IN FLIGHT +## Inline-math slice lane — PR #158 OPEN, main integrated - Portable branch: `githubsucks/inline-math-slice`; worktree - `../pmacs-math-slice`. + `../pmacs-math-slice`. **PR #158**, base `main`. +- **Canonical `main` @ `8c86d34` merged into the lane** (2026-07-25), + 28 commits behind at the time. Merged rather than rebased, per the + #135/#137 precedent: the PR is awaiting review rounds and a rebase + would break every review anchor. The only conflict was this ledger — + both sides' lanes were kept — and it was **pre-existing**, not + introduced by the dired (#164) or Lean 4 ledger commits; it already + conflicted against `main` @ `e745068`. +- **Integration surface** (derived from `git diff ..main`, + not from another PR's file list): `pmacs-gpu/src/main.rs` gained 72 + lines on main from `e547a90` — the minimap all-blank-slab + divide-by-zero fix — and this lane rewrites large parts of the same + file. Git auto-merged it **textually**; a clean auto-merge is not + evidence the tree compiles (the folding-arc lesson), so the full gate + suite below is what actually discharges it. +- **CI had never run on this branch** — zero workflow runs since the PR + opened on 2026-07-24, while every other open PR has a full 12-check + run. Not a fork and not a trigger-config issue (the workflow fires on + all `pull_request` events); cause unidentified. The integration push + is what gets it its first run, so treat that run as the branch's + first real CI evidence. - Framing: `docs/inline-math-slice-framing.md` rev 3, approved after two review rounds; parent arc framing merged as #154. - State: parser, font bundle (GUST licence), MATH-table layout with the @@ -79,15 +99,276 @@ If it does not, stop and repair the remote/fetch configuration. dependency line) are implemented on the branch tip. - Clippy is CLEAN on the whole workspace at `-D warnings` — the draw pass consumed every formerly-dead item. -- Verification at the tip: 199 `pmacs-gpu` tests under - `PMACS_REQUIRE_GPU=1`; 1,815 default + 1,992 CRDT library tests; M4 - 121; full workspace sweep green (isolated `XDG_CONFIG_HOME`). -- Remaining before PR: nothing known; awaiting the user's review pass. +- Verification **pre-integration** (at `14c1c01`, against the old base): + 199 `pmacs-gpu` tests under `PMACS_REQUIRE_GPU=1`; 1,815 default + + 1,992 CRDT library tests; M4 121; full workspace sweep green (isolated + `XDG_CONFIG_HOME`). Superseded by the post-integration run below — + those numbers describe a tree 28 commits behind. +- Verification **post-integration** (the merge commit; this is the set + that describes what the PR now proposes): recorded when the gate suite + finishes. +- Remaining: the user's review pass. Named v0 approximations: the peer-caret half of acceptance 14 is pinned at the mapping level (unit tests), not pixels; a soft-wrapped spacer draws its box whole at the first run's origin; the fit budget reads the bundled code face even under a custom `set_font` family (the draw anchors to the real shaped baseline either way). +## 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. + +## 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 + `../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`. +- **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. +- **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`. +- **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` 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 + 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. 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 + placement flip. ## Folding lane (Arc 6) — Stages 1 and 2 MERGED; Stage 3 (GPU) is next 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/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. 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`. 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. diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index bf6766a..eca19e5 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -8741,7 +8741,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), }) @@ -11433,6 +11443,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))]; diff --git a/src/daemon.rs b/src/daemon.rs index 98a688a..5af71d0 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 @@ -4123,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/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..79f1225 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -161,6 +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 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. 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)] @@ -208,8 +218,25 @@ 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 { + 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 +515,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 +623,7 @@ impl EditorState { snippets, statusline_registry, mouse_click: None, + window_drag: HashMap::new(), } } @@ -763,9 +801,73 @@ 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. @@ -777,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); @@ -800,6 +905,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 +1193,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 +1928,29 @@ 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. + // + // 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.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 THIS frontend's gesture only. + _ => { + self.window_drag.remove(&frontend_id); + } + } + return; + } + let Some((win_id, rect)) = window_at_cell( &self.core.borrow(), frontend_id, @@ -1826,6 +1962,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 +2073,132 @@ 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()); + // 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). + /// + /// 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.get(&frontend_id).copied() else { + return; + }; + 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; + }; + 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 +2639,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 +3109,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 +3153,22 @@ 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 +3392,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 @@ -3579,6 +3883,33 @@ fn mode_line_grapheme_width(graphemes: &[ModeLineGrapheme]) -> u32 { graphemes.iter().map(|grapheme| grapheme.width).sum() } +/// 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); +} + /// 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. @@ -6360,7 +6691,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:?}"); @@ -6776,16 +7108,14 @@ mod tests { } else { panic!("expected split"); } - let p1 = s - .core - .borrow() - .active_layout() - .compute(crate::window::Rect::new(0, 0, 24, 90)); - let p2 = s - .core - .borrow() - .active_layout() - .compute(crate::window::Rect::new(0, 0, 24, 60)); + 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 37cdabf..cfb2bcb 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,48 @@ 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 +1010,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 +1041,75 @@ 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() { - continue; + 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 { + // 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 + // 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 = 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 +2580,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 +2639,133 @@ 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). + /// + /// **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; + }; + 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 && 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 +2776,1027 @@ 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) + && 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 => { + // 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); + let origin_valid = origin.is_some_and(|origin| { + self.views + .get(&fid) + .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 { + 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_or(0, Buffer::len) + }; + 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. + #[allow( + clippy::too_many_lines, + reason = "one boundary-resize transaction: resolve, snapshot minima, clamp, write back" + )] + 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. + #[allow( + clippy::too_many_lines, + reason = "Q#BP3's precedence ladder reads as one ordered policy" + )] + 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.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()); + } + + // 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 +4477,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 +4935,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 +4954,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 +5522,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/highlight.rs b/src/highlight.rs index 2c7369f..de8ffe0 100644 --- a/src/highlight.rs +++ b/src/highlight.rs @@ -174,6 +174,46 @@ 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)), + // 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() @@ -1484,6 +1524,262 @@ 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 { + 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}; + + 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 + } + + /// 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_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 + // 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 10 + // of `local t = {}`. + 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`, diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index 7cf0bc4..29e3b47 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()?; @@ -8475,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()) @@ -8489,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 @@ -8529,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)) })?, )?; @@ -8691,6 +8734,10 @@ fn parse_terminal_spec(table: &Table) -> mlua::Result 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)?; { + // 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) })?, )?; } @@ -12185,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) })?, )?; } @@ -12271,8 +12327,7 @@ 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) })?, )?; } @@ -12302,9 +12357,41 @@ fn install_window_module(lua: &Lua, core: &SharedCore) -> mlua::Result
{ { let cc = core.clone(); + // 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. win.set( "buffer", - lua.create_function(move |_, ()| Ok(BufferIdLua(cc.borrow().active_buffer_id())))?, + lua.create_function( + move |lua, target: Option| -> mlua::Result { + // 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) + .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 new file mode 100644 index 0000000..f4833ef --- /dev/null +++ b/src/lua_bindings/window_panel.rs @@ -0,0 +1,642 @@ +// 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) { + // `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, + }; + 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) +} + +/// 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. +pub(crate) 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). +pub(crate) 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" + )) + }) +} + +/// A parsed adopter placement request (Q#BP11b). +/// +/// `listview`, compile, and terminal all take the same strict +/// `display = "current" | "panel"` value. In Stages 1–2 omission means +/// `"current"`; Stage 3 flips omission to `"panel"`. Explicit +/// `"current"` always preserves the adopter's pre-arc selected-window +/// behavior and is the user-facing opt-out from that flip. +pub(crate) enum AdopterPlacement { + /// Today's behavior: the raw switch into the frontend's active + /// window, deliberately bypassing display-policy dedication. + Current, + /// The bottom panel. + Panel, + /// An exact target window. + Window(WindowId), +} + +/// Parse an adopter's placement **before** it creates a buffer, session, +/// process, or wrapper — so an unknown value leaves nothing to roll back. +/// +/// # Errors +/// An unknown `display` value, a `window` combined with +/// `display = "panel"`, or a window id that is not live in the acting +/// frontend's layout. +pub(crate) fn parse_adopter_placement( + core: &SharedCore, + fid: FrontendId, + operation: &str, + display: Option<&str>, + 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(); + 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 => selected_window(&cc, fid)?, + }; + 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 => selected_window(&cc, fid)?, + }; + 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/syntax.rs b/src/syntax.rs index efbbd4d..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())) @@ -1130,6 +1136,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 +2427,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 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/src/window.rs b/src/window.rs index e1162c0..b66499e 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,66 @@ 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 + let extent = if let Some(rows) = extents[i] { + rows } else { - primary * w / total + 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 { + u32::try_from(u64::from(remainder) * u64::from(w) / u64::from(total)) + .unwrap_or(remainder) + }; + flexible_used += e; + e }; let child_area = match orientation { Orientation::Horizontal => Rect { @@ -465,13 +1005,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 +1146,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 +1156,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 +1172,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 +1192,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 +1229,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/bottom_panel_stage1_acceptance.rs b/tests/bottom_panel_stage1_acceptance.rs new file mode 100644 index 0000000..fb3a4ad --- /dev/null +++ b/tests/bottom_panel_stage1_acceptance.rs @@ -0,0 +1,2621 @@ +// 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); +} + +/// 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" + ); + + // 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\")"); + 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" + ); +} + +/// `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 +// --------------------------------------------------------------------------- + +#[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 — 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()"); + } + 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); + exec(&s, "pmacs.window.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"); +} + +/// 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" + ); + + // …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), peer_mode_line, 4), + CellSize::new(ROWS, COLS), + ); + + // …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 events did not cancel or steal LOCAL's in-flight drag" + ); +} + +#[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, + // `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\", \"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"); + let buffer: pmacs::lua_bindings::BufferIdLua = eval(&s, "return TERM_BUF"); + render(&s); + + // 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, 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!( + "pmacs.window.set_params({}, {{ fixed_rows = 10 }})", + panel.raw() + ), + ); + render(&s); + s.sync_terminal_layout(FrontendId::LOCAL, CellSize::new(ROWS, COLS)); + // 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!( + 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" + ); + exec(&s, "pmacs.terminal.terminate(TERM_BUF)"); +} + +/// 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!( + "pmacs.window.set_params({}, {{ fixed_rows = 23 }})", + panel.raw() + ), + ); + 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_id, grown) + .expect("snapshot at the grown size"); + + // 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)"); +} + +/// **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)); + } +} + +/// 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(); + exec( + &s, + "TERM_BUF = pmacs.terminal.open { command = \"/bin/sh\", \ + args = { \"-c\", \"i=0; while [ $i -lt 60 ]; do printf 'row%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); + let view_size = CellSize::new(5, COLS); + + // 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. + { + 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 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( + &s, + &format!( + "pmacs.window.set_params({}, {{ fixed_rows = 20 }})", + panel.raw() + ), + ); + render(&s); + s.sync_terminal_layout(FrontendId::LOCAL, CellSize::new(ROWS, COLS)); + + 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!( + 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: 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)"); +} + +/// 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(); + 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); +} diff --git a/tests/find_file_acceptance.rs b/tests/find_file_acceptance.rs new file mode 100644 index 0000000..fb793e3 --- /dev/null +++ b/tests/find_file_acceptance.rs @@ -0,0 +1,373 @@ +// 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:?}" + ); +} + +/// 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 +/// 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}" + ); +} 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/lean4_stage1_acceptance.rs b/tests/lean4_stage1_acceptance.rs new file mode 100644 index 0000000..d48a86c --- /dev/null +++ b/tests/lean4_stage1_acceptance.rs @@ -0,0 +1,340 @@ +//! 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_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). + + // 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"); +} 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