Merge remote-tracking branch 'githubsucks/main' into fix-m4-sink-races

This commit is contained in:
Levi Neuwirth 2026-07-28 16:31:13 -04:00
commit 302c21c688
65 changed files with 23909 additions and 1261 deletions

View File

@ -95,7 +95,7 @@ remain open to them.
| § | Concern | Grade | One-line state |
|---|---|---|---|
| 2 | Golden product journey | **Broken at entry** | `pmacs .` exits 1; only "launch" and "edit" pass cleanly zero-config |
| 2 | Golden product journey | **Runs to step 5** | `pmacs .` opens the directory (Journey Stage 1a); thin from step 6 on |
| 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 |
@ -109,10 +109,10 @@ remain open to them.
| 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 |
| 16 | Semantic frontend | **Strong** | v6..=v21 schema support; production attach remains v20 during the dark panel slice; degradation practiced |
| 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 |
| 19 | Coherence acceptance tests | **Started** | `tests/journey_acceptance.rs` exists (steps 2, 3, 5); the other five scenarios are still unwritten |
Three cross-cutting patterns explain most of the table; they are
detailed in §1.1§1.3: **substrate without surface**, **the silence
@ -120,7 +120,7 @@ asymmetry**, and **per-arc coherence debt**.
Coherence-shaped work already in flight at audit time: find-file /
dired Stage 0 (`C-x C-f`, merged #162, `docs/dired-framing.md`) and its
Stage 1 directory view (PR #165), bottom panel Stage 1 (merged #155),
Stage 1 directory view (merged #165), bottom panel Stage 1 (merged #155),
multi-root LSP affinity (merged #161), the config registry foundation
(merged #127).
@ -195,7 +195,7 @@ working, unreachable capability:
time; a complete 1,384-line dired existed only as a frozen test
fixture (`tests/fixtures/pmacs-dired/init.lua`). **Fixed:** dired
Stage 0 opens a path (`C-x C-f`, merged #162) and Stage 1 ships the
browsing view as a builtin (`C-x d` / `C-x C-j`, PR #165). The fixture
browsing view as a builtin (`C-x d` / `C-x C-j`, merged #165). The fixture
stays frozen — its `install_local` + `require` routing *is* the M8
package-universality proof (Q#DR1) — and shrinking it is scheduled
after Stage 3.
@ -339,7 +339,8 @@ the journey.
### Ground truth: the journey today
**Grade: broken at step 3.** Verified empirically at audit time:
**Grade: reaches step 5; thin from step 6 on.** Was **broken at step 3**
at audit time:
```
$ ./target/release/pmacs .
@ -347,15 +348,23 @@ pmacs: Is a directory (os error 21)
EXIT=1
```
The literal first arrow of the diagram above fails. `load_file`
The literal first arrow of the diagram above failed. `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.
`EditorState::open` returned `Err` and `main` printed and exited.
**Journey Stage 1a fixed that arrow** (`docs/journey-stage1a-framing.md`).
`resolve_target_buffer` now answers `ResolvedTarget::Directory` *ahead*
of the load, `pmacs .` lists the directory in dired, `RET` visits a
file, and a self-insert lands in it — steps 3 and 5 run end to end,
pinned by `tests/journey_acceptance.rs`. Which surface opens a directory
is a `path.open-directory` chain with dired as a replaceable fallback,
so this did not grow a second directory surface.
Still true: multiple file arguments are rejected (`"multiple files not
yet supported"`, `src/main.rs:227`), and everything from step 6 onward
is gated on a file being open — but the zero-config way to open one is
no longer "already know the path".
Full verdict table:
@ -363,12 +372,12 @@ Full verdict table:
|---|---|---|---|
| 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 at the CLI** | `pmacs .` still exits 1 (above): `load_file` 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. Dired Stage 1 (PR #165) supplies the buffer a directory should resolve *to*; routing `pmacs .` into it is Journey Stage 1's work, which must not invent a second directory surface |
| 3 | Open real project | **Works at the CLI** | Journey Stage 1a: `resolve_target_buffer` answers `ResolvedTarget::Directory` before the EISDIR-producing load, and `EditorState::open` / the daemon bootstrap dispatch the `path.open-directory` chain, whose fallback is dired (#165's buffer, reached rather than duplicated). Startup no longer fails: an unreadable directory, a crashed resolver, and a cleared handler all report on the status line and leave the session running. Because the listing is async and the bootstrap is synchronous, the commit runs against a destination captured at request time (`pmacs.window.commit_to`) rather than against the ambient frontend |
| 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: fixed (open by path merged #162; browsing PR #165). Symbol: works but undiscoverable** | No find-file/dired/picker existed at audit. Now `C-x C-f` opens a known path and `C-x d` / `C-x C-j` browse (flat listing, `dired` mode keymap); `M-.`/`M-?`/`C-c o` still 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. *Was broken outright on the GPU frontend until the double terminal-layout sync was fixed: the child took a `SIGWINCH` storm at tick cadence, so typing into it was impossible while output still flowed.* |
| 7 | Find symbol / file | **File: fixed (open by path merged #162; browsing #165). Symbol: works but undiscoverable** | No find-file/dired/picker existed at audit. Now `C-x C-f` opens a known path and `C-x d` / `C-x C-j` browse (flat listing, `dired` mode keymap); `M-.`/`M-?`/`C-c o` still bound but advertised nowhere and server-gated; no workspace-symbol command; `pmacs.index.*` has no UI |
| 8 | Open terminal | **Works** | Full PTY with scrollback + modeline segment, bound to `C-c t` and configurable through three registered settings (`terminal.default-profile`, `terminal.scrollback-rows`, `terminal.escape-key`) plus named `pmacs.terminal.profiles` (PR #173), and searchable through `M-x terminal.copy-mode` / `C-c C-t`, which materializes the retained scrollback into an ordinary read-only buffer (Stage 2). Named limitations: `C-c t` is unreachable from *inside* a terminal window, where `C-c` is consumed as the escape — `M-x terminal` still works there; and there is still **no close/kill command**, which is the remaining half of this step's discoverability gap. *Was broken outright on the GPU frontend until the double terminal-layout sync was fixed: the child took a `SIGWINCH` storm at tick cadence, so typing into it was impossible while output still flowed.* |
| 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) |
@ -379,6 +388,13 @@ A journey observation worth keeping verbatim from the audit:
C-M-s` opens all folds, while opening a file, opening a terminal, and
running a build have no bindings at all.
Two of that observation's three examples have since been answered —
opening a file by `C-x C-f` (#162) and opening a terminal by `C-c t`
(#173). **Running a build still has no binding**, and the underlying
inversion is a standing bias in how new work gets bound, not three
isolated omissions: the quote stays as written because it names the
pattern, and the pattern is not retired until step 9 is.
---
## 3. A Strong Zero-Configuration State
@ -460,7 +476,7 @@ level is the one missing. Audited level-by-level:
**Beginner** (should see: files, buffers, search, diagnostics, terminal,
build actions, menus, missing-tool guidance):
- files ✓ since #162 / PR #165 (`C-x C-f` opens a path, `C-x d` browses;
- files ✓ since #162 / #165 (`C-x C-f` opens a path, `C-x d` browses;
neither is advertised anywhere but the keymap) · 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
@ -639,7 +655,7 @@ Everything funnels through one function: `EditorInstance::dispatch_key`
| 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** |
| 6 | Terminal transport + configurable escape | `editor.rs:973-1010` | `EditorState::terminal_escape_chord``TerminalManager::escape_chord` (`src/terminal/session.rs`) | **partial, transport-level** |
| 7 | Ordinary dispatch | `editor.rs:1018-1032` | `KeymapStack::resolve` | the only inspectable layer |
Facts that define the gap:
@ -647,8 +663,29 @@ 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.
bindings led by the escape chord are **structurally unreachable** in
a terminal buffer. Since #173 that chord is `terminal.escape-key`
rather than a hardcoded `C-c`, so a user can *move* which prefix is
eaten; they cannot make the shadow stop eating one.
- **A worked example that a modal-*looking* feature need not become a
shadow.** Terminal copy mode (Stage 2 of the terminal-config arc) is
the case that most invited a seventh rung: it wants motion, search and
its own `g`/`q` inside a surface where every unescaped key otherwise
goes to a child process. It resolves to the buffer-local keymap idiom
instead, by **materializing** the retained scrollback into an ordinary
read-only document buffer. The keys-must-not-reach-the-child problem
then dissolves structurally rather than being guarded: the transport
arm keys on `is_terminal(buffer_id)`, and a snapshot buffer is not a
terminal, so the arm never fires. No new precedence rung, no new
hand-synced guard-list entry, and `describe-key` keeps reporting the
truth — pinned by asserting exactly that for the snapshot's `g` and
`q`, which is the observable difference between the idiom and a
shadow. **The count stays at six.**
The transferable rule: when a feature wants a keymap over *content*,
ask whether the content can become a buffer. The shadows that exist
are the cases where it genuinely cannot (a minibuffer prompt, a
live search prompt) — not the cases where nobody tried.
- **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 →
@ -1013,20 +1050,45 @@ layering, provenance, and adoption have not followed.**
`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),
- **Adoption is nine 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
(window.lua), `terminal.default-profile` +
`terminal.scrollback-rows` + `terminal.escape-key` (terminal.lua,
#173), and `lean.abbrev` (lean_input.lua, Arc 8 Stage 4b) — a
`live` boolean read against the typed edit's SOURCE buffer, the
`editing.auto-pair` shape including its correction to resolve
`rec.buffer` rather than the active buffer. 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).
- **The table-valued gap now has a named, shipped instance.**
`pmacs.terminal.profiles` (#173) is a raw Lua table sitting beside
three registered scalars *for the same feature*, because a profile is
inherently `{ command, args, cwd, env }` and the registry stores four
scalars. It is the clearest evidence yet that table-valued settings
are the blocking prerequisite: the terminal is now half-registered,
and no settings UI can render the half that matters most.
- **The missing `scope = "global"` flag has its second live case.** After
`autosave.interval-ms`, the terminal's two *open-time* settings —
`terminal.default-profile` and `terminal.scrollback-rows` — are read
before their terminal's identity buffer exists, so a buffer-local
override can never be consulted. The registry accepts `set_local` on
them anyway, because `Live` mutability is all it can express. Nothing
breaks; the setting simply has no effect, which is the worst shape a
configuration surface can take. `terminal.escape-key` is the contrast
that shows this is a real distinction rather than a blanket wish: it
*deliberately* supports buffer-locals, and per-terminal escapes are a
feature. So the argument for both deferrals is now **cumulative and
concrete** rather than hypothetical — two adopters, two distinct
missing primitives, one feature.
- **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
hooks+keymaps / packages), but with nine 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.
@ -1164,7 +1226,31 @@ Primitive-by-primitive against the list above:
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.
and shell-command. **Caveat found in terminal copy mode's review
(Stage 2): "intercept-read-only" is not read-only.** `Buffer::undo`
reaches the rope through `ensure_writable` without consulting the
intercept chain, so `M-x buffer.undo` empties such a buffer — and
rebinding the undo *chords* buffer-locally does not close it, as
`compile.lua`'s own comment admits ("command/menu undo stays
dispatchable"). `Buffer::set_generated_contents` (write + discard
history + assert `read_only`, in one authorized call) now fixes this
for the terminal snapshot; **four writer mechanisms have not yet adopted
it and remain emptiable** — listview panels; `compile.lua`'s
`ensure_slot`, which serves `*compilation*` **and** `*shell-command*`;
the independent `*search-results*` panel in
`builtin/commands/default.lua`; and dired buffers. All pair an erroring
intercept with `bypass_intercept` writes over a still-writable rope.
(`*workers*`, `*help*` and `*buffer-list*` are generated but do not use
this idiom.) **A second half of the same
caveat, found in round 3: a rope write is only half of an edit.** The
owner-authorized write must be fanned out to the windows showing the
buffer and queued for replica mirrors, or the displaying window keeps
a line index describing the previous contents and the next paint
indexes the new rope with stale ranges. Adoption is therefore not a
one-line swap — and the three appending buffers (`*compilation*`,
`*shell-command*`, `*search-results*`) need a streaming variant of the
primitive that does not exist yet. Listview and dired already write
whole-buffer replaces and are the cheap half.
- **Diagnostics collection** ✓ — `DiagnosticStore` + signs + unified
`error.next` source.
- **Transient selector** ✓ — the minibuffer (though its `source`
@ -1183,7 +1269,7 @@ Primitive-by-primitive against the list above:
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. Dired Stage 1 (PR #165) landed **without** inventing
section's point. Dired Stage 1 (merged #165) landed **without** inventing
one: its listing is flat (Emacs parity), and the recursive
in-buffer case — `i` insert-subdirectory — is a named deferral in
`docs/dired-framing.md` §13, which is where a shared tree primitive
@ -1254,9 +1340,13 @@ facto privileged implementation.
**Grade: strong — the healthiest concern in this document, and most of
its asks are already practiced.**
- Versioned, negotiated protocol `SUPPORTED=[6..=20]` with deliberate
- Versioned protocol schema `SUPPORTED=[6..=21]` with deliberate
encoding-breaking bumps, both-frontends support required per bump,
and byte-pin discipline for appended variants (handoff §4).
and byte-pin discipline for appended variants (handoff §4). The v21
bottom-panel family is reserved but dark in Stage 2B-1: because
`Hello` is server-first, the production daemon still advertises v20
so shipped v20 clients remain attachable; compatible v21 activation
belongs to Stage 2B-3.
- 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**:
@ -1347,7 +1437,8 @@ 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
lead somewhere broken or invisible (find a file — the mechanism is fixed
since #162/#165 but is advertised nowhere except the keymap; 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
@ -1377,20 +1468,24 @@ subsystems, complementing (not replacing) subsystem tests:
### Ground truth
**Grade: missing — but the culture that would make them excellent is the
project's strongest process asset.**
**Grade: started — the first suite exists; five of the six scenarios
above do not.**
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.
At audit time zero cross-subsystem journey tests existed. **Journey
Stage 1a created `tests/journey_acceptance.rs`**, the §2 journey itself,
seeded with steps 2 (launch unconfigured), 3 (open a real project), and
5 (edit immediately), and declared a ratchet: stages add rows, none
removes them. That is the "first launch" scenario, partially — missing
tools still have no actionable guidance to assert.
The rest is unchanged. Every other acceptance suite in the tree pins one
subsystem's contract (superbly — bite-verified, falsified-by-revert,
vacuity-checked). Command discovery, workspace lifecycle, worker
ownership, package lifecycle, and remote execution have no
cross-subsystem suite; several remain *untestable* because the behavior
doesn't exist (install in-session, disable). Steps 612 join
`journey_acceptance.rs` as later stages make them 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*
@ -1408,14 +1503,18 @@ 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 (the remaining half of step 3 —
dired Stage 1 landed the buffer it should resolve to); a find-file
surface (**done**: #162 open-by-path, PR #165 browsing); surfacing the
LSP spawn failure with guidance (§1.2); a
blockers. **State: runs to step 5; thin from step 6 (§2). Mostly wiring,
and unusually cheap:** directory-argument handling (**done**: Journey
Stage 1a); a find-file surface (**done**: #162 open-by-path, #165
browsing); 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.
`ProjectKind::Cargo`; a terminal keybinding (**done**: `C-c t`, #173); a
welcome buffer. The journey acceptance suite (§19) is the ratchet that
keeps it fixed — it **exists now** (`tests/journey_acceptance.rs`,
Stage 1a), seeded with steps 2, 3, and 5.
Journey Stage 1b is the named remainder: the compile binding + Cargo
defaults, LSP spawn guidance, and the welcome buffer.
### Priority 2: Make workspace and location explicit
@ -1479,9 +1578,13 @@ 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.
1. **Journey Stage 1** (P1): split at the new-Rust-primitive line.
**Stage 1a — landed**: directory open, the `EditorState::open`
`resolve_target_buffer` unification, the destination-scope substrate,
and the first journey acceptance suite. It routes `pmacs .` into
#165's dired buffer rather than growing a second directory surface.
**Stage 1b — remaining**: compile defaults, LSP-failure surfacing,
bindings, welcome buffer.
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

View File

@ -61,6 +61,21 @@ define {
kind = "all-must-succeed",
}
define {
name = "path.open-directory",
description = "Fired when a directory path is opened (Journey Stage 1a). " ..
"Receives the canonical absolute path and an opaque " ..
"destination. Return false to CLAIM the directory and stop " ..
"the fan-out; return nothing to decline. No builtin " ..
"subscribes -- because hook callbacks only ever append, a " ..
"subscribing builtin would always claim before any user " ..
"listener could run, so this hook is the user's chain and " ..
"pmacs.path.directory_handler is the default surface it " ..
"falls back to. A callback that RAISES stops the chain and " ..
"suppresses that fallback.",
kind = "short-circuit",
}
define {
name = "editor.before-quit",
description = "Fired before the editor exits. Return false to veto.",

View File

@ -77,6 +77,17 @@ end
-- inside a coroutine spawned by pmacs.async --- a bare call from main
-- thread will raise on the first yield.
function Handle:await()
-- Journey Stage 1a (Q#JR14b): `pmacs.window.commit_to` scopes the
-- acting frontend for the dynamic extent of its callback, using an
-- RAII guard on the Rust stack. Yielding out of that extent would
-- restore the scope while this coroutine is still parked, so the rest
-- of the commit would resume ambient -- silently reintroducing the
-- misrouting the scope exists to prevent. Do the awaiting BEFORE
-- entering the commit, which is what dired does with its listing.
if async_mod._in_commit_scope() then
error("await: cannot await inside pmacs.window.commit_to; " ..
"await first, then commit")
end
if not async_mod._is_complete(self._id) then
-- Yield self so pmacs.async's step() can park us. R46 carve-out:
-- this `coroutine.yield` is runtime code; package code uses

View File

@ -571,7 +571,15 @@ end
-- deliberately so (Q#DR10): the next directory is the same kind of
-- thing as the current one and belongs in the same slot, while a file
-- is not a dired buffer and belongs in the document area.
local function display(handle, opts, departed)
--
-- `captured` (Journey Stage 1a, Q#JR14) is the destination window a
-- background open must land in. It is NOT the same as "wherever the
-- scoped frontend is looking now": the scope fixes the *frontend*, and
-- within one frontend the selected window can still have moved to
-- another split while the listing was in flight. The preflight cannot
-- catch that -- the captured window is still live and still holds its
-- captured buffer -- so honoring it is this function's job.
local function display(handle, opts, departed, captured)
local side = nil
if departed ~= nil then
-- Dired's own window, not the request's: walking a tree in a side
@ -587,6 +595,11 @@ local function display(handle, opts, departed)
-- both the substrate's documented policy and Emacs's, so dired does
-- not try to unpin the user's panel.
pmacs.window.display(handle.buf, { side = side, select = true })
elseif captured ~= nil then
-- `select = true` because the rest of the commit -- seat_cursor via
-- `pmacs.editor.move_to_line` -- acts on the frontend's ACTIVE
-- window, so the seat would land in the wrong window otherwise.
pmacs.window.display(handle.buf, { window = captured, select = true })
else
pmacs.window.switch_buffer(handle.buf)
end
@ -598,7 +611,7 @@ end
pmacs.dired = pmacs.dired or {}
local OPEN_OPTS = { display = true, select_name = true }
local OPEN_OPTS = { display = true, select_name = true, dest = true }
-- Open `path`'s dired buffer, replacing `departed` (a handle) in the
-- window it occupies when this is a navigation rather than a fresh
@ -629,36 +642,76 @@ local function open_directory(path, opts, departed)
local sort_mode = (handle_for_path(canonical) or {}).sort_mode or SORT_MODES[1]
local entries, errors = read_listing(canonical, sort_mode)
local handle = claim_handle(canonical)
handle.entries = entries
handle.errors = errors
handle.sort_mode = sort_mode
-- Everything from here down MUTATES: it claims or finds a handle,
-- creates a buffer, reads the ambient buffer for `prev`, and paints.
-- None of it is undoable, and none of it may run against a
-- destination that has gone away -- so when the caller captured one
-- (Journey Stage 1a, Q#JR14), the whole commit runs inside
-- `pmacs.window.commit_to`, which validates the destination BEFORE
-- invoking this and scopes the acting frontend for its extent.
--
-- Note the await above is deliberately OUTSIDE the commit: awaiting
-- inside it is refused (Q#JR14b), because a yield would restore the
-- scope while this coroutine is still parked.
local function commit()
-- The captured window, read once. Everything below that would
-- otherwise consult "the active window" must consult THIS instead:
-- the scope pins the frontend, not the selected window, and a split
-- or panel can take focus within that frontend while the listing is
-- in flight (Q#JR14).
local captured = opts.dest ~= nil and opts.dest:window() or nil
-- `q` returns to the buffer you came from, never to another dired
-- buffer (which would trap `q` walking back down the tree); on a
-- descent the arriving buffer inherits the departing one's origin.
if departed ~= nil then
handle.prev = departed.prev
else
local active = pmacs.window.buffer()
if active ~= nil and handle_for_buffer(active) == nil then
handle.prev = active
local handle = claim_handle(canonical)
handle.entries = entries
handle.errors = errors
handle.sort_mode = sort_mode
-- `q` returns to the buffer you came from, never to another dired
-- buffer (which would trap `q` walking back down the tree); on a
-- descent the arriving buffer inherits the departing one's origin.
if departed ~= nil then
handle.prev = departed.prev
else
local active
if captured ~= nil then
active = pmacs.window.buffer(captured)
else
active = pmacs.window.buffer()
end
if active ~= nil and handle_for_buffer(active) == nil then
handle.prev = active
end
end
paint(handle)
display(handle, opts, departed, captured)
-- Seating happens after the display: `switch_buffer` zeroes the
-- window cursor, so an earlier seat would be discarded.
seat_cursor(handle, opts.select_name, 1)
kill_departed(departed, handle)
return handle.buf
end
paint(handle)
display(handle, opts, departed)
-- Seating happens after the display: `switch_buffer` zeroes the
-- window cursor, so an earlier seat would be discarded.
seat_cursor(handle, opts.select_name, 1)
kill_departed(departed, handle)
return handle.buf
if opts.dest == nil then
-- Interactive path (`C-x d`, tree descent, refresh): the acting
-- frontend is still ambient a tick later, which is what dired has
-- always relied on. Migrating these onto a captured destination too
-- is a named deferral, not this stage's work.
return commit()
end
local ok, result = pmacs.window.commit_to(opts.dest, commit)
if not ok then
error(string.format("destination is gone (%s)", tostring(result)))
end
return result
end
function pmacs.dired.open(path, opts)
return open_directory(path, opts, nil)
end
-- Every interactive entry point funnels through here: spawn the
-- coroutine the await needs, and turn a failure into a status message
-- rather than an uncaught raise inside `pmacs.async` (which would land
@ -670,6 +723,20 @@ local function open_async(path, opts, departed, where)
end)
end
-- Journey Stage 1a (Q#JR7): dired is the DEFAULT directory surface, not
-- a `path.open-directory` subscriber.
--
-- It cannot be a subscriber and still be replaceable. `HookRegistry.add`
-- only appends, and builtins load before `init.lua`, so a dired
-- subscription would always run first and always claim -- no user
-- listener could ever win. The hook is therefore the user's chain and
-- this slot is the fallback the editor consults when that chain
-- declines. Replace it to change what opens a directory; set it to nil
-- to disable directory opening entirely.
pmacs.path.set_directory_handler(function(path, dest)
open_async(path, { dest = dest }, nil, "dired")
end)
-- ---------------------------------------------------------------------------
-- Commands
-- ---------------------------------------------------------------------------

View File

@ -325,4 +325,28 @@ function fs.watch(path, callback, opts)
return watch
end
-- pmacs.fs.canonicalize(path) -> string | nil
--
-- Arc 8 Stage 3a (framing Q#LN20). The **only synchronous** function on
-- this module, and deliberately so: its consumer is a function-valued
-- `pmacs.lsp.config[lang].root`, invoked from `ensure_server` <-
-- `attach_buffer` <- the `buffer.after-load` hook, where there is no
-- coroutine and therefore nothing to `:await()` on. Every other
-- primitive here returns a Handle; this one cannot, or it would be
-- unusable at the one call site that needs it — the same trap
-- `pmacs.fs.stat` falls into for that caller.
--
-- Resolves symlinks and `.` / `..`, returning an absolute path, or nil
-- if the path does not exist or cannot be resolved. Nil is a normal
-- answer, not an error: callers routinely ask about paths that may have
-- been deleted.
--
-- Why it exists: a configured LSP root reaches `file_uri_for` verbatim
-- and that URI is the server-affinity key (PR #161), so one project
-- opened through a symlink and through its real path would otherwise
-- spawn two servers. `pmacs.editor.file_path()` collapses `.` and `..`
-- lexically but leaves symlinks intact, so the resolver cannot get a
-- canonical path any other way.
fs.canonicalize = pmacs._fs.canonicalize
pmacs.fs = fs

750
builtin/runtime/lean.lua Normal file
View File

@ -0,0 +1,750 @@
-- builtin/runtime/lean.lua --- Arc 8 Stage 3b: the Lean 4 language server.
--
-- Framing: `docs/lean4-mode-framing.md` Q#LN7 (lake serve + probe +
-- fallback latch), Q#LN8 (Lake-aware outermost root), Q#LN16
-- (waitForDiagnostics). Stage 1 shipped the grammar, mode, comment
-- strings and pair set; Stage 3a shipped the notification/response
-- seams and `pmacs.fs.canonicalize` this file consumes.
--
-- Loaded after `lsp.lua`, which owns `pmacs.lsp.config` and the drain.
local M = {}
-- Q#LN8 — the Lake-aware root -----------------------------------------
--
-- `pmacs.project.detect` cannot express this rule. It is innermost-wins
-- by construction, and a Lake package's `lean-toolchain` sits at the
-- OUTERMOST level: a file under `<pkg>/.lake/packages/dep/Foo.lean`
-- belongs to `<pkg>`'s server, not to `dep`'s, because `lake serve` is
-- bound to one package and analyzes its dependencies from inside it.
-- Inverting `detect` globally would change Rust/Go/Node roots for every
-- user, so the rule lives here as a function-valued `config.root` —
-- the generalization Stage 2 (#161) added for exactly this.
-- The marker test, and the two ways to get it wrong.
--
-- `pmacs.fs.stat` is UNUSABLE here: it returns an awaitable handle
-- (`fs.lua`), and this runs synchronously inside `ensure_server` <-
-- `attach_buffer` <- the `buffer.after-load` hook, where there is no
-- coroutine to await on. The Lua stdlib's `io.open` is the only
-- synchronous existence check available.
--
-- But `io.open` alone is wrong in BOTH directions:
-- * it SUCCEEDS on a directory (probed), so a truthiness test would
-- accept a `lean-toolchain` directory as a marker; and
-- * requiring a non-nil read rejects an EMPTY `lean-toolchain`, which
-- is a legitimate marker — `locate-dominating-file` semantics are
-- existence, not content.
-- The discriminator is `read`'s SECOND return (probed on LuaJIT 2.1):
-- file with content -> "l", no error -> marker
-- empty file -> nil, NO error -> marker
-- directory -> nil, "Is a directory" -> decline
-- missing -> io.open returns nil -> decline
-- so: decline only on a non-nil `err`. This needs no per-platform
-- re-probe, because both directory behaviors are declines — a platform
-- whose `fopen` refuses directories fails at `io.open` instead. There
-- is no platform where a directory both opens and yields a byte.
local function has_toolchain(dir)
local f = io.open(dir .. "/lean-toolchain", "r")
if not f then return false end
local _, err = f:read(1)
f:close()
return err == nil
end
local function parent_of(dir)
local up = dir:match("^(.*)/[^/]+$")
if up == nil or up == dir or up == "" then return nil end
return up
end
-- The walk stops at `pmacs.project.search_boundary()`. Not politeness:
-- `detect_project_within` (`src/project.rs`) exists precisely so a
-- stray marker above a temp fixture cannot leak into detection, and a
-- Lua walk that ignored the boundary would break that contract — and
-- make acceptance 23's outermost assertion non-hermetic against any
-- `lean-toolchain` sitting above the test's tempdir.
local function within_boundary(dir, boundary)
if not boundary then return true end
return dir == boundary or dir:sub(1, #boundary + 1) == boundary .. "/"
end
-- Returns the OUTERMOST ancestor holding a `lean-toolchain`, or nil to
-- decline (which falls through to `pmacs.project.detect`, then the
-- file's own directory).
--
-- **The result is canonical, and must be.** A configured root — which
-- this is — reaches `file_uri_for` verbatim and that URI is the
-- server-affinity key (#161). `pmacs.editor.file_path()` collapses `.`
-- and `..` lexically but leaves symlinks intact, so one package opened
-- through a symlink and through its real path would otherwise spawn two
-- `lake serve` processes. Canonicalizing ONCE up front is enough:
-- every ancestor of a canonical path is itself canonical, since the
-- walk only strips trailing components.
--
-- If canonicalization fails (deleted file, broken symlink) the resolver
-- declines rather than returning a path it cannot vouch for.
function M.root_for(path)
if type(path) ~= "string" then return nil end
local dir = path:match("^(.*)/[^/]*$")
if not dir then return nil end
dir = pmacs.fs.canonicalize(dir)
if not dir then return nil end
local boundary
local ok, b = pcall(pmacs.project.search_boundary)
if ok then boundary = b end
-- The boundary is canonicalized at set time (`set_search_boundary`),
-- so comparing it against a canonical `dir` is apples to apples.
local outermost = nil
local cur = dir
while cur and within_boundary(cur, boundary) do
if has_toolchain(cur) then outermost = cur end
cur = parent_of(cur)
end
return outermost
end
-- Q#LN7 — `lake serve`, with a lazy probe and a one-shot latch --------
--
-- `pmacs.lsp.config.lean4` is declarative and must stay cheap: spawning
-- a process at startup for every user, Lean-using or not, is the cost
-- rev 1 refused. So no probe runs here — it runs on the first `.lean`
-- attach, below.
pmacs.lsp.config.lean4 = pmacs.lsp.config.lean4 or {
command = "lake",
args = { "serve" },
root = M.root_for,
-- No `init_options`: `hasWidgets?` defaults to false, which is the
-- correct posture for a client reading plain goals out of standard
-- messages rather than driving the `$/lean/rpc/*` widget stack.
}
-- Session state. The latch is one-shot and never re-arms: a user whose
-- toolchain is broken sees one fallback attempt, not a loop.
local probe = {
started = false, -- the `lake --version` probe has been spawned
latched = false, -- the fallback has fired (or been ruled out)
proc = nil, -- process id of the running probe
out = "", -- accumulated probe stdout
buf_key = nil, -- tostring() of the buffer that started this
watching = nil, -- sid still being polled for die-before-initialize
primary = nil, -- sid the probe's verdict applies to; NOT cleared
-- when the server initializes, because a late
-- version verdict still has to retire it
armed = false, -- the target buffer + primary have been captured
repaired = {}, -- buffer key -> repair attempted (at most once)
repair_attempts = 0, -- COUNT of attach attempts, not distinct buffers:
-- table cardinality cannot tell "once per buffer"
-- from "every tick for one buffer"
fallback_installed = false,
fallback_watches = {}, -- sid key -> sid, each polled die-before-init
fallback_done = {}, -- sid key -> initialized or terminally handled
saw_initialized = false,
}
-- The command as configured, for status text. Hardcoding "lake serve"
-- was untruthful the moment the failure latch became command-agnostic:
-- a user whose `my-lean-wrapper` failed was told `lake serve` did.
local function configured_command()
local cfg = pmacs.lsp.config.lean4
local cmd = cfg and cfg.command
if not cmd then return "the Lean server" end
local args = cfg.args or {}
if #args > 0 then
return "`" .. tostring(cmd) .. " " .. table.concat(args, " ") .. "`"
end
return "`" .. tostring(cmd) .. "`"
end
-- The fallback command, for status text.
local function fallback_name()
local args = M._fallback.args or {}
if #args > 0 then
return "`" .. tostring(M._fallback.command) .. " "
.. table.concat(args, " ") .. "`"
end
return "`" .. tostring(M._fallback.command) .. "`"
end
local function report(msg)
-- COHERENCE §1.2: background work must leave an attributed trace.
-- `pmacs.editor.set_status` is the channel that EXISTS; `pmacs.error`
-- is referenced by fifteen call sites and defined nowhere in
-- production, so it rides along rather than standing alone.
pcall(pmacs.editor.set_status, msg)
if pmacs.error then pcall(pmacs.error, msg) end
end
-- `lake serve` below 3.1.0 starts a server that cannot answer, which is
-- worse than failing: `lean4-mode` probes for exactly this and falls
-- back to `lean --server`. Parses the leading `x.y` of a version line.
-- State kind for the server whose `tostring(id)` is `skey`, or nil if
-- the manager has forgotten it (which is itself a terminal answer).
local function server_state_kind_for_key(skey)
local ok, rows = pcall(pmacs.lsp.list)
if not ok or not rows then return nil end
for _, info in ipairs(rows) do
if tostring(info.id) == skey then
return info.state and info.state.kind
end
end
return nil
end
local function server_state_kind(sid)
return server_state_kind_for_key(tostring(sid))
end
local function version_below_3_1(text)
local major, minor = text:match("(%d+)%.(%d+)")
if not major then return false end
major, minor = tonumber(major), tonumber(minor)
if major < 3 then return true end
return major == 3 and minor < 1
end
-- What the latch falls back TO.
--
-- **Underscored: a test seam, not supported user configuration.** It is
-- a table only so the acceptance suite can point it at a stand-in server
-- and drive the real latch path end to end, instead of asserting on a
-- config mutation that proves nothing about whether a server ever
-- starts. Presenting it as public config would owe framing,
-- documentation, validation and mutation semantics that nothing here
-- provides; users configure Lean through `pmacs.lsp.config.lean4`.
M._fallback = { command = "lean", args = { "--server" } }
local function same_args(a, b)
a, b = a or {}, b or {}
if #a ~= #b then return false end
for i = 1, #a do
if a[i] ~= b[i] then return false end
end
return true
end
-- Swap `command`/`args` ONLY. A wholesale table replacement would
-- silently discard a user's `env` / `settings` / `init_options` / `root`
-- from `init.lua` at exactly the moment they are least likely to notice.
--
-- The only guard is idempotence — already-the-fallback means nothing to
-- do. It deliberately does NOT refuse when the command is user-supplied:
-- the latch fires only when the configured Lean server actually failed
-- to start, and one visible fallback attempt beats leaving the user with
-- no server at all. `probe.latched` is what keeps it to exactly one.
local function swap_to_fallback()
local cfg = pmacs.lsp.config.lean4
if not cfg then return false end
-- Idempotence compares command AND args: the same command with
-- different arguments is not "already applied", and treating it as
-- such would silently skip a swap that still needed to happen.
if cfg.command == M._fallback.command
and same_args(cfg.args, M._fallback.args) then
return false
end
cfg.command = M._fallback.command
cfg.args = M._fallback.args
return true
end
-- Retire the failed server, swap the command, then rebuild the
-- attachment on the buffer that started this.
-- Retire `sid` so it cannot come back. **Which call to use depends on
-- the state, and using the wrong one is worse than doing nothing:**
--
-- * TERMINAL (`crashed` / `stopped`) -> `forget`. It requires a
-- terminal state and removes the client outright, which also drops
-- the `next_restart_at` the crash scheduled. `stop` here would take
-- its not-initialized branch and set `ShuttingDown { .. None }` on
-- the premise that "the next exit observation cleans up" — but the
-- exit already happened, which is what made it `Crashed`. No
-- further event arrives, so it sits in `ShuttingDown` forever:
-- `server_is_live` reads that as LIVE so `attach_buffer` never
-- rebuilds, and `forget` then refuses it for not being terminal.
-- * NON-TERMINAL -> `stop`. `forget` rejects it, and `stop` disables
-- restart and drives the polite shutdown.
--
-- Round 1 skipped the call entirely for terminal servers. That avoided
-- the corruption but left `next_restart_at` armed, so the crashed
-- primary respawned 500ms later and kept respawning underneath the
-- live fallback — invisible to a test that stopped ticking first.
local function retire_server(sid)
local kind = server_state_kind(sid)
if kind == nil then return end
if kind == "crashed" or kind == "stopped" then
pcall(pmacs.lsp.forget, sid)
else
pcall(pmacs.lsp.stop, sid)
end
end
-- Retire EVERY Lean server, not just the one that failed.
--
-- `pmacs.lsp.config.lean4` is a single global entry, so swapping its
-- command invalidates every server spawned from the old one — and
-- Q#LN15 gives one server per project root, so there can be several.
-- Retiring only the server that happened to fail left the others live
-- and every buffer attached to them stranded on a command the config no
-- longer names.
-- Only servers the config-driven path itself produced. A server's label,
-- language, command, and root are all caller-supplied public values; none
-- is an ownership discriminator. `lsp.lua` records the successful spawn
-- in a private origin table, which is the fact this lifecycle may act on.
local function is_derived_server(sid)
local ok, owned = pcall(pmacs.lsp._is_default_server, sid, "lean4")
return ok and owned == true
end
local function retire_derived_lean_servers()
local ok, rows = pcall(pmacs.lsp.list)
if not ok or not rows then return end
local ids = {}
for _, info in ipairs(rows) do
if is_derived_server(info.id) then ids[#ids + 1] = info.id end
end
for _, id in ipairs(ids) do
-- These ids predate the fallback spawn. Mark them handled before
-- retirement so the discovery poll cannot mistake their terminal
-- state for a fallback that failed to initialize.
probe.fallback_done[tostring(id)] = true
retire_server(id)
end
end
local function watch_fallback_server(sid)
if not sid or not is_derived_server(sid) then return end
local key = tostring(sid)
if probe.fallback_done[key] then return end
probe.fallback_watches[key] = sid
end
-- Rebuild the ACTIVE buffer's attachment if it is Lean and stale.
--
-- `_attach_buffer` is an active-buffer-only seam, so a global config
-- swap cannot be applied to every open buffer at once. It is applied
-- lazily instead: whenever a Lean buffer becomes the active one, if its
-- record points at a server that is gone or terminal, it is rebuilt.
--
-- **At most one attempt per buffer.** Without that bound a fallback
-- that also fails to spawn would retry every tick forever with nothing
-- reported — the round-2 defect, which a general repair loop would
-- otherwise reintroduce for every buffer instead of just one.
--
-- A `shutting-down` server is deliberately NOT treated as stale: it is
-- still live by `server_is_live`'s reckoning, so `attach_buffer` would
-- early-return the stale record and burn this buffer's single attempt
-- on a no-op. Skipping leaves the attempt for a later tick, once the
-- retirement has actually landed.
local function repair_active_if_stale()
-- **`fallback_installed`, not `latched`.** When the swap does not
-- happen — the config already names the fallback, or it vanished
-- before an asynchronous verdict landed — `fire_latch` returns early
-- but `latched` stays true. Gating repair on `latched` then retried
-- the UNCHANGED configuration and reported the result as a fallback
-- failure, which is both a second pointless spawn and a misleading
-- message. Repair exists to apply a swap; no swap, nothing to apply.
if not probe.fallback_installed then return end
local buf = pmacs.window.buffer()
if not buf then return end
local key = tostring(buf)
if probe.repaired[key] then return end
local ok_lang, lang = pcall(pmacs.lsp.buffer_language, buf)
if not ok_lang or lang ~= "lean4" then return end
local rec = pmacs.lsp.active_attachment()
local stale
if not rec then
stale = true
else
local kind = server_state_kind(rec.server)
stale = (kind == nil or kind == "crashed" or kind == "stopped")
end
if not stale then return end
probe.repaired[key] = true
probe.repair_attempts = probe.repair_attempts + 1
local ok, fresh = pcall(pmacs.lsp._attach_buffer)
if not ok or not fresh then
report("LSP: lean4 fallback " .. fallback_name()
.. " did not start either")
return
end
-- **A successful SPAWN is not a successful START.** The once-per-
-- buffer bound stops `_attach_buffer` being called again, but it says
-- nothing about the server it produced. Arm this id immediately; the
-- poll below also discovers servers created through lsp.lua's own
-- after-load and command paths.
watch_fallback_server(fresh.server)
end
-- Every fallback server gets its own die-before-initialize poll. A scalar
-- watch cannot cover Q#LN15's simultaneous per-root servers, and a server
-- may be created by lsp.lua's after-load or command path without passing
-- through `repair_active_if_stale`. Discovery from the private ownership
-- table closes both holes.
local function poll_fallbacks()
if not probe.fallback_installed then return end
local ok, rows = pcall(pmacs.lsp.list)
if not ok or not rows then return end
local by_key = {}
for _, info in ipairs(rows) do
local key = tostring(info.id)
by_key[key] = info
if not probe.fallback_done[key] and is_derived_server(info.id) then
probe.fallback_watches[key] = info.id
end
end
for key, sid in pairs(probe.fallback_watches) do
local info = by_key[key]
local kind = info and info.state and info.state.kind
if kind == "initialized" then
probe.fallback_watches[key] = nil
probe.fallback_done[key] = true
elseif info == nil or kind == "crashed" or kind == "stopped" then
probe.fallback_watches[key] = nil
probe.fallback_done[key] = true
if info ~= nil then retire_server(sid) end
report("LSP: lean4 fallback " .. fallback_name()
.. " started but did not stay up")
end
end
end
local function fire_latch(sid, why)
if probe.latched then return end
probe.latched = true
probe.watching = nil
if not swap_to_fallback() then
report("LSP: lean4 " .. why)
-- No shared config changed, so only the server whose failure
-- triggered this verdict is invalid. Sweeping every root here stops
-- healthy instances of a root-sensitive command for no reason.
if sid and is_derived_server(sid) then retire_server(sid) end
return
end
retire_derived_lean_servers()
probe.fallback_installed = true
report("LSP: lean4 " .. why .. "; falling back to " .. fallback_name())
-- Repair what is in front of the user now; everything else is
-- repaired lazily as it becomes active (see `repair_active_if_stale`).
repair_active_if_stale()
end
local function drain_probe()
if not probe.proc then return end
local ok, evs = pcall(pmacs.process.events_take, probe.proc)
if not ok or not evs then return end
for _, ev in ipairs(evs) do
if ev.kind == "stdout" or ev.kind == "stderr" then
probe.out = probe.out .. tostring(ev.bytes)
elseif ev.kind == "exited" or ev.kind == "signaled"
or ev.kind == "crashed" then
local proc = probe.proc
probe.proc = nil
pcall(pmacs.process.forget, proc)
-- A non-zero exit is NOT a fallback trigger on its own. §2.9: elan
-- shims make `lake --version` exit non-zero with "no default
-- toolchain configured" on a machine where `lake serve` may still
-- be the right command — the server-failure latch covers that
-- case, and covers it better. The probe answers only the ONE
-- question failure detection would otherwise answer slowly: an
-- old-but-working lake that starts a useless server.
-- **`probe.primary`, NOT `probe.watching`.** `watching` is
-- failure-polling state and is cleared the moment the server
-- initializes. A slow `--version` that lands after a successful
-- initialize would then arrive with nil, and `fire_latch(nil)`
-- retires nothing: `_attach_buffer` finds the still-live primary
-- attachment, early-returns it, and the retry calls that success.
-- Status and config would say "fell back" while the buffer stayed
-- on the old server — the same silent no-op as round 1, reached
-- through a different event ordering. Initializing must stop the
-- failure poll, not erase the server the verdict has to retire.
if ev.kind == "exited" and ev.code == 0
and version_below_3_1(probe.out) then
fire_latch(probe.primary, "lake is older than 3.1.0")
end
end
end
end
-- The probe cannot gate the first attach. There is no blocking process
-- run (§2.9): `spawn` + `events_take` off a tick is the only shape
-- available, so the verdict arrives AFTER `ensure_server` has already
-- had to decide. Hence the optimistic `lake serve` spawn, with the
-- probe and the latch correcting it.
local function start_probe(root)
if probe.started then return end
probe.started = true
local cfg = pmacs.lsp.config.lean4
if not cfg or not cfg.command then return end
-- **Only probe something actually named `lake`.** `version_below_3_1`
-- parses the first `x.y` it finds anywhere in the output, which is a
-- rule about LAKE's output contract and nothing else. Run against a
-- user's wrapper it is a category error: a working `my-lean-wrapper`
-- reporting "wrapper 1.0" would be replaced despite its server having
-- initialized fine. The FAILURE latch stays command-agnostic — that
-- one keys on the server actually not starting, which is true of any
-- command — but the version rule only applies where its contract
-- holds.
local base = cfg.command:match("([^/]+)$") or cfg.command
if base ~= "lake" then return end
-- Probe the binary we would actually run, not the literal string
-- "lake": a user pointing `command` at an absolute path to lake should
-- have THAT probed, not whatever `lake` resolves to on PATH.
local spec = {
-- COHERENCE §9: `ProcessSpec.label` is the only identity a process
-- carries, and it is what `pmacs.process.list` renders. A user
-- wondering why their editor touched `lake` finds an owner here.
label = "lean:lake-version-probe",
command = cfg.command,
args = { "--version" },
stdin = "null",
}
if root then spec.cwd = root end
local ok, proc = pcall(pmacs.process.spawn, spec)
if ok then probe.proc = proc end
-- A probe that cannot even spawn says nothing the latch will not say
-- more reliably a moment later, so it is not reported here.
end
-- How the latch observes server failure.
--
-- There is no event for "died before initialize" — the drain ignores
-- state events. So this polls `pmacs.lsp.list()` on the
-- `process.after-tick` cadence and treats a terminal state reached
-- WITHOUT an intervening `initialized` as the trigger. Watching stops
-- as soon as the server initializes, so an ordinary later crash (a real
-- server dying on a real error) does not silently rewrite the command.
local function poll_latch()
local sid = probe.watching
if not sid or probe.latched then return end
local skey = tostring(sid)
local ok, rows = pcall(pmacs.lsp.list)
if not ok or not rows then return end
for _, info in ipairs(rows) do
if tostring(info.id) == skey then
local kind = info.state and info.state.kind
if kind == "initialized" then
-- Stop polling for failure; `probe.primary` deliberately
-- survives, because a later version verdict still needs it.
probe.saw_initialized = true
probe.watching = nil
return
end
if kind == "crashed" or kind == "stopped" then
fire_latch(sid, configured_command() .. " failed to start")
end
return
end
end
-- Gone from the manager entirely without ever initializing.
fire_latch(nil, configured_command() .. " failed to start")
end
-- Q#LN16 — `textDocument/waitForDiagnostics` --------------------------
--
-- A plain request: no position, so Q#LN12's `outbound_position` concern
-- does not apply. Resolves when the server has finished elaborating.
-- Awaited through Stage 3a's response seam.
--
-- **`version` is required, not optional.** Lean's
-- `WaitForDiagnosticsParams` is `{ uri, version }` (v4.9.0,
-- `src/Lean/Data/Lsp/Extra.lean`), and the request is how the client
-- says *which* revision of the document it wants elaboration for.
-- Sending only `uri` is a malformed request against a real server; it
-- happened to look fine here because the fake server echoes any
-- payload. Callers pass the attachment's current `version`.
--
-- `fn(err)` is called with nil on success. Registering the one-shot
-- requires the server to have an attached buffer — see the note on
-- `pmacs.lsp.on_response`; every caller here comes from an attachment.
function M.wait_for_diagnostics(sid, uri, version, fn)
local ok, rid = pcall(pmacs.lsp.send_request, sid,
"textDocument/waitForDiagnostics", { uri = uri, version = version })
if not ok then
if fn then pcall(fn, tostring(rid)) end
return nil
end
if fn then
pmacs.lsp.on_response(sid, rid, function(_, err)
fn(err and err.message or nil)
end)
end
return rid
end
local function when_server_ready(sid, fn)
local function state_kind()
local ok, state = pcall(pmacs.lsp.status, sid)
if not ok or not state then return nil end
return state.kind
end
local kind = state_kind()
if kind == "initialized" then
fn(nil)
return
end
if kind == nil or kind == "crashed" or kind == "stopped" then
fn("server did not initialize")
return
end
-- A command may have just healed a dead attachment, in which case the
-- replacement is still starting. Requests are not queued before
-- initialize, so issue this one after the lifecycle reaches ready
-- rather than replacing the attachment and immediately failing on it.
pmacs.async(function()
for _ = 1, 300 do
pmacs.async.yield_to_next_tick()
kind = state_kind()
if kind == "initialized" then
fn(nil)
return
end
if kind == nil or kind == "crashed" or kind == "stopped" then
fn("server did not initialize")
return
end
end
fn("server initialization timed out")
end)
end
pmacs.command.define {
name = "lean.wait-for-diagnostics",
description = "Wait for the Lean server to finish elaborating this file",
fn = function()
local rec = pmacs.lsp._attachment_for_command()
if not rec or rec.language ~= "lean4" then
pmacs.editor.set_status("lean: no Lean server for this buffer")
return
end
pmacs.editor.set_status("lean: elaborating…")
when_server_ready(rec.server, function(init_err)
if init_err then
pmacs.editor.set_status("lean: " .. tostring(init_err))
return
end
M.wait_for_diagnostics(rec.server, rec.uri, rec.version, function(err)
if err then
pmacs.editor.set_status("lean: " .. tostring(err))
else
pmacs.editor.set_status("lean: elaboration complete")
end
end)
end)
end,
}
-- `$/lean/fileProgress` — the elaboration-in-flight signal. Stage 5's
-- goal view reads it to distinguish "no goals" from "not done yet";
-- here it is recorded so that consumer has something to read and so the
-- notification seam has its first production subscriber.
M.file_progress = {}
pmacs.lsp.on_notification("$/lean/fileProgress", function(_, params)
local uri = params and params.textDocument and params.textDocument.uri
if type(uri) ~= "string" then return end
M.file_progress[uri] = params.processing or {}
end)
-- Wiring --------------------------------------------------------------
-- Runs after `lsp.lua`'s own `buffer.after-load` subscription.
--
-- **Keyed on the buffer's LANGUAGE, not on an attachment existing.**
-- Round 1 keyed on `active_attachment()` and returned early when it was
-- nil — which silently excluded the single most likely real-world
-- failure: `lake` not installed. `ensure_server` pcalls the spawn and
-- returns nil on ENOENT, so `attach_buffer` produces no record at all,
-- so the probe never started and the latch never armed. The case the
-- fallback exists for was the one case it could not see.
pmacs.hook.add("buffer.after-load", function()
local buf = pmacs.window.buffer()
if not buf then return end
local ok_lang, lang = pcall(pmacs.lsp.buffer_language, buf)
if not ok_lang or lang ~= "lean4" then return end
local rec = pmacs.lsp.active_attachment()
if rec and rec.language == "lean4" then
-- A matching-root server supplied by the user may be adopted by
-- `ensure_server`. Its lifecycle is not evidence about the
-- config-driven command, and neither the version probe nor fallback
-- latch may mutate config because that foreign server changed state.
if not is_derived_server(rec.server) then return end
if not probe.started then
local path = pmacs.editor.file_path()
start_probe(path and M.root_for(path) or nil)
end
-- **Arm ONCE, capturing buffer and server together.** Setting
-- `buf_key` on every Lean load meant a second Lean buffer opened
-- before the verdict silently became the rebuild target while the
-- latch still watched the FIRST buffer's server — so the rebuild
-- either repaired the wrong buffer or accepted the second buffer's
-- unrelated live server as success, stranding the first. The pair
-- (target buffer, primary server) is one fact and is captured as
-- one.
if not probe.armed and not probe.latched and not probe.saw_initialized then
probe.armed = true
probe.buf_key = tostring(buf)
probe.primary = rec.server
probe.watching = rec.server
end
return
end
-- **Unconfigured is DISABLED, not failed.** A user who sets
-- `pmacs.lsp.config.lean4 = nil`, or clears its `command`, has turned
-- the Lean server off; reporting that "nil could not be started" is a
-- false alarm, and latching would poison the session so a later
-- configuration could never take effect. Only a CONFIGURED command
-- that produced no attachment is a failure.
local cfg = pmacs.lsp.config.lean4
if not cfg or not cfg.command then return end
if not probe.started then
local path = pmacs.editor.file_path()
start_probe(path and M.root_for(path) or nil)
end
-- No attachment for a Lean buffer with a configured command means
-- `ensure_server` could not spawn at all — a synchronous ENOENT,
-- already swallowed upstream. That is not something to wait for; it
-- is the failure itself, and the only place it is still observable.
if not probe.latched then
-- No server was ever created, so there is no primary to retire —
-- but the rebuild still needs a target buffer.
if not probe.armed then
probe.armed = true
probe.buf_key = tostring(buf)
end
fire_latch(nil, configured_command() .. " could not be started")
end
end)
-- A buffer switch is the moment a stale Lean buffer becomes visible, so
-- repair immediately rather than waiting for the next tick. lsp.lua's
-- own `after-switch` subscription re-pushes views but does NOT rebuild a
-- stale attachment, so nothing else covers this.
pmacs.hook.add("buffer.after-switch", function()
repair_active_if_stale()
end)
pmacs.hook.add("process.after-tick", function()
drain_probe()
poll_latch()
-- Repair the active buffer if the latch invalidated it. Cheap when
-- there is nothing to do, and bounded to one attempt per buffer.
repair_active_if_stale()
poll_fallbacks()
end)
-- Test seam: acceptance drives the latch deterministically rather than
-- waiting on real process timing. Not part of the public surface.
M._probe = probe
M._fire_latch = fire_latch
M._version_below_3_1 = version_below_3_1
pmacs.lean = M

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,517 @@
-- lean_input.lua --- the Lean 4 Unicode input method (Arc 8 Stage 4b).
--
-- Typing `\alpha` gives `α`; `\<>` gives `⟨⟩` with the point between.
-- The table is vendored in lean_abbrev.lua, generated from
-- vscode-lean4 — see that file's header and Q#LN11.
--
-- This is a typed-edit consumer (Stage 4a, Q#LN10) registered AHEAD of
-- auto-pairing at priority 50. The ordering is load-bearing, not
-- cosmetic: 64 abbreviation keys contain a character in the `lean4`
-- pair set (`\[[]]` → `⟦⟧`, `\{{}}` → `⦃⦄`), so with pairing first,
-- typing `\[` would insert `[]` with the point between and corrupt the
-- pending key to `\[]` before the second `[` arrives — `\[[]]` becomes
-- unreachable. Priority, not load order, is what decides this; that is
-- the whole reason Stage 4a exists.
--
-- The consumer therefore claims every keystroke that EXTENDS an open
-- pending abbreviation, not merely one that completes an expansion. A
-- consumer that claimed only completed expansions would hand each
-- intermediate `[` to pairing, which is the same corruption by a
-- different route. "Claimed" means the chain stops, not that an edit
-- was made (Q#LN22).
--
-- UNDO IS CROSS-PEER-DEGRADED, and this is accepted rather than papered
-- over (Q#LN21). `classify_key` (src/optimistic.rs) returns `Insert(c)`
-- for `\` and for every ASCII letter — only the nine built-in pair
-- chars are excluded — so on a CRDT frontend `\alpha` arrives as six
-- SOURCE-peer optimistic inserts while the expansion is a single
-- DAEMON-peer replace spanning all six. Undo across that boundary is
-- not chronologically arbitrated. This is the same defect Q#LN6 already
-- accepts for `⟨⟩`, one order of magnitude wider: it is every
-- abbreviation the user types, not a few brackets. The general fix is
-- chronological cross-peer undo arbitration, named substrate work.
-- `set_round_trip_input` would fix it and is rejected — it also makes
-- `dispatch_idle` report false, so RET would stop inserting a newline.
--
-- Framing: docs/lean4-mode-framing.md Q#LN11, Q#LN21, Q#LN22.
pmacs.lean_input = pmacs.lean_input or {}
local ed = pmacs.editor
local LEADER = "\\"
local CURSOR = "$CURSOR"
pmacs.config.define {
name = "lean.abbrev",
description = "Expand \\-prefixed abbreviations into Unicode symbols in Lean 4 buffers.",
type = "boolean",
default = true,
mutability = "live",
}
-- ---------------------------------------------------------------------
-- The table, and the two indexes derived from it at load time
-- ---------------------------------------------------------------------
-- `best[p]` is the symbol for the shortest key having `p` as a prefix,
-- ties broken by the key's position in the vendored sequence. Both
-- halves matter: 101 prefixes have equal-shortest candidates that
-- resolve to DIFFERENT symbols (`f` → `` from `f<`, not `` from
-- `f>`), and the sequence's order is the only place that tie is
-- recorded. `pairs` over a map-shaped table could not express it.
--
-- `eager[k]` marks the 1,550 keys that are complete and have no longer
-- key extending them — the ones that expand the moment they are typed,
-- with no terminator. `to` is NOT one of them (`top`, `to0`, `toa`),
-- which is exactly the case that reads as eager until the table is
-- consulted.
local best, eager = {}, {}
do
local seq = pmacs.lean_abbrev
if type(seq) ~= "table" then seq = {} end
local extended = {}
for i = 1, #seq do
local entry = seq[i]
local key, symbol = entry[1], entry[2]
-- Walk every prefix of the key, including the key itself. Iterating
-- the sequence in order and only overwriting on a STRICTLY shorter
-- key is what makes the source-order tiebreak fall out: an equal
-- length arriving later loses to the one already recorded.
for n = 1, #key do
local p = key:sub(1, n)
local cur = best[p]
if cur == nil or #key < cur.len then
best[p] = { symbol = symbol, len = #key }
end
if n < #key then extended[p] = true end
end
end
for i = 1, #seq do
local key = seq[i][1]
if not extended[key] then eager[key] = true end
end
end
-- Test seam (leading underscore = not stable API). Acceptance 45g reads
-- these to pin self-consistency properties a corrupt emit would break —
-- it cannot diff against `abbreviations.json`, which is not shipped.
function pmacs.lean_input._resolve(text)
local hit = best[text]
return hit and hit.symbol or nil
end
function pmacs.lean_input._is_eager(key)
return eager[key] == true
end
-- ---------------------------------------------------------------------
-- Pending state: one record per FRONTEND (Q#LN22)
-- ---------------------------------------------------------------------
-- Keyed by frontend id, with the buffer stored inside and compared by
-- value. Q#LN22 specifies the key as `(frontend, buffer)`; a per-
-- frontend slot is equivalent here and avoids inventing a scalar
-- buffer key (`BufferId`'s inner value is deliberately private, R22).
-- The generality a two-level map would add is unreachable: a frontend
-- has one point, and `buffer.after-switch` clears that frontend's slot,
-- so no frontend can hold pending state in a buffer it is not in.
--
-- Per-frontend rather than per-buffer is NOT a refinement — a buffer-
-- keyed table lets either frontend consume or discard the other's
-- half-typed abbreviation in a shared buffer, which is the ordinary
-- TUI-plus-GPU configuration this project ships.
local pending = {}
-- Expansions the chain consumer decided on but did NOT perform, keyed
-- the same way. See `run_deferred` below for why they wait.
local deferred = {}
local function frontend_id()
local ok, id = pcall(function() return pmacs.frontend.id() end)
if ok then return id end
return nil
end
-- Is `rec` a typed edit that continues `p` exactly? Conservative by
-- construction (Q#LN22): abandonment is LAZY because pmacs has no
-- cursor-motion hook, so every guard that would have been checked at
-- the moment the user left is checked here instead, at the next typed
-- edit.
local function still_valid(p, rec, buf)
if p.buffer ~= rec.buffer or p.window ~= rec.window then return false end
-- The point must still be at the end of the pending span: the leader,
-- plus what has been typed into it, plus the character that just
-- landed.
if rec.effective_start ~= p.start_offset + 1 + #p.text then return false end
-- Exactly one edit since this frontend last extended the pending
-- abbreviation — the one being processed now. Deliberately strict
-- across frontends: `revision()` is BUFFER-GLOBAL, so a peer editing
-- the shared buffer invalidates this record even though it edited
-- elsewhere. Keeping it alive would mean translating and validating
-- the span through arbitrary peer edits, substrate Stage 4b does not
-- add.
local ok, rev = pcall(function() return buf:revision() end)
if not ok or rev ~= p.expected_revision + 1 then return false end
return true
end
-- ---------------------------------------------------------------------
-- Expansion
-- ---------------------------------------------------------------------
-- Right-gravity translation of `pos` through the effective edit —
-- pair.lua's shape, for the same reason: the point sits AFTER the
-- replaced span (on the terminator, or on a closer pairing inserted)
-- and has to move with it.
local function translate(pos, estart, estop, einserted)
if pos < estart then return pos end
if pos > estop then return pos - (estop - estart) + einserted end
return estart + einserted
end
-- Replace the pending span (leader + typed text) with `symbol`.
--
-- The span deliberately STOPS BEFORE the terminator. Including the
-- terminator would make the expansion and the terminator one edit, but
-- it would also swallow whatever auto-pairing did with that terminator
-- — and a pair character is a legal terminator (`\alp(`). One undo
-- restores the same text either way, because the terminator was its own
-- insert to begin with.
--
-- ONE `buf:replace`: one undo step, one CRDT op, one effective-edit
-- verification. A rejection drops the pending state and does not retry,
-- the same discipline as comment.lua's Q#CT5 and pair.lua.
local function expand(buf, start, span_end, symbol)
local cursor_at = symbol:find(CURSOR, 1, true)
local text = cursor_at and (symbol:gsub("%$CURSOR", "", 1)) or symbol
-- The context to compare against AFTER the edit. A buffer intercept
-- may switch window or buffer while the replace runs; the point in
-- whatever it switched to is not ours to move.
local win0 = pmacs.window.current()
local point0 = ed.cursor()
local ok, estart, estop, einserted = pcall(function()
return buf:replace(start, span_end, text)
end)
if not ok then
ed.set_status("lean abbreviation rejected by buffer intercept")
return nil
end
if estart ~= start or estop ~= span_end or einserted ~= #text then
ed.set_status("lean abbreviation altered by buffer intercept")
return nil
end
-- The point MUST be placed explicitly. Unlike pairing's at-cursor
-- insert, this replace SHRINKS the buffer — `\alpha` (6 bytes)
-- becomes `α` (2) — and a point left at the pre-edit offset is past
-- the new end. Every later self-insert is then silently rejected and
-- the editor looks dead. There is no daemon re-grounding that covers
-- this; that only holds for an edit that lands at the cursor.
--
-- Context-guarded exactly as pair.lua's `repair_cursor` is: if the
-- intercept switched us elsewhere, `goto_byte` would move the point
-- of a buffer that has nothing to do with this expansion.
if pmacs.window.current() == win0 and pmacs.window.buffer() == buf then
if cursor_at then
ed.goto_byte(start + cursor_at - 1)
else
ed.goto_byte(translate(point0, estart, estop, einserted))
end
end
return start + #text
end
-- ---------------------------------------------------------------------
-- The consumer
-- ---------------------------------------------------------------------
-- Chain invocations not yet matched by a `run_deferred`.
--
-- `buffer.after-edit` fan-outs NEST: the typed-edit contract explicitly
-- supports a consumer calling `pmacs.hook.run("buffer.after-edit")`,
-- and a nested run re-enters every subscriber — including this module's
-- deferred-expansion subscriber, while the OUTER chain is still walking
-- its consumer list and pairing has not yet seen the terminator. A
-- nested run that performed the expansion would reproduce exactly the
-- bug deferring exists to fix: pairing resumes afterwards holding a
-- record the replace has invalidated, declines, and the closer is lost.
--
-- Counting has to happen INSIDE the chain and BEFORE any consumer that
-- might start a nested fan-out. A subscriber registered alongside
-- `run_deferred` is too late — the whole nested fan-out completes
-- inside the outer chain's subscriber, before either of them runs. And
-- counting in the expander itself is not enough: a lower-priority
-- consumer may CLAIM and stop the chain before the expander is
-- reached, so a nested pass would go uncounted while its
-- `run_deferred` still ran (round 11's fix, round 12's defect).
--
-- Hence a separate no-op consumer at the minimum priority, which runs
-- first in every chain invocation that reaches any consumer at all.
-- Its guarantee is exactly the ordering contract the chain already
-- rests on, and it degrades safely: the only thing that can skip it is
-- a claim ahead of it, which skips the expander too, so nothing is
-- queued in that fan-out either.
local depth = 0
local function count_fan_out()
depth = depth + 1
return false
end
local function on_typed_edit(rec)
local fid = frontend_id()
if fid == nil then return false end
-- A fan-out carrying no record is still information: a paste,
-- programmatic edit or replicated op landed, so whatever this
-- frontend had pending no longer describes the buffer. Drop it and
-- decline — this is why the chain calls consumers with nil rather
-- than skipping them (Q#LN10).
if not rec then
pending[fid] = nil
return false
end
if not (ed.this_command and ed.this_command() == "buffer.self-insert") then
pending[fid] = nil
return false
end
-- Both gates resolve against the SOURCE buffer of the typed edit, not
-- the active one — a context-switching command may have replaced it
-- by callback time (pair.lua round 2, finding 2).
if not pmacs.config.get("lean.abbrev", rec.buffer) then
pending[fid] = nil
return false
end
local lang
if pmacs.lsp and pmacs.lsp.buffer_language then
local ok, l = pcall(pmacs.lsp.buffer_language, rec.buffer)
if ok then lang = l end
end
if lang ~= "lean4" then
-- No pending abbreviation is ever OPENED outside a `lean4` buffer:
-- `\` in Rust is an ordinary character and `\[` there still pairs.
pending[fid] = nil
return false
end
local buf = pmacs.window.buffer()
if not buf or buf ~= rec.buffer or pmacs.window.current() ~= rec.window then
pending[fid] = nil
return false
end
-- Fail closed on a transformed source self-insert, as pairing does:
-- expanding on top of a relocated or rewritten character compounds
-- the intercept's result.
if not rec.clean then
pending[fid] = nil
return false
end
-- ...and on a source edit whose context is no longer current. The
-- buffer and window matching is not enough: a redefined self-insert
-- can insert the character and THEN move the point, and expanding
-- over a span the user has left teleports them back into it. Pairing
-- makes the same three-part check for the same reason.
if ed.cursor() ~= rec.post_cursor then
pending[fid] = nil
return false
end
local revision
do
local ok, rev = pcall(function() return buf:revision() end)
if not ok then
pending[fid] = nil
return false
end
revision = rev
end
local p = pending[fid]
if p and not still_valid(p, rec, buf) then
p = nil
pending[fid] = nil
end
local ch = rec.char
-- No pending abbreviation: only the leader opens one.
if not p then
if ch == LEADER then
pending[fid] = {
buffer = rec.buffer,
window = rec.window,
start_offset = rec.effective_start,
text = "",
expected_revision = revision,
}
-- Claimed: the leader belongs to the abbreviation, and pairing
-- has no interest in it either way.
return true
end
return false
end
-- Pending: does any key still have `text .. ch` as a prefix?
local extended = p.text .. ch
if best[extended] then
p.text = extended
p.expected_revision = revision
if eager[extended] then
pending[fid] = nil
deferred[fid] = {
buffer = rec.buffer,
window = rec.window,
start_offset = p.start_offset,
text = extended,
symbol = best[extended].symbol,
re_arm = false,
}
end
-- Claimed either way: an extension that has not yet completed must
-- NOT reach auto-pairing (`\[` in `\[[]]`), and a completing one is
-- part of the abbreviation, not a character pairing should react to.
return true
end
-- `ch` does not extend the abbreviation: it TERMINATES it, and a
-- terminator is an ordinary character that auto-pairing is entitled
-- to react to (`\alp(` must give `α()`). So the expansion is
-- DEFERRED to the subscriber below and this returns false, leaving
-- pairing a record whose offsets still describe the buffer.
--
-- Expanding here and returning false would not do: the replace makes
-- pairing's copy of the record stale, so pairing declines and the
-- closer is silently lost. Expanding here and returning true is
-- worse — it is what shipped in the first revision of this file, and
-- it makes every pair-character terminator silently unpaired.
pending[fid] = nil
if best[p.text] and #p.text > 0 then
deferred[fid] = {
buffer = rec.buffer,
window = rec.window,
start_offset = p.start_offset,
text = p.text,
symbol = best[p.text].symbol,
-- A terminating `\` re-arms as a NEW leader at its own position
-- (`\al\to` → `∀→`). Upstream gets this from `processChange`,
-- where a finished abbreviation reports `isAffected = false` and
-- so does not suppress the new-leader branch. This is NOT the
-- `\\` case: there the pending text is empty, `\` EXTENDS, and
-- the result is one literal backslash with nothing left open.
re_arm = ch == LEADER,
}
elseif ch == LEADER then
-- Nothing to expand, but the leader still opens a fresh
-- abbreviation where it landed.
pending[fid] = {
buffer = rec.buffer,
window = rec.window,
start_offset = rec.effective_start,
text = "",
expected_revision = revision,
}
return true
end
return false
end
-- The deferred expansion, on its own `buffer.after-edit` subscriber.
--
-- It runs AFTER the whole typed-edit chain — this chunk loads after
-- typed_edit.lua, and hook callbacks run in registration order — so
-- auto-pairing has already reacted to the terminator by the time the
-- expansion rewrites the text in front of it. Pairing's closer lands
-- after the terminator, outside the replaced span, so it survives.
--
-- It must also run BEFORE lsp.lua's subscriber (Q#AP7): that one
-- flushes `didChange` synchronously on the signature-trigger path, and
-- a server told about `\alp ` instead of `α ` stays wrong until the
-- next edit. This chunk loads before lsp.lua for exactly that reason.
--
-- A claim by ANY chain consumer stops the chain but not this — which
-- is the point. Pairing claims the terminator it reacts to.
local function run_deferred()
-- Match off this fan-out's chain invocation. `> 1` means the outer
-- chain is still mid-list — pairing has not had the terminator yet —
-- so the queued expansion stays queued for the outer pass. The clamp
-- keeps this honest if a claim beat the counting consumer, in which
-- case nothing was queued in that fan-out either.
local level = depth
if depth > 0 then depth = depth - 1 end
if level > 1 then return end
local fid = frontend_id()
if fid == nil then return end
local d = deferred[fid]
deferred[fid] = nil
if not d then return end
local buf = pmacs.window.buffer()
if not buf or buf ~= d.buffer or pmacs.window.current() ~= d.window then
return
end
-- The span must still hold exactly what was typed into it. Pairing
-- only edits at the point, which is past this span, so in practice
-- this holds; a buffer intercept is not obliged to be so polite.
local span_end = d.start_offset + 1 + #d.text
local ok, actual = pcall(function()
return buf:slice(d.start_offset, span_end)
end)
if not ok or actual ~= LEADER .. d.text then return end
local after = expand(buf, d.start_offset, span_end, d.symbol)
if after and d.re_arm then
local rev_ok, rev = pcall(function() return buf:revision() end)
if rev_ok then
pending[fid] = {
buffer = d.buffer,
window = d.window,
start_offset = after,
text = "",
expected_revision = rev,
}
end
end
end
-- Q#KR11's seam: a detached frontend's pending state must not outlive
-- it. Ids are monotonic, so this table would otherwise grow for the
-- life of the session.
pmacs.hook.add("frontend.detached", function(fid)
pending[fid] = nil
deferred[fid] = nil
end)
pmacs.hook.add("buffer.after-edit", run_deferred)
-- `buffer.after-switch` fires with NO arguments, so it cannot say whose
-- switch it was. The acting frontend is the one that produced the most
-- recent dispatched input event, which is what `pmacs.frontend.id()`
-- reports at callback time. Clearing every entry instead would let one
-- frontend's navigation discard another's half-typed abbreviation.
pmacs.hook.add("buffer.after-switch", function()
local fid = frontend_id()
if fid ~= nil then pending[fid] = nil end
end)
-- Runs first in every chain invocation that reaches a consumer at all,
-- which is what makes the nesting count trustworthy — see `depth`. It
-- declines, always: it observes, it does not participate.
pmacs.typed_edit.add_consumer {
name = "lean-abbrev-fan-out-counter",
priority = -2147483648,
fn = count_fan_out,
}
pmacs.typed_edit.add_consumer {
name = "lean-abbrev",
priority = 50,
fn = on_typed_edit,
}

View File

@ -607,6 +607,12 @@ local function project_root_for(language, path)
return dir_of(path), "fallback"
end
-- Servers created by the automatic config-driven path. This is the
-- ownership fact a caller-supplied `label` cannot provide: labels are
-- public, unreserved display strings, while entries here are written
-- only after this module itself successfully spawns a server.
local default_servers = {}
local function ensure_server(language, path)
local cfg = pmacs.lsp.config[language]
if not cfg or not cfg.command then return nil end
@ -660,7 +666,30 @@ local function ensure_server(language, path)
cwd = root,
root_uri = key_uri,
})
if ok then return sid end
if ok then
default_servers[tostring(sid)] = language
return sid
end
return nil
end
-- Internal ownership seam for builtins whose lifecycle follows the
-- config-driven server set (currently Lean's one-shot fallback). A
-- user-managed server may deliberately use the same language id, label,
-- command, and root; none of those make it ours.
function pmacs.lsp._is_default_server(sid, language)
local owned_language = default_servers[tostring(sid)]
return owned_language ~= nil
and (language == nil or owned_language == language)
end
local function server_state_kind(sid)
if not sid then return nil end
for _, info in ipairs(pmacs.lsp.list()) do
if tostring(info.id) == tostring(sid) then
return info.state and info.state.kind
end
end
return nil
end
@ -669,14 +698,8 @@ end
-- forgotten, or was spawned against a now-replaced `pmacs.lsp.config`
-- entry — get rebuilt on the next attach attempt.
local function server_is_live(sid)
if not sid then return false end
for _, info in ipairs(pmacs.lsp.list()) do
if tostring(info.id) == tostring(sid) then
local kind = info.state and info.state.kind
return kind ~= "crashed" and kind ~= "stopped"
end
end
return false
local kind = server_state_kind(sid)
return kind ~= nil and kind ~= "crashed" and kind ~= "stopped"
end
local function server_is_initialized(sid)
@ -811,6 +834,15 @@ local function attach_buffer(buf)
local existing = attachments[key]
if existing and server_is_live(existing.server) then return existing end
if existing then
local kind = server_state_kind(existing.server)
if kind == "crashed" or kind == "stopped" then
-- A terminal OnCrash client may still have `next_restart_at`
-- armed. Spawning beside it creates two same-root servers when
-- the old id restarts. `forget` is the terminal-state operation:
-- it removes the client and cancels that pending restart before
-- the replacement is created.
pcall(pmacs.lsp.forget, existing.server)
end
attachments[key] = nil
-- Unsent edits targeted the dead attachment; the did_open below
-- carries the full current text, superseding them.
@ -871,6 +903,21 @@ local function attached_for_active()
if not buf then return nil end
local key = tostring(buf)
local rec = attachments[key]
-- A record whose server is dead is worse than no record: every
-- command below issues requests against it and gets silence. Rebuild
-- instead, which is what `attach_buffer` does for a stale attachment
-- anyway — this just stops the dead record short-circuiting that.
--
-- Load-bearing for anything that retires a server out from under open
-- buffers (Arc 8 Stage 3b's fallback latch retires every Lean server
-- at once). Buffers in OTHER frontends get no `buffer.after-switch`
-- in this one, so an eager repair sweep keyed on the ambient active
-- buffer cannot reach them; healing at the point of USE is
-- frontend-agnostic, because whichever frontend runs the command is
-- the active one while it runs.
if rec and not server_is_live(rec.server) then
rec = nil
end
if rec then
-- Every interactive command resolves its attachment here before
-- issuing requests; flushing now means the server answers those
@ -881,6 +928,14 @@ local function attached_for_active()
return attach_buffer(buf)
end
-- Internal command-path resolver for builtin request producers outside
-- this module. Unlike `active_attachment` it may replace a dead record;
-- unlike `attachment_for_request` it is called only from an explicit
-- user command, where attach-on-use is the intended policy.
function pmacs.lsp._attachment_for_command()
return attached_for_active()
end
-- Pure, side-effect-free attachment lookup for the active buffer:
-- returns the live record (with `.uri`) when a server is already
-- attached, else nil. Unlike `attached_for_active`, it never *triggers*
@ -893,6 +948,24 @@ function pmacs.lsp.active_attachment()
return attachments[tostring(buf)]
end
-- Re-run the attach for the ACTIVE buffer, rebuilding it against the
-- current `pmacs.lsp.config`.
--
-- Exists for the Arc 8 Stage 3b fallback latch (Q#LN7): after that latch
-- stops a server that failed to start and rewrites `config.lean4`,
-- something has to actually spawn the replacement and re-point the
-- buffer at it. Nothing else does — `attach_buffer` early-returns for a
-- live attachment, and no hook re-fires on a config change, so without
-- this the buffer stays bound to the stopped server and the "fallback"
-- is a config edit with no effect.
--
-- Deliberately keyed on the active buffer, matching `attach_buffer`'s
-- own use of `active_buffer_path()`; it is not a general re-attach for
-- arbitrary buffers and must not be used as one.
function pmacs.lsp._attach_buffer()
return attach_buffer(pmacs.window.buffer())
end
-- Arc 4 stage 3: pure modeline projection. This reads the private
-- per-buffer attachment map directly so passive split windows report their
-- own buffer instead of the focused window. It never attaches, flushes
@ -924,6 +997,19 @@ function pmacs.lsp.attachment_for_request()
local key = tostring(buf)
local rec = attachments[key]
if not rec then return nil end
-- Same liveness rule as `attached_for_active`: a record naming a dead
-- server is worse than none, because the caller issues a request
-- against it and waits for a reply that cannot come. Unlike that
-- function this one is deliberately non-attaching (it must not
-- perturb LSP state), so a dead record reads as "no attachment"
-- rather than triggering a rebuild.
if not server_is_live(rec.server) then
-- Preserve the record. A crashed OnCrash server may restart under
-- the SAME id; clearing the map here would orphan that recovered
-- server, while this non-attaching lookup has no authority to
-- cancel the restart or create a replacement.
return nil
end
flush_did_change(key)
return rec
end
@ -1546,6 +1632,186 @@ end
-- itself is unaffected. Server ids are snapshotted before the loop
-- because `apply_workspace_edit` → `find_or_open` can attach a new
-- buffer mid-iteration (mutating `attachments`).
-- Server-originated notification / response seams (framing Q#LN9) -------
--
-- Before this, `handle_server_requests` handled five `request` methods
-- and `initialized`, and dropped every `notification` and `response` on
-- the floor. Dropping responses made `pmacs.lsp.send_request` a
-- write-only API from Lua: the reply was drained and discarded, so
-- nothing outside Rust's typed stores could ever consume one.
--
-- Both seams route through the *existing* drain. A second
-- `events_take` caller would steal events from this one — `take_events`
-- removes the queue — so any new consumer must extend this loop rather
-- than open its own.
--
-- method -> array of subscriber fns. Persistent; `pmacs.hook` has no
-- `remove` and neither does this, deliberately matching it.
local notification_subs = {}
-- tostring(sid) -> { [request_id] = { fn = fn, attempt = n } }. One-shot.
local pending_responses = {}
local function report_subscriber_error(what, err)
local msg = string.format("LSP: %s subscriber failed: %s", what,
tostring(err))
-- COHERENCE §1.2: a pcall around background wiring must report, not
-- discard. `pmacs.editor.set_status` is the channel that exists;
-- `pmacs.error` is referenced by fifteen call sites and defined
-- nowhere in production, so it rides along rather than standing alone.
pcall(pmacs.editor.set_status, msg)
if pmacs.error then pcall(pmacs.error, msg) end
end
-- Current spawn attempt for `sid`, or nil if the manager has forgotten
-- it. A restart reuses the sid but bumps the attempt, which is how a
-- pending one-shot tells "my server is still here" from "my server died
-- and a new generation took its id".
local function server_attempt(sid)
local skey = tostring(sid)
for _, info in ipairs(pmacs.lsp.list()) do
if tostring(info.id) == skey then
return info.attempt or 0
end
end
return nil
end
-- fn(sid, params); persistent, fires for every server.
function pmacs.lsp.on_notification(method, fn)
if type(method) ~= "string" or type(fn) ~= "function" then
error("pmacs.lsp.on_notification(method, fn): want string, function")
end
local subs = notification_subs[method]
if not subs then
subs = {}
notification_subs[method] = subs
end
subs[#subs + 1] = fn
end
-- fn(result, err); ONE-SHOT, keyed to the exact request.
-- `request_id` is what `pmacs.lsp.send_request` returned.
--
-- **Register only against a server with an attached buffer.** The drain
-- that delivers replies visits only sids present in `attachments`, so a
-- one-shot on an unattached server will not fire on its reply — the
-- reply sits in that server's queue and the handler is invoked only when
-- the purge below decides the server is gone. That is fire-on-death, not
-- fire-on-reply, and it looks exactly like a hung request while
-- debugging. The attach path is the ordinary way to get a sid; a
-- hand-spawned one from `init.lua` is the case to watch.
function pmacs.lsp.on_response(sid, request_id, fn)
if not sid or type(request_id) ~= "number" or type(fn) ~= "function" then
error("pmacs.lsp.on_response(sid, request_id, fn): want sid, number, function")
end
local skey = tostring(sid)
local pend = pending_responses[skey]
if not pend then
pend = {}
pending_responses[skey] = pend
end
-- The attempt is captured at registration so a restart under the same
-- sid purges this entry rather than leaving it waiting on a reply the
-- dead generation was going to send.
pend[request_id] = { fn = fn, attempt = server_attempt(sid) or 0 }
end
local function dispatch_notification(sid, ev)
local subs = notification_subs[ev.method]
if not subs then return end
-- Length captured up front: a subscriber that registers another one
-- must not be able to extend the list being walked.
local n = #subs
for i = 1, n do
local ok, err = pcall(subs[i], sid, ev.params)
if not ok then
report_subscriber_error("notification " .. tostring(ev.method), err)
end
end
end
local function deliver_response(sid, ev)
local skey = tostring(sid)
local pend = pending_responses[skey]
if not pend then return end
local entry = pend[ev.request_id]
if not entry then return end
-- Removed UNCONDITIONALLY, so a handler that raises is still retired
-- and cannot be invoked a second time by the purge. Removing first is
-- the defensive order and costs nothing, but it is not what defends
-- against re-invocation: `pcall` catches the raise either way, so
-- before-vs-after is unobservable without a re-entrant drain. The
-- reachable bug is gating removal on a clean return, which acceptance
-- 32 bites (2 != 1).
pend[ev.request_id] = nil
if next(pend) == nil then pending_responses[skey] = nil end
local ok, err = pcall(entry.fn, ev.result, ev.error)
if not ok then
report_subscriber_error("response " .. tostring(ev.method), err)
end
end
-- Settle every one-shot whose server can no longer answer it.
--
-- Deliberately driven off `pmacs.lsp.list()` and NOT off a death event
-- observed in the drain, because the drain cannot be relied on to reach
-- the server in question: `handle_server_requests` builds its sid list
-- from `attachments`, and a sid leaves that table whenever
-- `attach_buffer` finds it dead and rebuilds the attachment against a
-- fresh server. So the very event that should trigger the purge —
-- `crashed` / `stopped` — is the one most likely to go undrained. A
-- one-shot settled only by the drain would leak exactly when it matters.
--
-- `pmacs.lsp.list()` enumerates the manager directly and is unaffected
-- by attachment bookkeeping, which is what makes it the right authority.
local function purge_dead_pending()
if next(pending_responses) == nil then return end
local ok, rows = pcall(pmacs.lsp.list)
-- A failed enumeration is not evidence that every server died; leaving
-- the registrations alone is the safe read of "we don't know".
if not ok or not rows then return end
local alive = {}
for _, info in ipairs(rows) do
local kind = info.state and info.state.kind
if kind ~= "crashed" and kind ~= "stopped" then
alive[tostring(info.id)] = info.attempt or 0
end
end
for skey, pend in pairs(pending_responses) do
local attempt = alive[skey]
local dead = {}
for rid, entry in pairs(pend) do
-- Absent or terminal, or the same sid running a NEW generation:
-- in every case the request this entry awaits is unanswerable.
--
-- The generation half is **defensive and not covered by the
-- acceptance suite**, stated plainly rather than left to look
-- tested. Reaching it requires a crash and its restart to both
-- fall inside a gap with no `_async.tick` — the crash backoff is
-- 500ms (`src/lsp.rs:1007`), so any tick during that window sees
-- `crashed` and the absent-or-terminal test above fires first. A
-- stalled or idle editor can produce such a gap, and then this is
-- the only thing standing between a one-shot and waiting forever
-- on a reply the dead generation owed. Every attempt to stage it
-- deterministically ended up exercising the `crashed` path
-- instead, so it is kept as insurance and labelled as such.
if attempt == nil or attempt ~= entry.attempt then
dead[#dead + 1] = rid
end
end
for _, rid in ipairs(dead) do
local entry = pend[rid]
pend[rid] = nil
local ok_h, err = pcall(entry.fn, nil,
{ message = "server gone before response" })
if not ok_h then
report_subscriber_error("response purge", err)
end
end
if next(pend) == nil then pending_responses[skey] = nil end
end
end
local function handle_server_requests()
local sids, seen = {}, {}
for _, rec in pairs(attachments) do
@ -1598,6 +1864,10 @@ local function handle_server_requests()
-- LSP spells the field "unregisterations".
pcall(unregister_file_watchers, sid,
ev.params and ev.params.unregisterations)
elseif ev.kind == "notification" then
dispatch_notification(sid, ev)
elseif ev.kind == "response" then
deliver_response(sid, ev)
elseif ev.kind == "initialized" then
-- Buffers attach before the server finishes initializing, so
-- the pulls in `attach_buffer` are no-ops for the FIRST file
@ -1620,6 +1890,10 @@ if pmacs._async and pmacs._async.tick then
pmacs._async.tick = function(...)
local ret = _prior_async_tick(...)
pcall(handle_server_requests)
-- After the drain, so a response delivered this tick settles its
-- one-shot normally rather than being purged as "server gone" in the
-- same pass when the server died right after answering.
pcall(purge_dead_pending)
pcall(flush_due_did_changes)
return ret
end

View File

@ -4,20 +4,30 @@
-- next char is already `)` steps over it instead of doubling it. The
-- carrier is a `buffer.after-edit` reaction (Q#AP1): the opener stays
-- a genuine single-codepoint self-insert — the classification
-- signature help depends on — and this hook inserts (or swallows) the
-- closer as a second edit. Provenance is the exact one-shot typed-edit
-- record (`pmacs.editor.take_typed_edit()`, Q#AP9), not buffer-text
-- inference: pastes, programmatic edits, manual hook runs, and a stale
-- `this_command` have no record and never pair, and a transformed,
-- relocated, or context-switching source self-insert fails closed.
-- signature help depends on — and this reaction inserts (or swallows)
-- the closer as a second edit. Provenance is the exact one-shot
-- typed-edit record (`pmacs.editor.take_typed_edit()`, Q#AP9), not
-- buffer-text inference: pastes, programmatic edits, manual hook runs,
-- and a stale `this_command` have no record and never pair, and a
-- transformed, relocated, or context-switching source self-insert fails
-- closed.
--
-- This chunk loads BEFORE lsp.lua (Q#AP7): registration order is hook
-- execution order, and lsp.lua's after-edit callback synchronously
-- flushes didChange on the signature-trigger path — the closer must
-- already be in the buffer when that callback runs. Everything under
-- `pmacs.lsp` is therefore looked up lazily at callback time.
-- Since Arc 8 Stage 4a (Q#LN10) pairing no longer subscribes to
-- `buffer.after-edit` itself. It registers on the typed-edit chain
-- (`builtin/runtime/typed_edit.lua`), which owns the single subscriber
-- and the single one-shot read. Everything above still holds — the
-- record is the same record — but the chain, not this file, decides
-- who sees it and in what order.
--
-- Framing: docs/auto-pairing-framing.md.
-- This chunk loads AFTER typed_edit.lua (it registers into it) and
-- BEFORE lsp.lua (Q#AP7): registration order is hook execution order,
-- and lsp.lua's after-edit callback synchronously flushes didChange on
-- the signature-trigger path — the closer must already be in the
-- buffer when that callback runs. Everything under `pmacs.lsp` is
-- therefore looked up lazily at callback time.
--
-- Framing: docs/auto-pairing-framing.md; Stage 4a in
-- docs/lean4-mode-framing.md Q#LN10.
pmacs.pair = pmacs.pair or {}
@ -40,7 +50,7 @@ local ed = pmacs.editor
-- Per-buffer on/off switch (Q#CR8's flagship adopter). Read against the
-- SOURCE buffer of the typed edit, never the currently active one — see
-- the hook body below, which resolves it the same way `set_for` resolves
-- the consumer body below, which resolves it the same way `set_for` resolves
-- the buffer's pair set (round 2, finding 2): `rec.buffer`, not
-- `pmacs.window.buffer()`.
pmacs.config.define {
@ -214,28 +224,36 @@ end
-- Acceptance tests flip `_capture_records` on; each fan-out then
-- publishes the record it observed (or nil) to `_last_record`, which
-- is how tests read the exact codepoint / effective triple and prove
-- one-shot-ness (this callback registers first and consumes it).
-- one-shot-ness (the chain takes the record before any other
-- `buffer.after-edit` subscriber can, and hands it here).
pmacs.pair._capture_records = false
pmacs.hook.add("buffer.after-edit", function()
-- The typed-edit consumer (Arc 8 Stage 4a, Q#LN10). `rec` is the one
-- record `typed_edit.lua` read for this fan-out — possibly nil, which
-- is why the capture seam below is updated before the nil guard.
-- Returns whether pairing CLAIMED the keystroke: true once it has
-- committed to reacting (a skip-over or a closer insert, landed or
-- intercept-rejected), false on every decline. Pairing is last of the
-- builtin consumers, so nothing currently observes that value; it is
-- stated correctly so it stays correct when something does.
local function on_typed_edit(rec)
-- One-shot provenance (Q#AP9). Absence — paste, programmatic edit,
-- manual hook run, rejected insert, a post-insert mutation by the
-- command, stale `this_command` — is a silent non-event; only a
-- live record for a pair-set character that then fails a gate
-- reports.
local rec = ed.take_typed_edit and ed.take_typed_edit()
if pmacs.pair._capture_records then pmacs.pair._last_record = rec end
if not rec then return end
if not (ed.this_command and ed.this_command() == "buffer.self-insert") then return end
if not rec then return false end
if not (ed.this_command and ed.this_command() == "buffer.self-insert") then return false end
-- The master switch, per-buffer (Q#CR4): the SOURCE buffer of the
-- typed edit, resolved buffer-local -> global -> default(true). A
-- second buffer of the same language is untouched by a buffer-local
-- override here (acceptance 29).
if not pmacs.config.get("editing.auto-pair", rec.buffer) then return end
if not pmacs.config.get("editing.auto-pair", rec.buffer) then return false end
local buf = pmacs.window.buffer()
if not buf then return end
if not buf then return false end
-- Relevance first (PR #110 round 1, finding 2): pairing has no
-- interest in characters outside the set, so a transformed or
@ -247,14 +265,14 @@ pmacs.hook.add("buffer.after-edit", function()
-- Rust.
local ch = rec.char
local openers, closers = maps_for(set_for(rec.buffer))
if not (openers[ch] or closers[ch]) then return end
if not (openers[ch] or closers[ch]) then return false end
-- Fail closed on a transformed source self-insert (Q#AP3): the
-- intercept's positional result stands as produced; pairing on top
-- of a relocated or expanded opener would compound it.
if not rec.clean then
ed.set_status("auto-pair skipped: source self-insert transformed")
return
return false
end
-- Fail closed when the source edit's context is no longer current:
-- an intercept switched window/buffer, or something moved the
@ -268,14 +286,14 @@ pmacs.hook.add("buffer.after-edit", function()
or pmacs.window.current() ~= rec.window
or ed.cursor() ~= rec.post_cursor then
ed.set_status("auto-pair skipped: source context changed")
return
return false
end
-- Region guard (Q#AP3/Q#AP6): on the dispatch route type-over has
-- already consumed and cleared the region. A region surviving the
-- edit means the TUI's selection-blind optimistic gate let a custom
-- pair char through (named deferral) — reacting would pile a closer
-- onto an unconsumed region.
if ed.region() ~= nil then return end
if ed.region() ~= nil then return false end
local cursor = rec.post_cursor
@ -294,19 +312,19 @@ pmacs.hook.add("buffer.after-edit", function()
if not ok then
-- The duplicate stays (e.g. `())`); report, no retry.
ed.set_status("auto-pair skip rejected by buffer intercept")
return
return true
end
if estart ~= cursor or estop ~= cursor + #ch or einserted ~= 0 then
ed.set_status("auto-pair skip altered by buffer intercept")
repair_cursor(win0, buf, cursor, estart, estop, einserted)
end
return
return true
end
end
local closer = openers[ch]
if not closer then return end
if not should_pair(buf, cursor, closers) then return end
if not closer then return false end
if not should_pair(buf, cursor, closers) then return false end
local win0 = pmacs.window.current()
local ok, estart, estop, einserted = pcall(function()
@ -315,7 +333,7 @@ pmacs.hook.add("buffer.after-edit", function()
if not ok then
-- Nothing landed; the opener stands alone.
ed.set_status("auto-pair closer rejected by buffer intercept")
return
return true
end
if estart ~= cursor or estop ~= cursor or einserted ~= #closer then
ed.set_status("auto-pair closer altered by buffer intercept")
@ -324,4 +342,11 @@ pmacs.hook.add("buffer.after-edit", function()
-- Clean path: no cursor motion — the insert landed at the cursor
-- and Lua mutators move no cursors, so it already sits between the
-- pair; the daemon's per-tick CursorByte re-grounds both frontends.
end)
return true
end
pmacs.typed_edit.add_consumer {
name = "auto-pair",
priority = 100,
fn = on_typed_edit,
}

View File

@ -3,6 +3,38 @@
local terminal = assert(pmacs.terminal, "pmacs.terminal raw bindings are required")
local raw_open = assert(terminal._open, "pmacs.terminal._open is required")
-- Q#TC2a. Every default reproduces today's behavior exactly, so a tree
-- with no settings written and no profiles registered behaves as before.
pmacs.config.define {
name = "terminal.default-profile",
type = "string",
default = "",
allow_empty = true,
mutability = "live",
description = "Profile name from pmacs.terminal.profiles to open by default. " ..
"Empty means no profile: fall back to $SHELL.",
}
pmacs.config.define {
name = "terminal.scrollback-rows",
type = "integer",
default = 10000,
min = 0,
max = 4000000,
mutability = "live",
description = "Rows of scrollback retained per terminal. " ..
"0 retains no history.",
}
pmacs.config.define {
name = "terminal.escape-key",
type = "string",
default = "C-c",
mutability = "live",
description = "Chord that escapes to the editor from a terminal. " ..
"Pressing it twice sends the chord itself to the child.",
}
local function bind_terminal_keys(buffer)
local function bind(sequence, command)
pmacs.keymap.bind {
@ -17,21 +49,398 @@ local function bind_terminal_keys(buffer)
bind("C-v", "terminal.page-down")
bind("M-<", "terminal.scroll-oldest")
bind("M->", "terminal.scroll-bottom")
-- Q#TC8a/Q#TC9: copy mode is ADDITIVE. The live keys above are
-- unchanged; this is one more leaf beside them. `C-t` is globally
-- `edit.transpose-chars`, which is meaningless in a read-only
-- terminal buffer, and binding it buffer-locally is the scoped
-- idiom rather than a shadow — `keymap.bind`'s strictness rejects
-- binding a PREFIX of an existing sequence within a scope, not
-- cross-scope shadowing.
--
-- Physically typed as `C-c C-t`: in a terminal every unescaped key
-- goes to the child, so terminal-local bindings are reached through
-- the escape. That also matches emacs-libvterm's own chord.
bind("C-t", "terminal.copy-mode")
end
-- Q#TC1: profiles are a raw Lua table, not a config setting. The
-- registry stores four scalars and has no table kind, so a profile —
-- inherently `{ command, args, cwd, env }` — lives here beside
-- `pmacs.lsp.config` and `pmacs.pair.sets` until table-valued settings
-- exist.
terminal.profiles = terminal.profiles or {}
local PROFILE_FIELDS = {
command = "string",
args = "table",
cwd = "string",
env = "table",
}
-- Every diagnostic below renders a caller- or user-supplied value, so
-- rendering must never be the thing that fails. `%q` is partial — it
-- raises on a table or function — and a profile name arrives straight
-- from `open { profile = ... }`.
local function describe_name(name)
if type(name) == "string" then return string.format("%q", name) end
return string.format("<%s %s>", type(name), tostring(name))
end
local function validate_profile(name, profile)
local shown = describe_name(name)
if type(profile) ~= "table" then
error(string.format("terminal profile %s must be a table", shown), 0)
end
for key, value in pairs(profile) do
local expected = PROFILE_FIELDS[key]
if not expected then
error(string.format("terminal profile %s: unknown field %q", shown, tostring(key)), 0)
end
if type(value) ~= expected then
error(string.format(
"terminal profile %s: field %q must be a %s, got %s",
shown, key, expected, type(value)), 0)
end
end
return profile
end
-- `terminal.profiles` is a raw user table, so its keys are whatever the
-- user wrote. Sorting them directly raises "attempt to compare number
-- with string" the moment the table holds both a string and a numeric
-- key — and it raises on the UNKNOWN-PROFILE path, replacing the very
-- error this list exists to explain with an opaque one. Sorting DISPLAY
-- strings is total over every key type, so the diagnostic survives a
-- malformed table.
local function known_profile_names()
local names = {}
for name in pairs(terminal.profiles) do names[#names + 1] = tostring(name) end
table.sort(names)
return names
end
-- Q#TC2 / Q#TC3a: resolve a profile by name, or nil when none is
-- selected. An explicitly requested profile that does not exist is an
-- error even when `terminal.default-profile` is valid — a typo must not
-- silently fall back to the default.
local function resolve_profile(requested)
local name = requested
if name == nil then
local configured = pmacs.config.get("terminal.default-profile")
if configured == nil or configured == "" then return nil end
name = configured
end
local profile = terminal.profiles[name]
if profile == nil then
local known = known_profile_names()
local listed = #known > 0 and table.concat(known, ", ") or "(none defined)"
error(string.format(
"terminal profile %s is not defined; known profiles: %s",
describe_name(name), listed), 0)
end
return validate_profile(name, profile)
end
-- Q#TC3a merge order, per field: explicit open field, then the profile's
-- field, then the scalar setting, then the built-in fallback. `env` is
-- the one field where "first wins" would be wrong, so it MERGES with
-- explicit entries overriding the profile's — any other reading silently
-- drops half a user's environment.
local function merge_env(profile_env, explicit_env)
if profile_env == nil then return explicit_env end
local merged = {}
for key, value in pairs(profile_env) do merged[key] = value end
for key, value in pairs(explicit_env or {}) do merged[key] = value end
return merged
end
function terminal.open(spec)
local buffer = raw_open(spec)
spec = spec or {}
local resolved = {}
for key, value in pairs(spec) do
if key ~= "profile" then resolved[key] = value end
end
local profile = resolve_profile(spec.profile)
if profile then
for key in pairs(PROFILE_FIELDS) do
if key ~= "env" and resolved[key] == nil then resolved[key] = profile[key] end
end
resolved.env = merge_env(profile.env, spec.env)
end
-- The two open-time settings resolve through the GLOBAL chain
-- (Q#TC2b): they are read before the identity buffer exists, so there
-- is no terminal to resolve a buffer-local against.
if resolved.scrollback_rows == nil then
resolved.scrollback_rows = pmacs.config.get("terminal.scrollback-rows")
end
if resolved.command == nil then
resolved.command = os.getenv("SHELL") or "/bin/sh"
end
local buffer = raw_open(resolved)
bind_terminal_keys(buffer)
return buffer
end
pmacs.command.define {
name = "terminal",
description = "Open a terminal running $SHELL (or /bin/sh).",
description = "Open a terminal running the configured profile, or $SHELL.",
fn = function(profile)
return terminal.open { profile = profile }
end,
}
-- Q#TC10: the opening binding. `COHERENCE.md` Priority 1 names a
-- terminal keybinding as part of protecting the golden journey, and §2
-- step 8 grades the terminal "works but undiscoverable". `C-c` is
-- already a live global prefix (fold's `C-c @ ...`), so this is a new
-- leaf under it rather than a shadow.
--
-- Named limitation: unreachable from INSIDE a terminal window, where
-- `C-c` is consumed as the escape. `M-x terminal` still works there.
pmacs.keymap.bind { scope = "global", sequence = "C-c t", command = "terminal" }
-- === Copy mode (Stage 2, Q#TC6) =========================================
--
-- `terminal.copy-mode` MATERIALIZES the retained rows into an ordinary
-- read-only document buffer instead of adding a modal state to the
-- terminal. That choice is the whole design:
--
-- * isearch, motion, selection, `M-w` and the kill ring all work with no
-- new substrate — the snapshot is a rope, so `SearchStore` and the
-- existing match painting apply unchanged;
-- * "keys must not reach the child" dissolves structurally rather than
-- being guarded: the transport arm keys on `is_terminal(buffer)`, and
-- a snapshot buffer is not a terminal, so it never fires;
-- * the dispatch-shadow count stays at SIX (`COHERENCE.md` §6) and
-- `describe-key` keeps telling the truth, because the bindings are
-- buffer-local and inspectable.
local raw_copy_retained = assert(terminal._copy_retained,
"pmacs.terminal._copy_retained is required")
-- An ARRAY of `{ terminal = <buf>, buffer = <buf> }`, scanned linearly and
-- compared with `==`, following dired's handle table (F7).
--
-- Not `snapshots[name]`, and not `snapshots[buf]`, for two separate
-- reasons — both of which were live defects in review round 1:
--
-- * **A terminal name is not a unique key.** `TerminalManager::open`
-- uniquifies only the DERIVED name; an explicitly passed
-- `name = "*same*"` is inserted verbatim
-- (`src/terminal/session.rs`, `if spec.name.is_some()`). Two valid
-- terminals can therefore share a name, and a name-keyed table gives
-- them one snapshot between them: the second invocation silently
-- retargets it, `q` returns to the wrong terminal, and killing either
-- one removes the shared buffer.
-- * **A buffer handle is not a stable table key.** `BufferIdLua`
-- implements `__eq` but each wrapper is a distinct table key, so
-- `snapshots[buf]` would miss on a freshly minted handle for the same
-- buffer. Comparison works; hashing does not. Hence the scan.
local handles = {}
-- Compact dead entries first, so a command in a killed snapshot sees
-- "not in copy mode" rather than operating on dead state.
local function live_handles()
local live = {}
for _, h in ipairs(handles) do
local term_ok, term_valid = pcall(h.terminal.is_valid, h.terminal)
local snap_ok, snap_valid = pcall(h.buffer.is_valid, h.buffer)
if term_ok and term_valid and snap_ok and snap_valid then
live[#live + 1] = h
end
end
handles = live
return live
end
local function handle_for_terminal(term_buf)
if term_buf == nil then return nil end
for _, h in ipairs(live_handles()) do
if h.terminal == term_buf then return h end
end
return nil
end
local function handle_for_snapshot(buf)
if buf == nil then return nil end
for _, h in ipairs(live_handles()) do
if h.buffer == buf then return h end
end
return nil
end
local function buffer_name(buf)
local ok, described = pcall(pmacs.describe.buffer, buf)
if ok and described then return described.name end
return nil
end
local function buffer_named(name)
for _, id in ipairs(pmacs.buffer.list()) do
local ok, described = pcall(pmacs.describe.buffer, id)
if ok and described and described.name == name then return id end
end
return nil
end
-- `*terminal:bash*` -> `*terminal-copy: terminal:bash*`. The surrounding
-- asterisks are stripped before nesting so the result reads as one
-- generated-buffer name rather than two.
local function snapshot_base_name(term_buf)
local name = buffer_name(term_buf) or "terminal"
return string.format("*terminal-copy: %s*", (name:gsub("^%*", ""):gsub("%*$", "")))
end
-- How far the `<2>`, `<3>`, ... disambiguation walks before giving up.
local NAME_VARIANT_LIMIT = 99
-- `pmacs.buffer.create` takes any caller-chosen name, so a foreign buffer
-- may already be called `*terminal-copy: sh*` — and two same-named
-- terminals legitimately produce the same base name. Painting into a
-- buffer we did not create would clobber a user's data through
-- `bypass_intercept`, so **found-by-name is NOT adoption**: ownership
-- means "this buffer is in the handle table above", exactly as in dired.
local function unique_snapshot_name(term_buf)
local name = snapshot_base_name(term_buf)
if buffer_named(name) == nil then return name end
for i = 2, NAME_VARIANT_LIMIT do
local candidate = string.format("%s<%d>", name, i)
if buffer_named(candidate) == nil then return candidate end
end
error(string.format(
"terminal.copy-mode: %s is taken and no free variant remains", name), 0)
end
-- Q#TC7: the snapshot text comes from the SAME serializer selection-copy
-- uses, so soft wraps, wide glyphs, clusters and trailing blanks cannot
-- drift between the two.
local function render_snapshot(record)
local text = raw_copy_retained(record.terminal) or ""
-- The owner-authorized write, and the ONLY one this buffer accepts.
--
-- Not `delete`+`insert` with `bypass_intercept` (review round 2): that
-- leaves the buffer writable at the rope, and it leaves undo history
-- behind. `Buffer::undo` reaches the rope through `ensure_writable`
-- without consulting the intercept chain, so a single `C-/` — or
-- `M-x buffer.undo`, which no buffer-local rebinding can take away —
-- replaced a freshly rendered snapshot with an empty buffer.
-- `set_generated_contents` writes, discards the history, and leaves
-- `read_only` asserted, so undo/redo and remote CRDT imports are all
-- refused at the rope. Its binding also fans the resulting edit out to
-- the windows showing this buffer and to replica mirrors (review round
-- 3) — a rope write alone leaves a displaying window indexing the new
-- contents with stale line offsets.
pmacs.buffer.set_generated_contents(record.buffer, text)
end
local function claim_snapshot(term_buf)
-- Q#TC8: re-invoking against the same terminal refreshes IN PLACE.
-- Identity is the terminal BUFFER, so two same-named terminals get two
-- snapshots and neither can retarget the other's.
local existing = handle_for_terminal(term_buf)
if existing then return existing end
local name = unique_snapshot_name(term_buf)
local buf = pmacs.buffer.create(name)
local record = { terminal = term_buf, buffer = buf }
handles[#handles + 1] = record
-- Q#TC6a — BOTH calls, and the protection is now LAYERED. Review
-- round 2 changed what each one is for.
--
-- `set_generated_contents` leaves `read_only` asserted at the rope, so
-- on the DAEMON side undo, redo, ordinary edits and imported CRDT ops
-- are all refused by `ensure_writable()`. The intercept below is no
-- longer the daemon's guard; it survives to give a dispatching edit a
-- named error instead of a bare refusal.
--
-- `set_round_trip_input` still guards the half `read_only` cannot
-- reach: a semantic frontend applies optimistically in its own MIRROR
-- before the daemon ever sees the op. `dispatch_idle_for` reports
-- false while this buffer is focused, so the mirror never mutates and
-- no op is emitted to be refused. That is the layering — rope-level
-- read-only protects the daemon copy, round-trip input protects the
-- replica copy — and neither substitutes for the other.
pmacs.buffer.add_intercept(buf, function()
error(name .. " is read-only")
end)
pmacs.buffer.set_round_trip_input(buf, true)
pmacs.keymap.bind { scope = "buffer", buffer = buf,
sequence = "g", command = "terminal.copy-refresh" }
pmacs.keymap.bind { scope = "buffer", buffer = buf,
sequence = "q", command = "terminal.copy-quit" }
-- Q#TC8 lifecycle, both directions. Killing the terminal takes ITS
-- snapshot with it — `record`, captured here, not "whatever is
-- currently filed under this name"; killing the snapshot alone leaves
-- the terminal running, and `live_handles` compacts the entry out so a
-- later invoke rebuilds.
--
-- `on_removed` is sound here because every user-facing kill path
-- routes through `pmacs.buffer.kill`, which fires the callbacks. The
-- terminal manager's own `prune` does not — but it never removes a
-- buffer either; it REACTS to one already gone from the registry. A
-- child exiting therefore leaves both the terminal and its snapshot
-- alive, which is what makes reading back a finished command's output
-- work at all.
pcall(pmacs.buffer.on_removed, term_buf, function()
local ok, valid = pcall(record.buffer.is_valid, record.buffer)
if ok and valid then pcall(pmacs.buffer.kill, record.buffer) end
end)
return record
end
-- The snapshot record whose buffer the active window shows, or nil.
local function snapshot_for_current_buffer()
return handle_for_snapshot(pmacs.window.buffer())
end
function terminal.copy_mode(term_buf)
term_buf = term_buf or pmacs.window.buffer()
assert(term_buf, "terminal.copy-mode: no active buffer")
if not terminal.is_terminal(term_buf) then
error("terminal.copy-mode: the current buffer is not a terminal", 0)
end
local record = claim_snapshot(term_buf)
render_snapshot(record)
pmacs.window.switch_buffer(record.buffer)
return record.buffer
end
pmacs.command.define {
name = "terminal.copy-mode",
description = "Open a searchable read-only snapshot of this terminal's scrollback.",
fn = function() return terminal.copy_mode() end,
}
pmacs.command.define {
name = "terminal.copy-refresh",
description = "Re-snapshot the source terminal into this copy buffer.",
fn = function()
return terminal.open {
command = os.getenv("SHELL") or "/bin/sh",
}
local record = snapshot_for_current_buffer()
if not record then return end
if not record.terminal:is_valid() then
pmacs.editor.set_status("terminal.copy-refresh: the source terminal is gone")
return
end
render_snapshot(record)
end,
}
pmacs.command.define {
name = "terminal.copy-quit",
description = "Return to the terminal this copy buffer was taken from.",
fn = function()
local record = snapshot_for_current_buffer()
if not record then return end
if record.terminal:is_valid() then
pmacs.window.switch_buffer(record.terminal)
end
end,
}

View File

@ -0,0 +1,183 @@
-- typed_edit.lua --- the typed-character consumer chain (Arc 8 Stage 4a).
--
-- `pmacs.editor.take_typed_edit()` is ONE-SHOT and per-frontend (Q#AP9):
-- the first `buffer.after-edit` callback to call it clears the slot, and
-- every later callback in the same fan-out --- including a nested manual
-- `pmacs.hook.run` --- sees nil. That was survivable only because
-- auto-pairing was the sole consumer, which was never a property anyone
-- chose. A second independent caller gets nil or steals the record from
-- pairing depending on hook registration order, and registration order
-- is not a contract.
--
-- This module makes it one. It owns the single `buffer.after-edit`
-- subscriber that reads the record, and offers that one read to
-- consumers registered through `pmacs.typed_edit.add_consumer`:
--
-- local handle = pmacs.typed_edit.add_consumer {
-- name = "auto-pair", -- for error reporting
-- priority = 100, -- LOWEST runs FIRST
-- fn = function(rec) ... return claimed end,
-- }
-- pmacs.typed_edit.remove_consumer(handle) -- -> true if it was live
--
-- A consumer returns whether it CLAIMED the edit; the first that claims
-- stops the chain. "Claimed" means the chain stops, not that an edit was
-- made --- Stage 4b's abbreviation expander claims every keystroke that
-- extends a pending abbreviation precisely so that auto-pairing does not
-- also react to it (Q#LN22).
--
-- Priority is an explicit number rather than load-order-implied, because
-- the ordering is load-bearing (Q#LN22: 64 Lean abbreviation keys
-- contain a character in the `lean4` pair set, and pairing running first
-- corrupts them) and a reader must be able to check it without
-- reconstructing `src/editor.rs`'s include list.
--
-- ORDERING CONTRACT: this chunk loads BEFORE pair.lua, which registers
-- into it, and therefore before lsp.lua. That preserves Q#AP7 --- see
-- pair.lua's header and the load site in `src/editor.rs`.
--
-- Framing: docs/lean4-mode-framing.md Q#LN10.
pmacs.typed_edit = pmacs.typed_edit or {}
-- Consumers in run order: lowest `priority` first, registration order
-- breaking ties. Maintained by ordered INSERTION rather than
-- `table.sort`, which is not stable in Lua --- equal priorities would
-- otherwise resolve arbitrarily, and "ties broken by registration
-- order" is part of the stated contract, not an incidental property.
local consumers = {}
-- Handles are opaque to callers; only identity matters. An integer
-- counter is enough because nothing ever reuses one.
local next_handle = 0
-- `math.huge` is the only portable spelling of infinity available in
-- both LuaJIT and 5.4, and NaN is the only value not equal to itself.
local INT32_MIN, INT32_MAX = -2147483648, 2147483647
-- Register a typed-edit consumer; returns an opaque handle for
-- `remove_consumer`. Argument errors throw: registration happens at
-- chunk-load or config-load time, where a throw is a visible startup
-- failure rather than a silently missing feature. Nothing in the
-- after-edit path throws --- see the fan-out below.
function pmacs.typed_edit.add_consumer(spec)
if type(spec) ~= "table" then
error("pmacs.typed_edit.add_consumer: spec must be a table", 2)
end
local name, priority, fn = spec.name, spec.priority, spec.fn
if type(name) ~= "string" or name == "" then
error("pmacs.typed_edit.add_consumer: name must be a non-empty string", 2)
end
-- A bare `type(priority) == "number"` admits NaN and the infinities,
-- and EVERY ordered comparison against NaN is false --- so a NaN
-- consumer silently lands wherever the insertion scan happens to give
-- up, and the lowest-first contract other consumers depend on stops
-- holding. Bounded integers match `pmacs.completion.register`, whose
-- priority is an i32 on the Rust side.
if type(priority) ~= "number" or priority ~= priority
or priority == math.huge or priority == -math.huge
or priority % 1 ~= 0
or priority < INT32_MIN or priority > INT32_MAX then
error("pmacs.typed_edit.add_consumer: " .. name ..
": priority must be a finite integer in [-2147483648, 2147483647]", 2)
end
if type(fn) ~= "function" then
error("pmacs.typed_edit.add_consumer: " .. name ..
": fn must be a function", 2)
end
-- STRICTLY-greater comparison, so a new consumer lands AFTER every
-- already-registered consumer of equal priority. That is exactly the
-- registration-order tiebreak; `>=` here would silently reverse it.
local at = #consumers + 1
for i, c in ipairs(consumers) do
if c.priority > priority then
at = i
break
end
end
next_handle = next_handle + 1
local handle = next_handle
table.insert(consumers, at,
{ handle = handle, name = name, priority = priority, fn = fn })
return handle
end
-- Unregister a consumer by the handle `add_consumer` returned. Returns
-- true if it was registered, false otherwise (so a double-remove is a
-- reportable no-op rather than a throw). Without this, re-evaluating a
-- config or reloading a package accumulates callbacks permanently ---
-- the leak COHERENCE.md §13 already records against `pmacs.hook.add`,
-- which this chain would otherwise inherit and spread.
function pmacs.typed_edit.remove_consumer(handle)
for i, c in ipairs(consumers) do
if c.handle == handle then
table.remove(consumers, i)
return true
end
end
return false
end
pmacs.hook.add("buffer.after-edit", function()
local ed = pmacs.editor
-- ONE read for the whole fan-out (Q#AP9). The record may be nil ---
-- paste, programmatic mutation, manual hook run, a replicated CRDT
-- op, a stale `this_command` --- and consumers are called ANYWAY,
-- with nil. That is deliberate: "this fan-out carried no typed edit"
-- is information a consumer acts on. Auto-pairing's test seam
-- observes the non-event through it, and Stage 4b abandons a pending
-- abbreviation that an unrelated edit invalidated. Skipping the
-- fan-out on nil would leave both reading stale state.
local rec = ed.take_typed_edit and ed.take_typed_edit()
-- Iterate a SNAPSHOT. A consumer may register or remove consumers
-- while the chain is running, and `table.insert`/`table.remove` on
-- the live array shifts indices under `ipairs` --- a consumer that
-- registers a lower-priority one shifts itself forward and runs
-- twice, and repeating that is unbounded. Registrations and removals
-- made during a fan-out therefore take effect on the NEXT fan-out.
local snapshot = {}
for i, c in ipairs(consumers) do
snapshot[i] = c
end
for _, c in ipairs(snapshot) do
-- Each consumer gets its OWN copy of the record. The table handed
-- out is plain Lua data, so a declining consumer could otherwise
-- edit `rec.char` in place and the next consumer would act on the
-- forged value --- auto-pairing reads `rec.char` to decide what to
-- close, so a rewritten `char` makes it insert a pair the user
-- never typed. Every field is a scalar or an opaque id, so a
-- shallow copy is a complete snapshot.
local mine = nil
if rec ~= nil then
mine = {}
for k, v in pairs(rec) do
mine[k] = v
end
end
-- Contain the consumer. A throw here would skip every LATER
-- consumer in the chain and mark the whole `buffer.after-edit` run
-- failed; the other subscribers still run, because all-must-succeed
-- collects errors and continues (`src/hook.rs`'s
-- `run_all_must_succeed`), but one broken consumer must not be able
-- to silently disable the ones behind it. This matches pair.lua's
-- existing never-throw-from-after-edit discipline.
local ok, claimed = pcall(c.fn, mine)
if not ok then
-- Rendering is itself protected: a Lua error may be any value,
-- including a table whose `__tostring` throws, and an escaping
-- error here would defeat the containment above.
local shown, rendered = pcall(tostring, claimed)
if not shown or type(rendered) ~= "string" then
rendered = "<unprintable error>"
end
pcall(ed.set_status,
"typed-edit consumer '" .. c.name .. "' failed: " .. rendered)
elseif claimed then
return
end
end
end)

View File

@ -1,10 +1,23 @@
# Active work — cross-machine resume ledger
**Snapshot: 2026-07-25.** This file records volatile work that has not
**Snapshot: 2026-07-28.** 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.
**One lane below is retained past its merge, and says so at its own
head**: the PTY terminate diagnostic (#176), because no landed-doc PR
owns moving its facts to `docs/agent-handoff.md` yet, and rule 4 removes
a lane only *after* that move. Every other merged lane has been removed —
the Lean 4 and GPU-terminal-input headers this paragraph used to
disclaim are gone, as are the inline-math (#172), dired (#169), and
terminal config + copy mode lanes — the last of these was #180's work,
folded into #182 so two open PRs would stop re-conflicting in this file.
**Trust the canonical-base line below over any lane header**: if a PR
number appears in `git log --first-parent githubsucks/main`, it has
landed regardless of what a lane says.
## Repository authority
- Canonical development URL:
@ -14,11 +27,16 @@ backlog.
machine-local: `origin` may name this canonical URL, a release mirror,
or something else, and therefore has no authority by name alone.
- Canonical base at this snapshot:
`githubsucks/main` @ `d152120` (the bottom-panel landed-doc refresh #156
atop the inline-math slice #158, dired Stage 1 #165, the GPU terminal
input fix #166, Lean 4 Stage 2 #161, the dired framing #164,
COHERENCE.md #163, find-file #162, Lean 4 Stage 1 #160, and the minimap
blank-slab fix #159; protocol v20).
`githubsucks/main` @ `7fd646d` (Journey/GPU directory-target ratchet
#183, atop Journey Stage 1a #182 and the previously recorded landed
work; protocol v20). The previous snapshot named `c2d56ff`, and **the
recovery floor advances with it**: the check below now requires
`7fd646d` or newer, so a tree at `c2d56ff` no longer passes. That is
deliberate — the floor moves with the base, because a check that
accepts an older commit than the declared base passes on a tree the
rest of this file does not describe.
**Lanes below that name an older base have not been re-based; derive
their integration surface from `git diff <their base>..main`.**
- On the transfer source, `origin/main` named a release mirror at
`d3fa632` and lagged badly. On the current destination, `origin` names
the canonical URL. This difference is why all recovery begins by
@ -52,365 +70,487 @@ git worktree list
git status --short --branch
```
The `git log` command must expose `d152120` or a newer intentional main.
The `git log` command must expose `7fd646d` — the base named above — or a
newer intentional main. Keep this threshold and the canonical-base line in
step: a recovery check that accepts an older commit than the base it
declares canonical will pass on a tree the rest of this file does not
describe.
If it does not, stop and repair the remote/fetch configuration.
## Lean 4 lane (Arc 8) — Stage 1 MERGED; Stage 2 IN REVIEW (PR #161)
## PTY terminate diagnostic lane — MERGED (PR #176)
- Stage 1 **merged as #160** (`main` @ `0827dd1`, 2026-07-25, one review
round, all twelve checks green). Branch `githubsucks/lean4-stage1`
retained; it was worked in the shared checkout (no sibling worktree).
- 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#LN119), 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 — multi-root LSP server affinity (Q#LN15)
> **Lane retained deliberately, and it is the next one to close.** #176
> merged into `main` @ `bf8878f` (2026-07-26); rule 4 below removes a
> merged lane, but only after its durable facts reach
> `docs/agent-handoff.md`. **That absorption is unowned** — no landed-doc
> PR exists for #176 — so removing the lane now would delete the record
> instead of moving it. Whoever opens that PR removes this section.
- Portable branch: `githubsucks/lsp-multi-root-affinity`, shared checkout,
based on `githubsucks/main` @ `0827dd1`. Named for the substrate, not
for Lean: **the diff contains no Lean content**, because `ensure_server`
is the one server-affinity function every LSP language shares and a
cross-cutting change to it must not be reviewable only as a Lean
feature.
- Three files, no protocol change: `src/lua_bindings/mod.rs` (the
`lsp.list()` row builder gains `root_uri` + `cwd`),
`builtin/runtime/lsp.lua` (`project_root_for` returns `root, source`;
`ensure_server` hoists it above the reuse loop and matches on it),
`tests/lsp_multi_root_acceptance.rs` (9 tests, acceptance 1321).
- **The rule that keeps this from regressing every other language: the
affinity key is the root only when a root was actually FOUND.**
`project_root_for` never returns nil for a file with a path — its last
resort is the file's own directory — so a naive `(language_id, root)`
key gives every directory of loose scratch files its own server, for
every language. `source` is `"config" | "detected" | "fallback"` and
only the first two become a key.
- **Wire-identical for the fallback case, and that is provable rather
than hoped.** Matching is on the spawned spec's `root_uri` (nil matching
nil), so the fallback spawn passes `root_uri = nil`; `cwd` still carries
the directory and `build_initialize` derives the identical `rootUri`
from `cwd` when the field is None, using a percent-encoder with the same
allowed set as Lua's `file_uri_for`. `build_initialize` (`src/lsp.rs`)
is the **only** reader of `spec.root_uri` in the tree.
- Deliberate behavior change, asserted not discovered: a server
hand-spawned from `init.lua` with only `cwd` set also reads back nil, so
a root-bearing attach will not adopt it.
- `config[language].root` may now be a `function(path) -> string|nil`,
memoized per directory — needed because the hoist puts root resolution
on every attach rather than every spawn. The memo is keyed **weakly by
the resolver function itself**, so replacing `config[lang].root` cannot
serve a root the previous resolver computed. This is Q#LN8's
generalization landing early; the Lean resolver that uses it is Stage 3.
- Bite-verified three ways: 5/9 fail against the pre-change `lsp.lua`,
8/9 against the pre-change `mod.rs`, and — the one that matters most —
installing the naive always-key-on-root variant fails acceptance 20 and
21 exactly as Q#LN15 part 2 predicts. The four that survive the first
bite (13, 15, 16, 19) are the regression pins; passing on both sides is
their job.
- Every fixture sets `pmacs.project.set_search_boundary` at its own
tempdir root. Without it the marker walk climbs to the filesystem root
and a stray `.git` above the temp directory turns the markerless cases
into detected ones — the assertions would still pass while testing
nothing.
- **Found but not fixed here (pre-existing, own lane):** `ensure_server`
never forwards `cfg.restart` to `pmacs.lsp.spawn`, so a
`restart = "never"` in `pmacs.lsp.config[lang]` is silently dropped on
the auto-attach path. At least one existing test sets it believing it
takes effect. Out of scope for a PR whose acceptance 16 pins existing
attach behavior as unchanged.
- **Review round 1 addressed.** The blocker was process, not design: the
test file was committed *before* `cargo fmt` ran, so the fix sat
uncommitted in the working tree and the branch as pushed failed the
first gate. The reported "fmt clean" described the worktree, not the
branch — gate results are only meaningful when run against the pushed
tree. Also added the two pins review asked for (a **string** `config
.root` as an affinity key — acc17 only covered the function form; and
`root = false` reading as unset), each bite-verified against exactly
the mutation it targets and neither against the other. And documented
the canonicalization obligation: the `"detected"` arm is canonicalized
for free, a **configured** root is not, so on macOS a resolver
returning `/var/…` and a detected `/private/var/…` are different keys
for one directory. Stage 3's Lean resolver is the first real consumer,
so the obligation is written at the point of use.
- Verification on this branch: `cargo fmt --check` clean; strict
workspace Clippy clean; 1,826 default + 2,003 CRDT library tests;
multi-root 11/11; M4 121; statusline 7; completion popup 9; auto-pair
45; required GPU 155; **isolated-config workspace sweep 3,164 across 91
suites**; `git diff --check` clean. The sweep needs an isolated
`XDG_CONFIG_HOME` and `-- --skip basedpyright`.
- Portable branch: `githubsucks/pty-terminate-eperm`; worktree
`../pmacs-math-slice`. **PR #176**, base `main`, based on `ccf29e3`
with `c93f9ee` (#175) merged in.
- Approved framing: `docs/process-signal-tolerance-framing.md`
**revision 4**, after three review rounds.
- **Diagnostic only. No disposition change.** Every call that failed
before still fails, with no state transition and no reap-ledger
arming. `src/process.rs` is the only source file touched.
- **Why nothing is fixed:** revisions 13 each proposed a *tolerance*
rule and all three were rejected as unsound in the same way — each
concluded something about a process from something that was not about
that process. Rev 1 from an errno alone (EPERM means the caller lacks
permission, not that the id was recycled); rev 2 from `try_wait`,
which observes the spawned **leader** while a PTY signal targets
`-tcgetpgrp(...)`, entities that diverge exactly when job control has
moved the terminal; rev 3 from group-directed **ESRCH**, which proves
only that the selected foreground group vanished.
- **Two facts that killed the original argument.** `group = true` is
*rejected* for PTY mode at spawn (`src/process.rs:1428-1429`), so the
reap ledger never applies to the PTY path at all; and the ledger
comment (`:1075`) says EPERM "cannot happen for our own children" and
drops the entry for **bounded growth** — not a ruling that EPERM means
dead.
- **The CI evidence never established the child had exited.** The probe's
last source statement is a file write and CPython teardown does not
synchronise with it, so no tolerance rule could even be shown to fix
the symptom. That is the whole reason the lane is diagnostic.
- What ships: a failing `kill` now reports five separate facts — target
source, target kind/value, spawn-time group, errno, and the leader's
real `try_wait` state. The test seam injects the **kill result only**,
never the observation, so the real `ChildHandle::try_wait` runs against
the real child.
- **Not "strictly additive".** `try_wait` reaps and caches, so an exited
child may be reaped earlier than otherwise. Safe because
`portable-pty` 0.9.0 returns a `std::process::Child` on Unix and
delegates `try_wait` to it, so `poll_one` still sees the cached
status — pinned by an exactly-one-terminal-event test rather than
assumed.
- Round-1 review fixes: the exited-child tests no longer use a fixed
sleep as proof of exit (nix's `waitid` is unavailable on macOS and
`libc::waitid` needs `unsafe`, which the crate forbids), instead
driving the production diagnostic in a bounded loop until it observes
the exit; and every assertion is now exact message equality built from
the kernel-assigned pid, since the substring forms would have accepted
a hardcoded target or a wrong exit code.
- Bites, all verified rather than assumed: tolerating the failure fails
the disposition test; stubbing the leader observation fails three
tests including the one-event pin; a hardcoded target fails four; a
wrong exit code fails two.
- **The sweep found a real defect in these tests, not a flake.**
`observing_the_leader_does_not_consume_the_exit_event` failed with
"process ProcessId(26) is not running": the pid helper drained for
`Started`, and **`drain_until` ticks**. A tick can observe an
immediately-exiting child and move the record out of `Running`, after
which `signal` never reaches the diagnostic at all, so the bounded
loop spun to its limit. It passed standalone because the drain
returned on `Started` before `poll_one` saw the exit; only load lost
the race. Fast-exiting children now read the pid straight from the
supervisor record (no tick), and the loop fails fast if the record
left `Running`. **Verified under matched load: 0/15 with all 16 cores
saturated, while the old ticking helper fails 1/10 — the fix is
load-bearing.**
- Verification: fmt, `git diff --check`, strict workspace clippy clean;
lib 1,838 + CRDT 2,015 (both +6, exactly the new tests); GPU 202; M4
121; bottom-panel 46; compile-mode 67; vterm 9/6/5; **isolated-config
`--no-fail-fast` sweep 3,258 across 93 suites, zero failures**.
Earlier sweeps on this branch showed two failures and then one; the
totals reconcile (3,256/2 → 3,257/1 → 3,258/0, same test count). The
two that were genuinely unrelated —
`read_dir_supersede_cancels_in_flight_predecessor` (known
pre-existing) and
`headless_snapshot_round_trip_summary_restores_the_minimap` — are
load-contention flakes; the second is structurally unreachable from
this diff, since `pmacs-gpu` depends on `pmacs-protocol` and never on
`pmacs`.
- **Parked, each with its reason:** all tolerance rules (need the
evidence this PR produces); `terminate` idempotence for an
already-reaped process (independent fix, different failure, one
feature per PR); and `signal_target`'s read-then-kill of `tcgetpgrp`
— still the most likely real fix site.
- **The lane closes when this merges.** It does not wait for the flake
to recur; the next occurrence carries its own evidence under whoever's
PR, and a Stage B framing follows then.
## Dired lane — Stage 0 MERGED; Stage 1 IN REVIEW (PR #165)
## The CRDT half of the test corpus is dark in CI — NEEDS A LANE
- Approved framing: `docs/dired-framing.md` **revision 6** — rev 5 is the
approved text (merged as its own docs PR #164), rev 6 adds §0's Stage 1
implementation notes (S1-1…S1-9). Stages 2 (marks and operations) and 3
(wdired) each get their own detailed framing after the prior stage lands.
- **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 branch: `githubsucks/dired-stage1`**, worktree
`../pmacs-dired-stage1`, based on `githubsucks/main` @ `8c86d34` (the
framing merge #164). **A fresh cut, not a rebase:** the older `dired`
branch (`ffdd642`, worktree `../pmacs-dired-arc`) was based on the
superseded `0827dd1` and carried only the framing content #164 already
put on `main`, so merging it would have reconciled two histories of one
document. It is left untouched and carries nothing unmerged.
- **Stage 1 implemented; no wire change (protocol stays v20).** What
landed on the branch:
- `builtin/runtime/dired.lua`: one buffer per directory named
`*dired:<canonical path>*` with the handle-table ownership check;
read-only intercept + `set_round_trip_input`; the `dired` major mode
and its mode-scoped keymap (`RET`/`f`, `^`, `n`/`p`, `g`, `q`, `s`);
basename cursor re-seating across every wholesale repaint;
`display_file` for file visits and same-window reuse for directory
descent; `C-x d` / `C-x C-j`; the `dired.kill-when-opening` setting.
Loaded after `window.lua`.
- `src/fs.rs`: `ReadDirTolerance`, `FsDirEntryError`, `FsDirListing`,
and one walk that either fails on a per-entry condition or records it
(Q#DR6). `src/async_runtime.rs` carries the listing in
`ReplyKind::ReadDir` / `JobResult::ReadDir`; `src/lua_bindings/mod.rs`
keys the Lua result **shape** on `errors.is_some()`, so the bare array
the frozen M8.2 fixture consumes with `ipairs` is untouched;
`builtin/runtime/fs.lua` validates read-op opts and **rejects unknown
keys** (a typo'd `tolerant` used to degrade silently to fatal).
- `src/editor_core.rs` + `src/lua_bindings/mod.rs`:
`normalize_buffer_path` is `pub` and exposed as
`pmacs.path.canonicalize` — Q#DR2's preferred end state, so no Lua
mirror exists and Stage 2 owes no mirror removal. This makes B2
("tolerant `read_dir` is the only Rust change") false by one small
binding, deliberately.
- `tests/dired_acceptance.rs`: 22 tests over framing items 116,
dispatch-driven; item 17 is the m8_1/m8_2/m8_3 additivity gate.
- **The framing claim the substrate falsified (S1-2):** R2-3 expected a
dedicated dired panel to carry its dedication across a descent.
`display_buffer` never replaces the buffer in a slot dedicated to
another one — it discards every side-specific parameter and falls back
to the document window (Q#BP3 2.iii), and the exact-window arm errors.
Dired does not unpin the user's panel; both arms are pinned.
- **The vacuity the bites found (S1-3):** acceptance 3c cannot pin the
descent *routing*. Dired holds focus in its own panel, so a raw
`switch_buffer` lands in the same window and every 3c assertion holds
either way. Dedication is the only discriminator, so the
dedicated-panel test is the real pin — and the vacuity is documented at
the assertion rather than relabelled.
- **The pre-existing test dired's first mode-scoped binding broke
(S1-4):** `describe_key_identifies_every_default_binding` asserted every
binding in the stack resolves through `describe.key` context-free, which
held only while the modes table was empty. It now sets the effective
context per binding and explicitly *clears* the mode for global ones,
because a leaked mode legitimately shadows a global chord of the same
name (dired's `RET` shadows `edit.newline-and-indent`).
- Durable substrate facts, independent of this arc:
- `pmacs.buffer.kill` (not `remove`) redirects windows off a doomed
buffer before removal, so `kill-when-opening` kills **after** the
replacement is displayed.
- Interactive origin does **not** survive an await: work resumed in
`tick_async` sees no `InteractiveCommandOrigin`, so `pmacs.window.*`
acts for the *ambient* active frontend (S1-9).
- Kinds are lstat-based in both `read_dir` and `stat`, so nothing in an
entry says whether a symlink points at a directory; `RET` probes by
trying to list it (S1-8).
- A path-backed buffer's *name* is its full path, not its basename —
worth knowing before writing any name assertion.
- `C-x d` takes **no** completion source on purpose (S1-5): with one,
RET on an empty field opens whatever sorts first, and
RET-on-where-you-are is the gesture the binding exists for. The field
is prefilled instead.
- **Bite verification:** 15 claims, each mutated in place and required to
fail the test that names it. `dired.lua` is new, so `scripts/bite`'s
file swap does not apply; every mutation was applied and reverted with
`git checkout --`. One came back VACUOUS and is recorded above.
- **Review round 1 addressed** (framing rev 7, S1-10…S1-12). Three
behavioral fixes, each bite-verified: `dired.revert`'s re-seat is
guarded on the active buffer (an ambient `move_to_line` after an await
moved an unrelated buffer's cursor — the buffer-level instance of
S1-9); `fmt_size` keeps the column width past ten digits, because
`_layout` is a contract Stage 3 is planned against; and the symlink
descent dropped its probe, since `open_directory`'s
changed-nothing-on-failure invariant *is* the probe (it was listing the
target directory twice). Plus a consecutive-`readdir`-error cap, because
**nothing cancels a dired listing** — it carries no supersede key, so
cancellation was never the backstop the tolerant loop implicitly relied
on. Naming/comment findings taken as-is.
- Durable process lesson, hit twice now: a mutation-bite helper restores
with `git checkout --`, which reverts to **HEAD** — so a fix must be
committed *before* it is bitten. Round 1's fixes were briefly wiped by
exactly that.
- **Canonical main integrated twice** — at `46a1b8f` (multi-root LSP
affinity #161) and again at `b889873` (GPU terminal input #166), both
merged rather than rebased per the #135/#137 precedent so the review
anchors stay addressable. Each conflict was a single doc hunk resolved
as the union: this lane owns COHERENCE's journey step 7 file half, #161
owns the in-flight list, #166 owns step 8's GPU-terminal addendum.
Three things worth carrying:
- **A conflicting PR silently stops running CI.** GitHub builds
`pull_request` runs against the merge ref, which does not exist while
the PR conflicts, so no run is created and nothing reports a
failure — the checks list simply stays as it was. Three pushes to
this branch produced no CI at all before the cause was found. Watch
`mergeable` on a long-lived lane, not just the check list.
- #161's own COHERENCE finding **falsified a claim in this lane's
module doc**: `pmacs.error` is never defined in production, so an
uncaught raise inside a `pmacs.async` coroutine does not reach
`*errors*` as the comment said. It reaches a bare `error()` inside
`pmacs._async.tick()`, whose result `tick_async` discards with
`let _ =` — i.e. nowhere. That makes dired's per-coroutine `pcall` +
`set_status` load-bearing rather than tidy, and the comment now says
so.
- **A lane in review against a fast-moving `main` needs its gates rerun
per integration, not per push.** Main advanced twice inside this
review round, and the second time landed while the first
integration's sweep was still running. The numbers below describe the
twice-merged tree.
- Verification on the twice-merged tree (`main` @ `b889873`):
`cargo fmt --check` clean; strict workspace Clippy clean; **1,832
default + 2,009 CRDT** library tests; dired acceptance **25 default +
25 CRDT**; m8_1 10 / m8_2 15 / m8_3 32 unchanged; multi-root 13 and
vterm Stage 3 5 (both suites main added, green under this lane's
`mod.rs` and `editor.rs` changes); M4 121; required GPU 155;
**isolated-`XDG_CONFIG_HOME` workspace sweep 3,205 passed across 93
suites, zero failures**; `git diff --check` clean. The sweep needs the
isolated config for the reason recorded in the bottom-panel lane
below.
- Coherence (framing §0.5, required since #163): serves `COHERENCE.md` §20
Priority 1, which names this work explicitly; journey step 7's file half
goes from no surface to a surface; **adds no interaction island** — keys
are a mode-scoped keymap, and wdired will be 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. The
audited claims this changes are updated in `COHERENCE.md` itself, per its
§25.
- **Boundary with the Journey Stage 1 arc** (`COHERENCE.md` §20 arc-cut
1): CLI directory-argument handling (`pmacs .` exits 1) belongs there,
not here — Stage 1 does **not** fix it. 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.
- **No branch, no framing yet.** Found while gating #166, then measured
properly during the vterm as-framed audit. Deliberately kept out of #166 so
a CI change would not arrive after review approval.
- **Root cause:** `.github/workflows/ci.yml` never enables the `crdt` feature
anywhere — zero hits across the workflow directory. The `test` job runs
`cargo test --all-targets --no-default-features --features luajit|lua54`.
Every `#[cfg(feature = "crdt")]` test is therefore **not compiled** in CI,
not merely skipped.
- **Measured, `--list` under CI's exact flags versus the same flags plus
`crdt`: 3,176 vs 3,449 — 273 tests dark.** Re-measured at `74301d1`
(2026-07-26; at `fe8b8ba` it read 3,170 vs 3,443, the same 273 dark —
#176 added six tests, none of them `crdt`-gated). **The number moves
with every merge and must be
re-measured, not quoted.** #168 reported 3,024 vs 3,288 — 264 dark,
177 in the library — at `1b6a084`; #178 then added CRDT-only
generated-buffer coverage, and other lanes landed CRDT tests in
between. Per target:
## GPU terminal input lane — IN REVIEW
| dark | CI | full | target |
|---:|---:|---:|---|
| 185 | 1,848 | 2,033 | **the library itself** (`src/lib.rs`) |
| 21 | 15 | 36 | `m5_5_acceptance` |
| 13 | 1 | 14 | `gpu_invocation_acceptance` |
| 13 | 1 | 14 | `gpu_initial_target_acceptance` |
| 8 | 0 | 8 | `m10_11_acceptance` |
| 6 | 0 | 6 | `auto_pair_crdt_acceptance` |
| 6 | 0 | 6 | `m10_2_perf` |
| 4 | 5 | 9 | `vterm_stage3_acceptance` |
| 4 | 0 | 4 | `m10_10_perf` |
| 3 | 0 | 3 | `compile_mode_crdt_acceptance` |
| 2 | 22 | 24 | `theme_faces_acceptance` |
| 2 | 0 | 2 | `m11_5_semantic_acceptance` |
| 1 | 14 | 15 | `terminal_copy_mode_acceptance` |
| 1 | 9 | 10 | `vterm_stage1_acceptance` |
| 1 | 7 | 8 | `statusline_segments_acceptance` |
| 1 | 10 | 11 | `gpu_font_acceptance` |
| 1 | 0 | 1 | `auto_indent_crdt_acceptance` |
| 1 | 0 | 1 | `m10_11_perf` |
- Portable branch: `githubsucks/gpu-terminal-input`, worktree
`../pmacs-gui-term-input`, based on `githubsucks/main` @ `46a1b8f`.
- Approved framing: `docs/gpu-terminal-input-framing.md` revision 2,
committed as the branch's first commit (`9a0df21`). Bug fix, not a
feature; **no protocol change (stays v20)**.
- Reported as "text input within the terminal doesn't work on GUI, this is
fine in TUI". Root cause: the dispatcher applied **both** terminal-layout
syncs to **every** attached frontend, and a semantic session satisfies both
conditions (a `term_sizes` entry from `AttachRequest` *and* a terminal
declaration). Its PTY was resized twice per tick forever — grid arm installs
the TUI placement size, semantic arm installs the declared content
rectangle, each arm's idempotence guard seeing only what the other just
wrote — so the child took a `SIGWINCH` storm at tick cadence.
- **The fix is a split, not a guard.** The grid arm is also the only per-tick
controller-liveness release a semantic frontend gets, and
`sync_semantic_terminal_layout` cannot take that over: the buffer-follow
snapshot clears the viewport declaration (`on_buffer_snapshot_sent`), so
that arm stops running in exactly the switch-away case that needs the
release. `sync_terminal_layout` is therefore split into a
frontend-kind-neutral half (panel reconcile + liveness) and a grid-only
geometry half, with the loop body extracted to
`sync_terminal_layouts_for_tick` so the exclusivity is structural and tests
drive the real thing.
- **Trap for anyone touching this again:** the release at the "no
`window_placements` entry" arm reads like liveness and is grid geometry. A
semantic frontend has no placement entry at all, so moving it into the
neutral half releases a GPU controller every tick.
- Bite-verified against **two** pre-images, because the naive guard fixes the
storm and introduces the leak:
The rows sum to 273; the table is the whole census, not its head.
| pin | `main` | naive guard | the split |
|---|---|---|---|
| settle (acc 2+3) | FAIL | pass | pass |
| controller release (acc 6) | pass | FAIL | pass |
| grid still resizes (acc 5) | pass | pass | pass |
- **The single worst line is the library.** `cargo test --lib --features crdt`
is a REQUIRED local gate in `CLAUDE.md`, and CI has never run it. 185
library tests — the whole CRDT half — are developer-machine-only, and
that count grows with every merged branch that adds a `crdt`-gated
unit test.
- **Ten suites run zero or one test in CI**, including `gpu_initial_target`
(#148's entire acceptance, 1/14), `gpu_invocation` (#141's, 1/14), and
`a37`, the Vterm Stage 3 real-daemon/real-PTY/real-wgpu path that #135
built specifically because "a decoded-message fixture would prove none of
the three fit together".
- **`a37` will report green in the new job without running, unless the
job builds `pmacs-gpu` AND sets `PMACS_REQUIRE_GPU=1`.** Measured
2026-07-26 while gating #173. `a37_real_daemon_real_pty_and_headless_gpu_
render_one_terminal_session` derives its sibling binary path from
`CARGO_BIN_EXE_pmacs`, and on a missing binary it `eprintln!`s a skip and
**returns `ok`**. A fresh worktree running
`cargo test --features crdt --test vterm_stage3_acceptance` reports **9/9
in 0.17 s having never run it**; a real run takes ~4 s. Only
`PMACS_REQUIRE_GPU=1` promotes that skip to a failure, and `CLAUDE.md`
applies that flag to `cargo test -p pmacs-gpu` — a **different package**,
so the required local gate does not cover a37 either. The `gpu-render`
job already sets the flag, which is what makes fix-shape part 2 sound;
state it as a **requirement** of that job rather than inheriting it by
luck, because a `crdt` leg added to the plain `test` job would run a37
vacuously.
- **`a37` is also load-sensitive, which changes how to read the expected
first-run failures.** It passed at `d152120` and failed at that *same
commit* twenty minutes later, with a second agent saturating the machine
with `rustc` in between; it then failed identically on `d152120`,
`04c5ad1`, and the #173 merge commit, which is how #173 established the
failure was not its own. The signature is `last_frame_text` all spaces
with `rendered_nonuniform_frames` nonzero — frames arrive, content does
not. `pmacs-gpu`'s own suite flaked the same way under the same load
(201/202, then 202/202 on immediate rerun). **So a red a37 on the first
CI run is ambiguous by construction**: before treating it as a real
failure, run the same command on the merge base, and prefer serialized
execution for this suite over retry-until-green.
- **Sort deliberate from accidental before proposing a fix.** Some of the 264
are perf suites that are `#[ignore]`d by default and belong to their own
jobs (`m10_2_perf` 6, `m10_11_perf` 1). `m10_10_perf` has **no** `#[ignore]`
and no CI job naming it, so it looks accidental. This classification is not
finished and is the lane's first task.
- **Fix shape, two parts** (the flag combination is verified to work:
`--no-default-features --features luajit,crdt` lists 10 vterm Stage 1 tests
versus 9 without):
1. a `crdt` leg on the `test` job for the non-GPU suites and the library;
2. the GPU-requiring `crdt` suites onto the existing `gpu-render` job, which
already has lavapipe and `PMACS_REQUIRE_GPU=1`
`vterm_stage3_acceptance`, `gpu_invocation_acceptance`,
`gpu_initial_target_acceptance`, `gpu_font_acceptance`.
- **Expect first-run failures, and budget for them.** These would execute in
CI for the first time ever: real PTY timing on CI runners, wgpu under
lavapipe, and daemon-socket tests at unfamiliar concurrency. Start
ubuntu-only and decide about macOS from evidence. A red first run is the
lane working, not the lane failing.
- Mitigating fact, verified rather than assumed: #166's three unit pins are
**not** `crdt`-gated and do run under CI's exact flags, including the
controller-release pin whose only job is catching the plausible wrong fix.
- **This lane also owns a `--lib --features crdt` flake, observed and
scoped without overclaiming its cause** (inherited from #178's gating,
where the terminal lane recorded it). `cargo test --lib --features
crdt` failed ~1 run in 5 on
`process::tests::setsid_escapee_is_not_reaped_and_teardown_reclaims_readers`
`active_reader_probe` returning `None` at `process.rs:3179` ("live
runtime probe"). **Pre-existing and unrelated to #178:** that branch
did not touch `src/process.rs` at all, and the test passed 10/10
standalone; the observed
failures were during parallel full-suite runs. That localizes the
trigger to suite load or interaction, but does **not** distinguish
parallelism from another full-suite effect — no serial full-suite bite
was run. The leading code-path explanation is the known `drain_until`
trap: draining for `Started` also ticks, and a tick can reap the leader
before the following `active_reader_probe`. That is an inference from
the failure site and control flow, not yet a falsified root cause.
Discriminating it belongs here. Two unnamed CRDT failures in #178's
round-2 gating are a plausible match but remain **unattributed** — no
test names were captured.
- **A second standing obstacle for this lane:** `cargo clippy --workspace
--all-targets --features crdt -- -D warnings` **fails on `main`**
measured at `74301d1`: seven errors before the build aborts, four in
`src/daemon.rs` (`useless_conversion` at 3996, missing doc backticks at
4076, `too_many_lines` 112/100 at 4083, an unneeded `mut` at 4965) and
three in `tests/vterm_stage3_acceptance.rs` (`too_many_lines` at 637
and 793, a redundant `continue` at 843). **Treat that as a lower
bound, not an inventory:** Clippy abandons the remaining targets once
one fails, and a run on an older tree surfaced a further doc-backticks
error in `tests/auto_indent_crdt_acceptance.rs:42` that this run never
reached. The
standing gate list runs Clippy without `crdt`, so these lints have
never been enforced. Any CI job that compiles the `crdt` targets has to
fix them first or it will be red on arrival.
- Real-path evidence: a quiet child trapping `SIGWINCH` reports **144 frames
in 4 s and `WINCH 1..12` on screen** against the pre-fix tree, versus a
settled screen with the fix.
- **Deliberately out of scope, named:** interactive-shell echo on a raw-mode
PTY (Q#GT5 — reproduces in-process too, so it is not the GUI/TUI
asymmetry), and a geometry change appearing to clear the visible screen
(reproduces pre-fix; why acceptance 4 latches its observation across
frames).
- Verification on this branch: `cargo fmt --check` clean; strict workspace
Clippy clean; 1,829 default + 2,006 CRDT library tests; vterm Stage 1/2/3
10 / 6 / 9 CRDT; bottom-panel Stage 1 46; M4 121; required GPU 155;
**isolated-config workspace sweep 3,177 across 92 suites, zero failures**;
`git diff --check` clean. Gates were run against the committed tree.
## Bottom-panel lane (Arc 7) — 2B-1 REGATED; PR #184 OPEN FOR REVIEW
## Bottom-panel lane (Arc 7) — Stage 1 MERGED; Stage 2 (GPU band) is next
Stage 1, the Stage 2 framing, and Stage 2A are on `main`. Framing
revision 5's three-way split of 2B was explicitly approved on
2026-07-27; revision 6 records PR #184's review correction. **Stage
2B-1 is implemented and integrated with canonical `main` @ `7fd646d`.
Review round 2's four findings are corrected at `ab7c207`; the
gate-found GPU/PTY probe barrier is corrected and the complete suite is
green at `9e20175`. The follow-up fixture-specific probe correction is
committed and proportionally regated at `9c79ce1`. PR #184 is open and
must not merge before user review.**
Stage 1 is on `main`; nothing in this arc is in flight. Stage 2 has **no
branch and no framing yet** — the approved parent framing
`docs/bottom-panel-framing.md` (rev 4) is what it re-scouts against.
- **Stage 2B-1 branch:** `bottom-panel-stage2b`, based on
`githubsucks/main` @ `7fd646d` by merge because review had begun.
Recovery: `git fetch githubsucks && git checkout
bottom-panel-stage2b`. Everything described through the integration
and gate checkpoint is committed and pushed; nothing depends on a
worktree or `/tmp`. PR #184:
<https://github.com/levineuwirth/pmacs/pull/184>.
- **Ships only the reserved v21 wire layer:** the four wire shapes,
schema version, shared cell-grid validator, and accepted-version
ladder move. The production daemon continues advertising v20 because
its `Hello` is server-first; v21 activation belongs to 2B-3. This
slice has no producer, consumer, or capability change;
`panel_capable` stays `false`, so it changes no user-visible journey
grade and existing v20 clients remain attachable.
- **Review round 1 closed:** two P1s and one P2, all corrected at
`9b364ad`: `PanelFrame` now identifies its buffer, the transport
ratchet covers the actual attach path rather than a detached codec
assertion, and shared grid bounds have one validator.
- **Review round 2 found four issues, corrected at `ab7c207`:** the
server-first `Hello` made the advertised v20↔v21 compatibility
one-way; `COHERENCE.md` and `docs/agent-handoff.md` still named only
v20 schema support; framing §9 named a nonexistent aggregate 2B
suite instead of the three exact 2B slice suites; and the panel plus
copied terminal "one byte over" fixtures were actually two bytes
over. The correction keeps production advertisement at v20, adds a
real-daemon existing-v20-client acceptance, updates all three durable
records, names the exact slice suites, and asserts both rejecting
fixtures are exactly `limit + 1`.
- **The full gate exposed and corrected a contradiction in Vterm Stage
3's headless probe at `9e20175`.** Its loop exited as soon as resize
plus two nonuniform composites were observed, while its acceptance
later required the PTY child's `VTERMROW` output in the final frame.
The v20-compatible handshake made that scheduling race deterministic:
terminal mode, five frames, and resize all succeeded, but the report
sampled a blank frame. The probe now waits for the exact child-output
observation its acceptance asserts. The formerly failing exact
GPU/PTY test passes, and the full nine-test Stage 3 target passes.
- **Follow-up review corrected the probe barrier's fixture leak at
`9c79ce1`.** The generic runner hard-coded the producer fixture's
`VTERMROW` breadcrumb, so the CAT input fixture could satisfy every
assertion but never satisfy the loop exit and waited out the
20-second safety deadline. Producer probes now name their required
frame text while input probes finish on the latched echo. The report
exposes `completion_observed`, and both paths assert it, so a
deadline-driven pass cannot hide the stall again.
- **The full gate found and corrected two 2B-1 omissions:** the
statusline version ladder still pinned v20/rejected v21, and Vterm
Stage 3 pinned v20 both structurally and in its real headless probe.
Those ratchets now expect v21 and, where applicable, reject v22.
- **Pre-integration green evidence at `b9123c2`:** formatting and strict
workspace Clippy; library **1,849 passed + 3 ignored default** and
**2,034 passed + 4 ignored CRDT**; bottom-panel Stage 1 / 2A / 2B-1
**46 / 17 / 15**; folding Stage 2 **48**; GPU font **11**; statusline
**8 CRDT**; m11_5 semantic **2 CRDT**; Vterm Stages 1 / 2 / 3
**10 / 6 / 9 CRDT**, with Stage 3's real daemon + PTY + wgpu probe
required and green; M4 **121 passed + 3 ignored + 1 filtered**;
required GPU **202**; and the isolated-config, one-invocation full
workspace sweep green on rerun. Its first pass hit the known
completion-before-supersede race in
`m8_1_acceptance::read_dir_supersede_cancels_in_flight_predecessor`;
the exact pin, its full 10-test target, and the complete workspace
rerun all passed.
- **The former deterministic red is resolved on `main`.** PR #183
corrected `gpu_initial_target_acceptance` through the public
`pmacs --gpu .` path, consumed the asynchronous dired snapshot, and
retained the managed daemon before the wait so failure cleanup remains
effective. The code integration auto-composed.
- **The previous complete post-integration gate was green at `c8895a8`:**
formatting; strict workspace Clippy; library **1,849 passed + 3
ignored default** and **2,034 passed + 4 ignored CRDT**; bottom-panel
Stage 1 / 2A / 2B-1 **46 / 17 / 15**; folding Stage 2 **48**; GPU font
**11**; statusline **8 CRDT**; m11_5 semantic **2 CRDT**; GPU initial
target and invocation **15 / 15 CRDT**; Vterm Stages 1 / 2 / 3
**10 / 6 / 9 CRDT**, including the required real daemon + PTY + wgpu
probe; M4 **121 passed + 3 ignored + 1 filtered**; required GPU
**202/202**; the isolated-config, one-invocation full workspace sweep;
and `git diff --check`.
- The first required-GPU pass was **201/202** on
`a_fraction_draws_rule_pixels_between_its_operand_rows`, a rendering
test structurally outside this lane's protocol-only GPU diff. The
exact test passed immediately in isolation with one test thread, and
the mandatory complete rerun passed **202/202**. This is retained as
classified gate evidence, not erased as a clean first pass.
- **The corrected review-round-2 head is fully green at `9e20175`:**
formatting; strict workspace Clippy; library **1,849 passed + 3
ignored default** and **2,034 passed + 4 ignored CRDT**; bottom-panel
Stage 1 / 2A / 2B-1 **46 / 17 / 16**; folding Stage 2 **48**; GPU
font **11**; statusline **8 CRDT**; m11_5 semantic **2 CRDT**; GPU
initial target and invocation **15 / 15 CRDT**; the handshake
consumers m5_5 / m5_7 / mode-system wiring **36 / 7 / 1 CRDT**
(the release-only m5 perf test remains ignored by its standing
contract); Vterm Stages 1 / 2 / 3 **10 / 6 / 9 CRDT**, including the
required real daemon + PTY + wgpu probe; M4 **121 passed + 3 ignored
+ 1 filtered**; required GPU **202/202**; the isolated-config,
one-invocation full workspace sweep; and `git diff --check`.
- An initial default-library attempt inside the restricted tool
sandbox produced three `Operation not permitted` failures in
socket-based attach tests. The authoritative outside-sandbox rerun
passed all **1,849 + 3 ignored**, and the matching CRDT run passed.
This is retained as environment classification, not presented as a
clean first attempt.
- **The fixture-specific follow-up is proportionally green at
`9c79ce1`:** formatting and strict workspace Clippy; protocol
**17/17**; bottom-panel Stage 2B-1 **16/16**; Vterm Stage 3 **9/9
CRDT** with the real daemon + PTY + required wgpu probe in **5.72 s**;
the formerly stalled CAT path **1/1 in 0.32 s**; required GPU
**202/202**; and `git diff --check`. The first Stage 2B-1 and Vterm
attempts inside the restricted tool sandbox reproduced the classified
Unix-socket `Operation not permitted` denial; their authoritative
outside-sandbox reruns passed.
- **Next ordering is fixed:** 2B-2 branches from `main` only after 2B-1
lands; 2B-3 branches only after 2B-2 lands. The daemon epoch machine
belongs to 2B-2; the GPU band and negotiated capability flip belong
to 2B-3.
- **Stage 2A MERGED as #177** (`main` @ `0a3fcd1`, 2026-07-26, all twelve
checks green at `8424172`, three review rounds). Branch
`githubsucks/bottom-panel-stage2a` and worktree `../pmacs-bp-stage2a`
are retained and carry nothing unmerged. Five commits: the classified
census routing, the painter extraction + acceptance, the lane record,
then the round-1, round-2 and round-3 review fixes. **No protocol
change; no behavior change for any frontend today** — with
`panel_capable = false` for semantic sessions,
`primary_document_window` returns `view.active` in every existing
configuration, so this is seam adoption that becomes load-bearing in
2B.
- **Stage 2A verification on its merge result:** `cargo fmt --check` clean; strict
workspace Clippy clean; **1,832 default + 2,015 CRDT** library tests;
`bottom_panel_stage2a_acceptance` **17**; bottom-panel Stage 1 46;
statusline segments 8 CRDT; m11_5 semantic 2 CRDT; GPU initial target
14 CRDT; terminal config 12 CRDT; vterm Stage 1/2 10 / 6; folding
Stage 2 48; M4 121; required GPU 202; `git diff --check` clean.
- **Every routed producer is now pinned at a seam its production caller
uses, and each pin was falsified by revert**: #1 follow, #2 lazy CRDT
upgrade, #3 `CursorByte`, #5 decorations, #7 `Viewport` (aligns
without focusing), #8 `Pointer` (aligns and focuses), #9 the
terminal-context gate, #12 statusline, #21 the publication filter,
plus the focus-class negatives. #1/#3/#21 required extracting three
named helpers, because their only production caller is
`dispatcher_loop`, which no test can drive.
- **Three lessons about the TESTS, not the code, all from review:**
(a) a *structural* test comparing the two authorities directly does
**not** catch a misrouted consumer — only consumer-level assertions
do; (b) a daemon-path test must `register_session` or the event is
dropped at the uninstalled-session check before reaching the code
under test; (c) a discriminating fixture must make the two routings
DISAGREE — comparing two non-terminal buffers, or two windows with no
selection, yields the same answer either way and proves nothing.
Round 2 found four of my own pins vacuous by exactly these shapes, and
round 3 found two more problems of the same family: a pin placed at a
HELPER while production called it from a producer (reverting only the
producer's call site left every test green), and a socket-pair
assertion whose blocking read made a regression HANG instead of fail.
Both now assert at the producer, with read timeouts on every read.
- **Review round 1 closed: 4 P1 + 2 P2, all real.** The P1s were a
stale-`Pointer` focus steal (the failed-alignment arm returned the
window, so #8's activation focused it before `dispatch_pointer`
rejected the buffer), the missing A2A-2 two-context fan-out, a census
suite that asserted the AUTHORITY rather than the CONSUMERS, and the
missing main integration. **Two of the new pins were themselves
vacuous on the first attempt** — the dispatcher test passed because an
unregistered session is dropped at `daemon.rs:1962` before reaching
the aligner, and the painter test was a fixed-point check that
survived deleting `text_view.render`. Both now fail under their own
bite.
- **`vterm_stage3_acceptance::a37` is a pre-existing flake here**, not a
Stage 2A regression: measured **6/8 failures on the base commit** and
**7/8 on the branch** in matched isolated samples. It needs a real
daemon + real PTY + headless GPU and is documented load-sensitive.
It also silently returns `ok` unless `pmacs-gpu` has been built, and
is `crdt`-gated so CI never runs it at all.
- **Two suites are dark without `--features crdt`**:
`m11_5_semantic_acceptance` reports **0 tests** and
`gpu_initial_target_acceptance` reports **1** in the default config.
Both are semantic-census suites, so Stage 2A must be gated with the
feature on or its most relevant coverage never executes.
- Stage 1 merged as **#155** (`main` @ `e745068`, 2026-07-24, after two
review rounds). No protocol change. Durable substrate facts live in
`docs/agent-handoff.md` §1; the two round lessons are in §5.
- Landed-docs follow-up merged as **#156** (`main` @ `d152120`,
2026-07-25).
- **Stage 2 framing: `docs/bottom-panel-stage2-framing.md` revision 6**
is on branch `githubsucks/bottom-panel-stage2b` (revision 5 is commit
`56301ed` there),
worktree `../pmacs-bp-stage2b`. Revisions 14 remain on
`githubsucks/bottom-panel-stage2-framing` (head `4fbd47f`, four
framing commits, revision 4 at `49757e5`). Round 1 closed 2 blocking +
3 high;
round 2 closed 1 blocking + 2 high + 1 medium and decided both open
items; round 3 closed 1 blocking + 1 high + 1 medium. No open items
remain. Revision 5 adds no decision; it records the approved
2B-1/2B-2/2B-3 implementation split. Revision 6 corrects the
server-first compatibility contract, durable protocol claims, exact
acceptance-suite names, and `limit + 1` fixture. The
parent framing `docs/bottom-panel-framing.md` (rev 4) remains
authoritative, **including its acceptance criteria 3755**.
- Retained, carrying nothing unmerged: branch `bottom-panel` and worktree
`../pmacs-bottom-panel`.
- **Stage 2 obligations, already named by the framing** — the starting
point for its own framing doc: `InstanceMessage::PanelFrame` plus
`FrontendEvent::{FrontendCellGeometry, PanelResizeRows, PanelPointer}`
at the next available protocol version, gated in both directions and
each extended enum byte-pinned on its own previous final variant;
extracting `paint_frame`'s per-window body *together with* the
active-window auto-scroll preparation; routing every consumer in the
framing's §1.3 census of 23 transitive active-context reads through
`primary_document_window`; the focus-chrome surface matrix (Q#BP14b);
and Q#BP17's fold-projection parameter plus the stale invariant comment
at `src/window.rs`. Stage 3 is the adopter default flip.
- **Stage 2 ships as four serial implementation slices**, each landing
before the next branches:
**2A** = classified §1.3 census routing + `paint_frame` per-window
painter extraction (with the active-window auto-scroll preparation), no
protocol change; **2B-1** = reserved protocol schema **v21**, with
production advertisement held at v20,
(`InstanceMessage::PanelFrame` plus
`FrontendEvent::{FrontendCellGeometry, PanelResizeRows, PanelPointer}`,
gated both directions, each extended enum byte-pinned on its own
previous final variant); **2B-2** = daemon panel projection and epoch
machine; **2B-3** = compatible v21 activation, the GPU band, and the
negotiated `panel_capable` flip.
Stage 3 is the adopter default flip.
- **Correction — this entry previously mis-stated the census contract.**
It is **not** "route every consumer through `primary_document_window`".
Q#BP14 classifies the 23 reads into four classes and routes only the
**Projection** class that way; focus/input (#13#15, #23), focus chrome
and surface-routed (#16#19), and focus/session (#20) keep their own
authorities. Rerouting them would break remote-op validation and
application, `DispatchIdle`, presence, focused search/menu/completion
routing, and terminal bell ownership. The Stage 2 framing carries the
full table.
- **The GPU document bottom is three boundaries, not one.**
`text_area_bottom` (`pmacs-gpu/src/main.rs:8490`) is today
`status_band_top`, `geometry_capacity_bottom`, and
`document_text_bottom` at once. Once a band is installed they diverge:
the status chrome must stay pixel-identical at the physical window
bottom while document consumers move. A blanket rewrite of that helper
moves both together and passes an "everything moved" assertion, so the
Stage 2 criterion asserts **both directions in one scenario**. The
census is 20 production sites (8 status-owned, 12 document-owned) + 1
definition + 8 test sites = 29 matches; the framing carries the
per-site table. The three easiest to misclassify are document
completion `:6140`, minibuffer candidates `:7351`, and edge scrolling
`:8561` — each with its own visible symptom.
- **Folding Stage 3 and this arc's Stage 2 both touch the semantic
projection.** Whichever is framed second re-scouts the other's landed
state.
@ -471,6 +611,82 @@ git worktree add --track \
## Closed since the last snapshot
- **Terminal configuration + copy mode arc — BOTH STAGES MERGED, lane
removed.** Stage 1 **#173** (`main` @ `cf54270`, one review round) and
Stage 2 **#178** (`main` @ `fe8b8ba`, **four review rounds**, twelve
checks green on head `1b44c69` — verified by `head_sha`, not by the
check summary), both 2026-07-26, both with no protocol change.
Approved framing: `docs/terminal-config-and-copy-mode-framing.md` rev
4, committed as the first commit of Stage 1's branch; its Q#TC6a
carries a superseded-in-part box rather than a silent rewrite. Durable
facts moved to `docs/agent-handoff.md` §1 (the arc bullet) and §4 (the
`set_generated_contents` invariant) per rule 3 below, and to
`COHERENCE.md` §14. **Stage 2 ships eight of nine criteria and the
missing one is named** — criterion 17 needs a real GPU frontend, so it
waits on the `a37` footing; the handoff records what it must assert.
Branches `githubsucks/terminal-config` and
`githubsucks/terminal-copy-mode` with worktrees
`../pmacs-terminal-config` and `../pmacs-terminal-copy-mode` are
retained. The gate-run flake found while gating #178 moved to the CI
`crdt`-coverage lane above, which owns its discrimination.
- **Dired Stage 1 (the directory view) — MERGED as #165** (`main` @
`c8ec8f3`, 2026-07-25, after one review round). pmacs has a directory
surface: `C-x d` / `C-x C-j`, one read-only buffer per directory named
`*dired:<canonical path>*`, a `dired` major mode carrying
`RET`/`f`, `^`, `n`/`p`, `g`, `q`, `s`. No wire change (v20). The Rust is
two things — a per-entry-tolerant `read_dir` (Q#DR6), which had to be
Rust because `read_dir_blocking` fails a whole listing on any of five
per-entry conditions and a tolerant wrapper cannot be written in Lua at
all, and `normalize_buffer_path` going `pub` as
`pmacs.path.canonicalize` (Q#DR2's preferred end state, so no Lua mirror
exists and Stage 2 owes no mirror removal). The frozen m8_1/m8_2/m8_3
counts are unchanged, which is the additivity gate. 15 claims
bite-verified; one came back VACUOUS (acceptance 3c cannot pin descent
routing — dired holds focus in its own panel, so dedication is the only
discriminator) and is documented at the assertion rather than
relabelled. Its branch (`dired-stage1`) and worktree
(`../pmacs-dired-stage1`) are done; the abandoned `dired` branch
(`ffdd642`, `../pmacs-dired-arc`) was superseded by a fresh cut and
carries nothing unmerged. **Stage 2 (marks and operations) and Stage 3
(wdired) each still need their own framing**, and the frozen fixture
shrinks after Stage 3. Durable substrate facts and both new ops lessons
live in `docs/agent-handoff.md` §§1/5; the implementation notes are
`docs/dired-framing.md` §0, S1-1…S1-12. Two named forward items for
Stage 2: `apply_resource_op`'s rename rebind is exact-PathBuf-equality,
first-match-only, looked up with the raw path while stored paths are
normalized — so a directory rename strands every buffer under it, and
`pmacs.fs.rename` has zero production callers, so it can be fixed at
the primitive; and Q#DR5's seam is the main-thread drain
`AsyncRuntime::tick`, not `_take_result`, where rename settles as an
undifferentiated `ReplyKind::FsUnit` and so must be keyed on
`JobKind::FsRename`.
- **GPU terminal input (the double terminal-layout sync) — MERGED as #166**
(`main` @ `b889873`, 2026-07-25, one review round, all twelve checks green
after a macOS PTY-timing rerun). The dispatcher applied **both**
terminal-layout syncs to **every** attached frontend; a semantic session
satisfies both conditions, so its PTY was resized twice per tick forever and
the child took a `SIGWINCH` storm that made a GPU terminal untypable while
output still flowed. `sync_terminal_layout` is now split into a
frontend-kind-neutral half (panel reconcile + controller liveness) and a
grid-only geometry half, with the loop body extracted to
`sync_terminal_layouts_for_tick` so the exclusivity is structural. No
protocol change (v20). Durable lessons are in `docs/agent-handoff.md` §5;
the framing (`docs/gpu-terminal-input-framing.md` rev 2) carries three
falsified hypotheses, the two-pre-image bite matrix, and two named
out-of-scope items (Q#GT5 interactive-shell echo on a raw PTY, which
reproduces in-process and so is not the GUI/TUI asymmetry; and a geometry
change appearing to clear the visible screen, which reproduces pre-fix).
Branch `gpu-terminal-input` and worktree `../pmacs-gui-term-input` retained.
**Its landed-doc pair MERGED as #168** (`main` @ `1b6a084`,
2026-07-26): #166 recorded as landed, the CI `crdt`-coverage gap
measured (**264 tests dark workspace-wide**, 177 in the library — a
reading taken at `1b6a084` and kept here only as history. **The CI
`crdt`-coverage lane above is the authority for the live figure**;
do not quote this one forward), the
vterm audit corrected — "only 3 of 9 acceptances drive a real daemon"
was optimistic; without the frontend binary the honest number is
**2** — and the a37 findings folded into the coverage lane.
- **Inline-math slice — MERGED as #158** (`main` @ `5aa9044`,
2026-07-25). Detect → parse → layout → draw for `$…$`, entirely inside
`pmacs-gpu`, no protocol change. Verified by the user's manual pass on

View File

@ -1,8 +1,28 @@
# Agent handoff — cross-machine continuity
**Last updated: 2026-07-25, after the inline-math slice (#158) landed —
the first mathematical typesetting in pmacs — following find-file (#162),
the dired arc's Stage 0, and COHERENCE.md (#163), Lean 4 Stage 1 (#160), the
**Last updated: 2026-07-28, during bottom-panel Stage 2B-1 PR #184
review; the canonical landed base remains the Journey/GPU
directory-target ratchet (#183), following Journey Stage 1a (#182),
which made directory
startup one coherent local/daemon/GPU path and incorporated the terminal
configuration + copy mode landed-doc work (#180); following terminal
copy mode (#178) — `C-c C-t`
materializes a terminal's whole retained range into an ordinary buffer,
plus `Buffer::set_generated_contents`, the first genuinely immutable
generated-buffer write path — and its landed-doc pair (#168); following
Lean 4 Stage 4a (#179) — the typed-edit
consumer chain — and bottom-panel Stage 2A (#177), the classified census
routing that makes every Projection-class consumer ask
`primary_document_window`; the bottom-panel Stage 2 framing
(#175), terminal configuration Stage 1 (#173) — profiles, scrollback, a
per-terminal configurable escape key, and the `C-c t` opening binding —
Lean 4 stages 3a and 3b (#167, #170), pmacs' first Lean language server;
the GPU terminal input fix (#166), the double terminal-layout sync that
made a GPU terminal untypable; the CRDT undo repro (#157), the
inline-math landed-doc refresh (#172), the inline-math slice (#158), the
first mathematical typesetting in pmacs; dired Stage 1 (#165), Lean 4
Stage 2 (#161), the dired framing pair (#163/#164), find-file (#162) —
the dired arc's Stage 0 — 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
@ -25,23 +45,231 @@ 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-25)
## 1. Where the project stands (2026-07-28)
- `main` @ `d152120` (the bottom-panel landed-doc refresh #156 atop the
inline-math slice #158, dired Stage 1 #165, the GPU terminal input fix
#166, Lean 4 Stage 2 #161, the dired framing #164, COHERENCE.md #163,
find-file #162, Lean 4 Stage 1 #160, minimap blank-slab #159,
bottom-panel Stage 1 #155). Protocol unchanged at **v20**. The bullets
below describe the arcs in their own terms; this line is the
head-of-`main` anchor.
- `main` @ `7fd646d` (Journey/GPU directory-target ratchet #183, atop
Journey Stage 1a #182, incorporating terminal configuration + copy
mode landed docs #180, Lean 4 Stage 4b #181, the dired Stage 1 landed
docs #169 and the PTY-terminate diagnostic #176, terminal copy mode
#178, the GPU-terminal-input landed docs #168, Lean 4 Stage 4a
#179, bottom-panel Stage 2A
#177, the bottom-panel Stage 2 framing #175, terminal configuration
Stage 1 #173, Lean 4 Stage 3b #170, Stage 3a #167, the CRDT undo repro
#157, the inline-math landed-doc refresh #172, the bottom-panel
landed-doc refresh #156, the inline-math slice #158, dired Stage 1
#165, the GPU terminal input fix #166, Lean 4 Stage 2 #161, the dired
framing #164, COHERENCE.md #163, find-file #162, Lean 4 Stage 1 #160,
minimap blank-slab #159, bottom-panel Stage 1 #155). Protocol unchanged
at **v20** — bottom-panel Stage 2A deliberately carries no wire change.
The in-review Stage 2B-1 reserves the v21 schema but keeps the
server-first production `Hello` at v20; compatible activation belongs
to Stage 2B-3. 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).
attribution. Its §2 grades the golden journey; **Journey Stage 1a
moved that grade off "broken at step 3"** — see the arc bullet below.
- **Journey arc (P1) — Stage 1a LANDED**
(`docs/journey-stage1a-framing.md`). `pmacs .` opens a directory
instead of exiting 1, on **one** path: `resolve_target_buffer` gained a
`ResolvedTarget::Directory` arm *ahead* of the load, `EditorState::open`
became a caller of it rather than a parallel implementation, and the
daemon/GPU bootstrap shares the same arm. Which surface handles a
directory is the `path.open-directory` chain with dired as a
replaceable fallback slot. `tests/journey_acceptance.rs` is the new
cross-subsystem ratchet (steps 2, 3, 5 seeded; **stages add rows, none
removes them**). No protocol change.
- **A hook a builtin subscribes to can never be first-claimant-wins
for users.** `HookRegistry::add` only appends and builtins load
before `init.lua`, so a dired subscription would always claim before
any user listener. That is why dired is a *slot*
(`pmacs.path.directory_handler`) and not a subscriber — and why
clearing the slot has to leave startup succeeding with a status,
not exiting 1.
- **A raise and a `false` are indistinguishable in `proceed`.**
`run_short_circuit` returns `proceed = false` for both; only
`HookOutcome.errors` separates them, and it decides whether to
*report*, not whether to fall back. Getting this backwards produces a
fallback that runs after a user's resolver crashed mid-handling.
- **The listing is async; the bootstrap is synchronous.** The whole
post-await commit therefore runs against a destination captured at
request time (`pmacs.window.commit_to`), which preflights every
precondition *before* invoking the callback — dired mutates handle
state, `prev`, and paint long before it reaches anything that could
refuse, so validating at display time is four mutations too late.
Awaiting inside a commit is refused: a yield would restore the scope
while the coroutine is still parked.
- **The scope swaps `core.active_frontend`, not just an override**
`pmacs.window.buffer()`'s no-arg arm reads the ambient active buffer
directly, so dired's `prev` capture would otherwise follow whatever
frontend happened to be dispatching. The override *also* exists, and
is load-bearing in exactly one case: a commit reached from inside an
interactive command, where the origin would otherwise outrank the
ambient value. Bite-testing found N4 green without it.
- **`replace_active_buffer` does not drop the startup scratch buffer**,
despite its doc comment having claimed so for as long as it has
existed. Its body is one `switch_active_buffer` call. The comment is
corrected here; changing the lifetime is separate work.
- Stage 1b is the named remainder: compile binding + Cargo defaults,
LSP spawn guidance, welcome buffer.
- **Terminal configuration + copy mode arc — COMPLETE**
(`docs/terminal-config-and-copy-mode-framing.md` rev 4; Stage 1 #173,
Stage 2 #178; no protocol change in either, still v20). Stage 1 ships
profiles, scrollback, a per-terminal configurable escape key and the
`C-c t` opener; Stage 2 ships copy mode — `M-x terminal.copy-mode` /
`C-c C-t`.
- **The snapshot MATERIALIZES into an ordinary buffer.** That is the
arc's organizing decision: isearch, motion, selection and the kill
ring work with no new substrate, and "keys must not reach the child"
dissolves structurally, because the transport arm keys on
`is_terminal(buffer_id)` and a snapshot is not a terminal. **The
dispatch-shadow count therefore stays at six.**
- **`prune` reacts to buffer removal rather than causing it** — it
filters on `!registry.contains(buffer_id)`, so a child exiting does
**not** remove the terminal buffer. That is what makes `on_removed` a
sound teardown hook, and why a finished command's output stays
readable.
- **Ownership means "in our own handle table", never found-by-name**
(dired's F7 rule, re-learned here): snapshot writes use
`bypass_intercept`, so adopting a same-named foreign buffer clobbers
user data. Snapshot identity is keyed by **comparing buffer handles
in an array** — `BufferIdLua` implements `__eq` but each wrapper is a
distinct table key, so comparison works and hashing does not.
- **Profiles are a raw Lua table**, joining `pmacs.lsp.config` and
`pmacs.pair.sets`, because `ConfigValue` is four scalars with no
table kind. The two open-time settings resolve through the **global**
chain (they are read before the identity buffer exists); only
`terminal.escape-key` resolves per buffer, and its cache lives on
**`TerminalSession`** so its lifetime is the terminal's —
`value_epoch` alone is not a sufficient key, because it does not
advance when focus moves between terminals holding different
buffer-local values.
- **Criterion 17 is deliberately unpinned, and its bite is now stated
correctly.** A real semantic frontend proving neither copy is mutated
needs the actual GPU binary (the optimistic apply exists only in
`pmacs-gpu/src/main.rs`; the headless `SemanticClient` has no
optimistic path), i.e. the `a37` footing §5 warns about. After
`set_generated_contents` the eventual test must look for
**unauthorized mirror mutation plus daemon refusal — divergence**,
not the "mutates both sides silently" the criterion originally
specified, which can no longer happen and would pass for the wrong
reason. *A fix can invalidate a test that was never written.*
- Test instruments worth reusing: **`cat -v` is the echo probe**,
because the screen rejects C0 controls before they reach cells so a
raw echoed `Ctrl-X` is invisible; and such probes must **count
occurrences rather than test presence**, because a single-character
probe collides with the child's own banner text.
- **Lean 4 arc (Arc 8) — stages 1, 2, 3a, 3b, 4a, 4b ALL LANDED**
(`docs/lean4-mode-framing.md`; #160, #161, #167, #170, #179, #181). pmacs edits Lean 4: `arborium-lean` highlighting, a
`lean4` major mode, `⟨⟩ ⦃⦄ ⟮⟯` pairs, and a `lake serve` language
server with a Lake-aware outermost root, a lazy toolchain probe, a
one-shot `lean --server` fallback, and `waitForDiagnostics`. **No
protocol change in any stage** (still v20).
- **Two of the four stages contained no Lean at all**, and that is the
arc's organizing rule: *no PR mixes a cross-cutting substrate change
with Lean feature content.* Stage 2 made LSP server affinity
per-project-root (`ensure_server` had been reusing one server across
roots — a correctness bug for every language, not just Lean). Stage
3a added notification/response subscription seams to
`handle_server_requests`, the single shared LSP event drain, plus
`pmacs.fs.canonicalize`.
- **Two consecutive re-scouts found that rule broken by the stage
being scouted** — Stage 3 in round 4, Stage 4 in round 5, each time
by a risk column that contradicted its own prose. The rule is not
self-enforcing. Re-check every remaining stage's risk column at
scout time.
- **A configured LSP root must be a canonical absolute path.** It
reaches `file_uri_for` verbatim and that URI is the affinity key, so
one package opened by two spellings spawns two servers. Stage 3a's
`pmacs.fs.canonicalize` is the primitive; it returns nil rather than
a lossy path for non-UTF-8 input.
- **`LspManager::stop` on an already-terminal client strands it in
`ShuttingDown` forever** — `server_is_live` then counts it live so
nothing rebuilds against it, and `forget` refuses it for not being
terminal. *Stopping a dead server is what makes it un-replaceable.*
Stage 3b works around it by dispatching on state (`forget` when
terminal, `stop` when live); merely skipping the call leaves
`next_restart_at` armed. The real fix is unframed substrate work.
- **`elan` shims lie**: `lake --version` and `lean --version` can both
fail ("no default toolchain configured") on a machine where Lean
otherwise works, so `command -v lake` is worthless as a capability
check. Lean acceptance is fake-server; live smokes must be PATH-
**and** success-gated.
- Stage 3b took six review rounds, and **the same defect appeared four
times**: "the fallback silently doesn't happen," as no re-attach,
then re-attach cleared by an unrelated buffer, then satisfied by the
very server being replaced, then repairing one buffer while the rest
stayed stale. Each fix was locally right; none asked what a *global*
config swap invalidates. The durable lesson is to heal at
**consumption** — the point where a stale record is handed out — not
at the moment of the swap.
- **Stage 4a (the typed-edit consumer chain) MERGED as #179**
(branch `lean4-stage4a-typed-edit-chain`, framing rev 8; it is part
of the main anchor above). It is substrate only:
`builtin/runtime/typed_edit.lua` owns the
single `buffer.after-edit` subscriber and the single one-shot read,
`pair.lua` becomes its first registered consumer, and
`tests/auto_pair_acceptance.rs` is unchanged by zero lines
(criterion 46, verified at the diff). No protocol change, no Lean
content. The three decisions that turned out load-bearing rather
than stylistic: consumers are called **even when the record is
nil** (three existing auto-pair tests assert the non-event through
it, and 4b abandons stale pending state on it); each consumer gets
its **own copy** of the record, because pairing reads `rec.char`
and a declining consumer could otherwise forge it; and the fan-out
iterates a **snapshot**, because a consumer that registers a
lower-priority one shifts itself forward under `ipairs` and runs
twice.
- **Round 8's durable lesson: `run_all_must_succeed` does NOT abort
the fan-out.** `src/hook.rs:332` collects each callback's error and
continues to the remaining subscribers, marking only the run
failed — so an uncontained throw inside a hook subscriber does not
stop `lsp.lua` from flushing didChange. Two framing revisions
asserted the opposite to justify a `pcall`. The guard was right and
the reason was wrong, and by the time review caught it the wrong
reason had been copied into a module comment, an acceptance
criterion, a test comment, and the ledger. **Correct the source a
rationale derives from, not only the sites that quote it.**
- **Stage 4b (the Unicode input method) MERGED as #181**
(framing rev 9): a vendored
1,855-entry table generated from `leanprover/vscode-lean4@17d1d08`
by `scripts/regen-lean-abbrev`, plus a consumer registered on the
Stage 4a chain at priority 50, ahead of pairing. **A consumer
cannot both edit and let a later consumer act on the same
keystroke**: the chain hands each consumer a copy of the record made
before any consumer ran, so an edit invalidates every copy still to
be used. The expansion therefore runs on a SECOND
`buffer.after-edit` subscriber after the chain — which is how a
pair character that terminates an abbreviation still pairs
(`\alp(` → `α()`). And **deferring work past a fan-out means
owning which fan-out it belongs to**: these fan-outs NEST, so a
consumer between the expander and pairing that calls
`pmacs.hook.run` re-enters the deferred subscriber while the outer
chain is still mid-list, and the count that recognises this has to
come from a MINIMUM-PRIORITY consumer — the expander is optional
(a claim can stop the chain first) and a subscriber beside the
deferred one is too late (the nested fan-out finishes inside the
outer chain's subscriber). Its other durable facts:
the table must stay an ORDERED SEQUENCE (equal-length ties resolve
by source declaration order, which a `pairs`-iterated map cannot
express); a generator round-trip check must re-read the BYTES ON
DISK, because comparing in-memory strings cannot see an encoding
applied by the write itself; and an expansion that SHRINKS the
buffer must place the point explicitly, or every later self-insert
is silently rejected and the editor looks dead.
- **Round 9 corrected three approved acceptance criteria** by
simulating the state machine over all 1,855 entries rather than
re-reading the prose. Four review rounds over the text had not
found them, because each named an example that reads as obviously
right and is wrong only against the data.
- Remaining: stages 5 (goal panel), 6 (`#eval` output channel), and 7
(module hierarchy) are framed but not scouted against current
`main`.
- **Inline math LANDED — #158** (`docs/inline-math-slice-framing.md` rev 3;
merge `5aa9044`). pmacs renders `$…$` as typeset mathematics in the GPU
frontend. **No protocol change (still v20); the whole slice lives in
@ -99,14 +327,86 @@ commands, read `docs/active-work.md` immediately after this file.
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.
- **Stage 1 (the directory view) is IN REVIEW as PR #165** — the
builtin `dired.lua`, the per-entry-tolerant `read_dir` opt, and
`pmacs.path.canonicalize`. Its branch state, substrate facts, and
verification live in `docs/active-work.md`; this section absorbs them
when it merges.
- Protocol **v20** (`SUPPORTED=[6..=20]`; v16 = `ThemeFacts`, v17 =
`FontFacts`, v18 = `StatuslineSegments`, v19 = terminal frames/events, v20 =
the GPU initial-target semantic bootstrap family).
- **dired Stage 1 — the directory view — LANDED — #165**
(`docs/dired-framing.md` §0, S1-1…S1-12; merge `c8ec8f3`; one review
round). pmacs now has a directory surface: `C-x d` / `C-x C-j` open a
read-only listing, one buffer per directory named
`*dired:<canonical path>*`, with a `dired` major mode whose
mode-scoped keymap carries `RET`/`f`, `^`, `n`/`p`, `g`, `q`, `s`.
Protocol unchanged at **v20**. **Stage 2 (marks and operations) and
Stage 3 (wdired) each still need their own framing**; the frozen
fixture shrinks after Stage 3.
- **The Rust is confined to two things**: a per-entry-tolerant
`read_dir` (`ReadDirTolerance {Fatal, PerEntry}` →
`FsDirListing {entries, errors}`), because `read_dir_blocking` fails
a whole 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** (one error value, no partial vec); and
`editor_core::normalize_buffer_path` becoming `pub`, exposed as
`pmacs.path.canonicalize`. Only non-UTF-8 **names** stay fatal —
byte-preserving paths would be needed. The Lua result **shape** keys
on `errors.is_some()`, so the bare array the frozen M8.2 fixture
consumes with `ipairs` is untouched.
- **Exposing a core normalizer beat mirroring it in Lua.** A Lua mirror
would have been a second canonical form — the same class of bug as
the five tab-width constants (#137). Applies to any future Lua-side
path reckoning.
- **A fixed-width column must be fixed-width for every input.** The
exported `pmacs.dired._layout` (MARK 0, KIND 2, PERMS 312, SIZE 13,
MTIME 24, NAME 41) is the contract Stage 3 reads offsets from, and
`%10d` overflows at ≥10 GB, silently shifting every column right of
it. Sizes now fall back to a width-clamped magnitude (K/M/G/T/P/E).
- **An ambient action must be gated on the buffer it assumes.** A
revert's cursor re-seat settles a tick or more later, by which time
the user may have switched buffers; the paint names its buffer and is
safe, but seating is ambient. This is the buffer-level instance of
the rule below that interactive origin does not survive an await.
- **A failure IS an answer — don't probe first.** Kinds are lstat-based
in both `read_dir` and `stat`, so nothing in an entry says whether a
symlink points at a directory. `RET` tries to list it and treats the
failure as the answer; an explicit probe was a second full
`read_dir`, so a descent listed twice.
- **Unbounded per-entry error collection needs a cap when nothing
cancels the work.** A dired listing carries no supersede key, so
cancellation was never the backstop the tolerant loop implicitly
relied on (`READDIR_MAX_CONSECUTIVE_ENTRY_ERRORS = 1024`).
- **This is the first builtin with mode-scoped keys** (#129's first
non-detection consumer), which broke the pre-existing
`describe_key_identifies_every_default_binding`: it asserted every
binding resolves through `describe.key` context-free, which held only
while the modes table was empty. It now sets the effective context
per binding and explicitly **clears** the mode for global ones,
because a leaked mode legitimately shadows a global chord of the same
name (dired's `RET` shadows `edit.newline-and-indent`), plus a floor
assertion that at least one mode-scoped binding exists.
- **A dedicated panel does not carry its dedication across a descent**
— the framing expected it to. `display_buffer` never replaces the
buffer in a slot dedicated to another one; it discards every
side-specific parameter and falls back to the document window (Q#BP3
2.iii), and the exact-window arm errors. Dired does not unpin the
user's panel; both arms are pinned.
- Smaller facts worth knowing before touching this code: a path-backed
buffer's **name is its full path**, not its basename, which matters
for any name assertion; `pmacs.buffer.kill` (not `remove`) redirects
windows off a doomed buffer first, so `dired.kill-when-opening` kills
**after** the replacement is displayed; ownership is checked against
the handle table only, never the buffer name; and `C-x d` takes **no**
completion source on purpose (with one, `RET` on an empty field opens
whatever sorts first, and RET-where-you-are is the gesture the binding
exists for — the field is prefilled instead).
- Verification at merge: 1,832 default + 2,009 CRDT library tests;
dired acceptance 25 + 25 CRDT; the frozen m8_1 10 / m8_2 15 / m8_3 32
unchanged, which is the additivity gate for the `read_dir` change; M4
121; required GPU 155; isolated-`XDG_CONFIG_HOME` workspace sweep
3,205 across 93 suites. 15 claims bite-verified.
- Canonical `main` is protocol **v20** (`SUPPORTED=[6..=20]`; v16 =
`ThemeFacts`, v17 = `FontFacts`, v18 = `StatuslineSegments`, v19 =
terminal frames/events, v20 = the GPU initial-target semantic
bootstrap family). Bottom-panel Stage 2B-1's in-review schema is v21
(`SUPPORTED=[6..=21]`), but its production daemon deliberately
advertises v20: the handshake is server-first, so advertising 21
would make shipped v20 GPU/TUI clients reject before
`AttachRequest`. Stage 2B-3 owns compatible production activation.
- **Bottom panel Stage 1 (window placement + TUI side windows) LANDED —
#155** (`docs/bottom-panel-framing.md` rev 4; merge `e745068`; two review
rounds). **No protocol change (still v20).** Arc 7's substrate: pmacs now
@ -171,10 +471,26 @@ commands, read `docs/active-work.md` immediately after this file.
`bottom_panel_stage1_acceptance` 46; kill ring 30; compile 67; M4 121;
required GPU 152; initial-target 14 CRDT; all three vterm suites; folding
Stage 2 48. All 12 CI checks green at merge.
- **Stage 2 (the GPU panel band) needs its own re-framing** before
implementation and takes the next available protocol version; the
framing's §1.3 census of 23 transitive active-context reads is its map.
- **Stage 2 (the GPU panel band) is FRAMED**
`docs/bottom-panel-stage2-framing.md` rev 6, four framing review
rounds, no open framing items; the rev-5 implementation split was
explicitly approved 2026-07-27 and rev 6 records PR #184's
server-first compatibility and gate correction. It reserves
protocol **v21** and ships as four serial
implementation slices: **2A** classified census routing +
per-window painter extraction (no wire change), **2B-1** the wire,
**2B-2** the daemon projection and epoch machine, then **2B-3** the
GPU band, compatible v21 activation, and negotiated
`panel_capable` flip. Production attachment remains v20 through
2B-1 and 2B-2. Parent acceptance 3755 remains authoritative.
Stage 3 is the adopter default flip.
- **The §1.3 census is CLASSIFIED, not uniformly redirected.** Only the
Projection class (#1#12, #21#22) routes through
`primary_document_window`; focus/input (#13#15, #23), focus chrome
and surface-routed (#16#19), and focus/session (#20) keep their own
authorities. Rerouting them breaks remote-op validation and
application, `DispatchIdle`, presence, focused
search/menu/completion routing, and terminal bell ownership.
- **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
@ -681,6 +997,44 @@ commands, read `docs/active-work.md` immediately after this file.
DAP, 8 GPU splits, plus the `.ipynb` arc (its JSON-grammar
prerequisite shipped in #123).
- **GPU terminal input LANDED — #166** (`main` @ `b889873`;
`docs/gpu-terminal-input-framing.md` rev 2; one review round). The
dispatcher applied **both** terminal-layout syncs to **every** attached
frontend each tick. A semantic session satisfies both conditions — a
`term_sizes` entry from `AttachRequest` *and* a terminal declaration — so
its PTY was resized twice per tick forever: the grid arm installed the TUI
placement size, the semantic arm the declared content rectangle, each arm's
`old_size == size` guard seeing only what the other had just written. The
child took a `SIGWINCH` storm at tick cadence, which made typing into a GPU
terminal impossible while output kept flowing. TUI was structurally
unaffected.
- `EditorInstance::sync_terminal_layout` is split into
`sync_terminal_controller_liveness` (frontend-kind **neutral**: panel
reconcile + release of a controller whose window moved away — reads only
views/windows/controller, never a grid size) and
`sync_terminal_grid_geometry` (**grid only**: TUI placement + resize).
`sync_terminal_layout` survives as the composition, so `editor::run` and
`LOCAL` are byte-identical.
- `daemon::sync_terminal_layouts_for_tick` is the extracted loop body:
liveness for every frontend once per tick, then **exactly one** geometry
arm keyed on `semantic_states` membership — the same fact session
establishment uses, so the arms cannot both fire.
- **The trap, kept in a comment:** the release on a missing
`window_placements` entry reads like liveness and is grid geometry. A
semantic frontend has no placement entry at all, so moving it into the
neutral half would release a GPU controller every tick.
- Why not the one-line guard: the grid arm was also the **only** per-tick
controller-liveness release a semantic frontend got, and
`sync_semantic_terminal_layout` cannot take it over — the buffer-follow
snapshot clears the viewport declaration, so that arm stops running in
exactly the switch-away case that needs the release.
- No protocol change (v20). Gates: 1,829 default + 2,006 CRDT library
tests; vterm Stage 1/2/3 10/6/9 CRDT; bottom-panel 46; M4 121; required
GPU 155; isolated-config workspace sweep 3,177 across 92 suites.
- **Known gap, its own lane:** CI never enables `crdt`, so the Stage 3
real-path acceptance (including `a37`) is not compiled there. #166's unit
pins are not `crdt`-gated and do run. See `docs/active-work.md`.
## 2. How we work (the part that must not drift)
The user is expert and reviews deeply — they falsify framings and find
@ -756,6 +1110,72 @@ before trusting them:
## 4. Substrate invariants (do not undo; tests enforce most of these)
**Generated buffers: `Buffer::set_generated_contents` is the ONE
authorized write** (terminal copy mode #178) — lift `read_only`, replace
via a single whole-buffer `Replace` skipping intercepts, discard history,
re-assert `read_only`, and **return the `Edit`**. Three things make it a
unit rather than a convenience:
- **An intercept is not read-only.** `Buffer::undo` reaches the rope
through `ensure_writable` and never consults the intercept chain, so an
intercept-only "read-only" buffer is emptied by `M-x buffer.undo`.
Rebinding the undo *chords* buffer-locally does **not** close it —
`compile.lua`'s own comment says so ("command/menu undo stays
dispatchable"). Only rope-level `read_only` does.
- **A bare `set_read_only` would be worse than nothing**, because it also
refuses the owner's refresh — the operation such buffers exist for.
That is why the pairing, not the setter, is the primitive. There is
deliberately no Lua `set_read_only`.
- **A rope write is only half of an edit.** The returned `Edit` must be
fanned out (`notify_buffer_edit_to_windows`, which also queues the
daemon-origin CRDT op). Skip it and a displaying window keeps a
`TextView` line index describing the previous contents — the next paint
indexes the new rope with stale ranges and trips
`assertion failed: end <= self.len()` — while replica mirrors never
import the write at all.
History clearing is load-bearing twice (nothing can pop entries
`read_only` makes unreachable, so they leak), and must clear **whichever
history the buffer has**: the v0.1 stacks are bypassed in CRDT mode, where
it lives in loro's `UndoManager`. That has no `clear`, and needs none — a
manager records only what happens after construction, so
`CrdtState::clear_undo_history` rebinds a fresh one to the same doc.
**Not yet adopted — the inventory is four writer mechanisms covering
five buffers.** *Every remaining intercept-protected writer* uses the
older idiom: an erroring intercept plus `set_round_trip_input`, written
through `bypass_intercept`, with the rope left writable. All are
emptiable by `M-x buffer.undo`:
| writer | buffers | shape |
|---|---|---|
| `builtin/runtime/listview.lua:60-61` | every listview panel | delete-all + insert |
| `builtin/runtime/compile.lua` (`ensure_slot`) | `*compilation*`, `*shell-command*` | **append** per output batch |
| `builtin/commands/default.lua:869` | `*search-results*` | reset per query, then **append** per match batch |
| `builtin/runtime/dired.lua:371` | every dired buffer | whole-buffer replace |
**Do not read `ensure_slot` as covering the search panel** — it serves
`*compilation*` and `*shell-command*` only (`compile.lua:1090,1125`).
`*search-results*` is an independent panel with its own intercept,
round-trip mark and writes, and `compile.lua` names it only in a
predicate. Nor is the scope "every generated buffer": `*workers*`,
`*help*` and `*buffer-list*` are generated too but do not use this
idiom, and the REPL package's intercept
(`builtin/packages/repl/init.lua:187`) is an op-filtering editing
policy, not a read-only panel — neither group belongs to this lane.
Adoption is not a one-line swap. It inherits the fan-out obligation, and
the three appending buffers need a **streaming variant** of the
primitive; listview and dired already write whole-buffer replaces and
are the cheap half. Recorded in `COHERENCE.md` §14.
**And it does not replace `set_round_trip_input`.** The protection is
layered across two copies: rope-level `read_only` refuses the op at the
daemon; round-trip input stops a semantic frontend applying
optimistically to its **own mirror**, which a daemon-side refusal cannot
reach — the refusal arrives after the frontend has already painted, so it
buys divergence, not prevention.
**Command boundaries (Arc 2 kill-ring substrate)** —
`EditorCore.command_history: HashMap<FrontendId, CommandBoundary{this, last}>`,
per frontend. Rotate on: keybound command, self-insert, menu invoke,
@ -794,12 +1214,17 @@ buffer owns a path's recovery slot; only recover/discard release
unclaimed crash data; adopt clears the old owner's skip cache.
**Protocol** — encoding-breaking bumps are deliberate and versioned. Canonical
`main` is `[6..=20]`. v15 = `CompletionPopup` + `StatusFacts.message`; v16 =
`main` is `[6..=20]`. The in-review bottom-panel 2B-1 schema extends support
to `[6..=21]`, while `ADVERTISED_PROTOCOL_VERSION` stays 20 until 2B-3
provides compatibility-preserving activation; the server-first `Hello`
cannot advertise 21 without stranding existing v20 clients before
`AttachRequest`. v15 = `CompletionPopup` + `StatusFacts.message`; v16 =
`ThemeFacts`; v17 = `FontFacts`; v18 = `StatuslineSegments`; v19 = the vterm
terminal family; v20 = semantic `SessionBootstrapRequest` plus appended
`InitialTargetResult`. New wire surface ⇒ bump + both-frontends support +
acceptance. An APPENDED variant must be guarded by a byte pin on the PREVIOUS
final variant — its own round-trip cannot detect a discriminant shift.
`InitialTargetResult`; v21 reserves the panel frame/event family. New wire
surface ⇒ bump + both-frontends support + acceptance. An APPENDED variant
must be guarded by a byte pin on the PREVIOUS final variant — its own
round-trip cannot detect a discriminant shift.
**Fake LSP** (`src/bin/pmacs_fake_lsp.rs`) modes: `fullonly`,
`rangeonly`, `rangeonly16` (UTF-16 + fail-closed bounds validation),
@ -807,6 +1232,34 @@ final variant — its own round-trip cannot detect a discriminant shift.
## 5. Hard-won ops lessons
- **A test that skips on a missing precondition reports `ok`, and a gate log
cannot tell that apart from a pass.** `vterm_stage3_acceptance::a37` — the
only acceptance driving a real daemon, a real PTY and a real wgpu render
together — derives `pmacs-gpu` from `CARGO_BIN_EXE_pmacs` and, when that
binary is absent from the target directory, prints a skip and returns.
A fresh worktree reports the suite 9/9 **in 0.17 s having never run it**;
a real run takes ~4 s. `PMACS_REQUIRE_GPU=1` is what promotes the skip to
a failure, and the standing gate list applies that flag to
`cargo test -p pmacs-gpu`, a *different package*. Two habits follow:
build the workspace before believing any suite that reaches for a sibling
binary, and **judge such a suite by its elapsed time**, not its verdict.
- **Before attributing a red test to your branch, run it on the merge base.**
`a37` failed on the #173 branch, which looked like a regression; it failed
identically on the PR's own base and on two intermediate commits, and had
*passed* on that same base twenty minutes earlier. The variable was machine
load from a second agent compiling continuously. Load-sensitive tests make
both verdicts uninformative in isolation, so the base-commit run is the
cheapest way to tell a regression from weather — and it is much cheaper
than the bisect it replaces.
- **A daemon-side fix is not deployed until the daemon is restarted from a
tree that contains it.** #166's reporter rebuilt and saw no change: the
running daemon had been started from a shared checkout still on a pre-fix
branch, and `pmacs --gpu` attaches to whatever process already owns the
socket. Rebuilding a binary does nothing to a running process. When
validating a daemon-side fix by hand, check the running process's binary
path and start time against the tree you think you fixed —
`ps -eo pid,lstart,args | grep '[p]macs --daemon'` — before concluding the
fix failed.
- **Two operations that must be alternatives are not made alternatives by
being adjacent.** The dispatcher applied its grid and semantic
terminal-layout syncs to every attached frontend; a semantic session
@ -858,6 +1311,24 @@ final variant — its own round-trip cannot detect a discriminant shift.
trap-guarded one-file swap over read-only `git show`, with an
inverted verdict (exit 0 iff the tests FAIL against the old
version), making bite-verification machine-checkable.
- **A fix must be COMMITTED before it is bitten.** `scripts/bite`
restores by `git checkout --`, which reverts the file to **HEAD**, not
to the state it found — so any uncommitted work in a bitten file is
destroyed. A whole review round's fixes were wiped this way during
#165. Corollary for a NEW file: the swap-over-`git show` mode does not
apply at all, so its claims must be bitten by hand-editing, which makes
the commit-first rule load-bearing rather than hygienic.
- **A CONFLICTING PR silently runs no CI at all.** GitHub builds
`pull_request` workflow runs against the PR's **merge ref**, which it
does not create while the branch conflicts with its base. So pushes
land, the branch updates, no run is ever queued, and **nothing reports
the absence** — the checks list simply keeps showing the last
successful run, which reads as current. Three pushes to #165 produced
zero CI before the cause was found, and `gh pr checks` returns nothing
usable here. On any lane that lives through a moving `main`, check
`gh pr view <N> --json mergeable,mergeStateStatus,headRefOid` and
confirm a run exists **for the current head sha**, not merely that a
recent run was green.
- **Stacked PRs**: retarget the child to main BEFORE merging the
parent — GitHub auto-closes a PR whose base branch is deleted and
cannot reopen it (#104 → re-opened as #105).

View File

@ -0,0 +1,958 @@
# Bottom panel Stage 2 — the GPU panel band (framing)
**Revision 6 — PR #184 review correction; the underlying Stage 2
framing remains APPROVED 2026-07-27. 2A is merged and 2B-1 is under
review. Ground truth: canonical `main` @ `7fd646d`, protocol v20 on
`main`; `bottom-panel-stage2b` reserves the v21 schema while its
server-first production handshake continues to advertise v20.**
Revisions 14 were pre-implementation; rev 5 recorded the three-way
slice of Stage 2B after its first slice was already built; rev 6
corrects that slice's mixed-version and gate contracts.
Stage 1 (#155, merge `e745068`) gave pmacs window placement, window
parameters, TUI side windows, the divider, and the adopter `display`
opt-in. It deliberately set `FrontendView::panel_capable = false` for
every semantic session, so a GPU frontend silently falls back to the
non-side target. **Stage 2 flips that bit, under an exact negotiated
rule, and earns the right to.**
This document is the re-framing `docs/bottom-panel-framing.md` (rev 4)
§2 requires before Stage 2 is implemented. It does **not** restate the
parent's decisions or replace its acceptance criteria. It records the
re-scout against current `main`, closes the four scout obligations
review round 1 required, and fixes what round 1 found wrong.
**Inherited reading, all of which remains authoritative:** parent
Q#BP8 (the band), Q#BP9 (protocol), **Q#BP14 (the primary-document
projection contract and its census classification)**, **Q#BP14a (panel
input gating is per-window)**, Q#BP14b (focus chrome and per-window
overlay routing), Q#BP15 (`PanelFrame` lifecycle), Q#BP15a (three
geometries), Q#BP16 (pointer transport), Q#BP17 (fold projection), and
**parent acceptance criteria 3755**.
## 0. Revision history
### 0.0 Rev 5 → rev 6 — PR #184 review round 2, four findings closed
- **R6-1 (P1) — v21 is reserved, not advertised, in 2B-1.** The
protocol's handshake is server-first. An existing v20 TUI or GPU
frontend rejects a `Hello { protocol_version: 21 }` before it can
send an `AttachRequest`, so rev 5's claim that a v21 daemon and v20
peer "still negotiate 20" was impossible. 2B-1 therefore extends the
schema and accepted-version ladder to v21 while the production daemon
continues advertising v20. A real-daemon acceptance emulates the
shipped v20 rejection point and then requires the attachment to reach
its initial grid. **2B-3 owns both a compatibility-preserving
activation mechanism and the production move to v21; it may not
simply change the unsolicited `Hello` to 21.**
- **R6-2 (P2) — durable protocol claims move with the wire.**
`COHERENCE.md` and `docs/agent-handoff.md` now distinguish v21 schema
support from the still-v20 production handshake.
- **R6-3 (P2) — the gate contract names the actual decomposition.**
§9 now names 2B-1's
`bottom_panel_stage2b_protocol_acceptance` suite and the exact planned
daemon/GPU suite names for 2B-2 and 2B-3 instead of the nonexistent
`bottom_panel_stage2b_acceptance`.
- **R6-4 (P2) — "one byte over" means exactly one.** The panel and
copied terminal boundary fixtures replace a one-byte cluster with a
two-byte cluster, and each independently asserts a total of
`limit + 1`.
### 0.1 Rev 4 → rev 5 — the three-way slice of 2B (not a review round)
This revision changes no decision. It splits one approved
implementation slice into three and reallocates the acceptance
criteria across them.
- **R5-1 — why.** Rev 4 §9 scoped 2B as a single PR: v21 protocol,
daemon panel projection, GPU band, and the negotiated
`panel_capable` flip. Implementation showed that to be roughly four
thousand lines spanning `pmacs-protocol`, `src/daemon.rs`, and
`pmacs-gpu` — three review surfaces with different failure modes, in
one diff. The same argument that produced 2A/2B applies again one
level down, and it is the argument this arc has already accepted
twice (Lean 4 stages 3a/3b and 4a/4b).
- **R5-2 — the boundary rule.** A slice ends where the next thing to
build has a different *authority*: the wire format, the daemon that
produces frames, and the frontend that paints them. Each slice is
independently reviewable against a subset of the parent criteria,
and each is additive — no slice makes a previously-passing assertion
fail.
**Criteria that span a boundary are named in every slice they touch,
with their half stated**, rather than assigned wholesale to one. The
clearest case is parent 39: its shared-validation and
transport-budget halves are wire properties provable in 2B-1, while
"the previous valid frame is retained" and "a duplicate does no
work" are receiver-state properties that need the epoch machine and
land in 2B-2.
- **R5-3 — this revision is retroactive for slice 1, and that is a
process defect worth recording.** `bottom-panel-stage2b` already
carries the v21 protocol layer (three commits, one review round
closed) written before this revision existed. The workflow is
framing → approval → branch → implement; slice 1 inverted it. The
slicing decision was sound, but it was taken in code and discovered
in the branch rather than proposed in the document, which is exactly
how a stage's scope drifts without anyone deciding that it should.
Rev 5 exists to put the decision back where it belongs before slices
2 and 3 are written.
- **R5-4 — two of the three slices ship dark, deliberately.** Nothing
in 2B-1 or 2B-2 is reachable by a user: `panel_capable` stays
`false` for every negotiated semantic session until 2B-3. Rev 5
incorrectly described that posture as a production v21 negotiation
that remained compatible with v20 clients; rev 6 R6-1 supersedes
that claim. The actual dark posture keeps the server-first
production handshake on v20 while the v21 schema is reserved. This
is the same posture 2A took ("seam adoption that becomes
load-bearing in 2B"), and it means the arc must not stall between
2B-1 and 2B-3. Recorded here so a stall is visible as a decision
rather than inherited as a default.
### 0.2 Round 3 (rev 3 → rev 4) — 1 blocking, 1 high, 1 medium, all closed
- **R3-1 (blocker).** Rev 3's three-boundary model was right but its
call-site table was wrong in five places, and each error was a real
defect: `:6140` is **document completion placement** (classified
status-owned, which would let completion overlap the panel);
`:7195`/`:7212` are the two **status text bounds** (classified
document-owned); `:7351` clips **global minibuffer candidate glyphs**
to the dropdown's band anchor (classified document-owned, which would
clip them against the document boundary); `:8561` (**document edge
scrolling**) was missing entirely, leaving it tied to the old bottom;
and `:8077` was described as completion placement when it is **caret
clipping** (its class was right, its label wrong). §5.3's table is
rebuilt from the full census and every row is verified against the
source.
**Root cause worth recording:** rev 3's table was built from a
`grep | head -20` over 29 matches. The truncation is exactly why
`:8561` vanished. The census is now stated as 20 production sites +
1 definition + 8 test sites = 29, so a future reader can check the
arithmetic instead of trusting the list.
- **R3-2 (high).** The three equations permitted negative coordinates
on a surface shorter than its chrome, where today's
`text_area_bottom` clamps with `.max(0.0)`. All three are now
explicitly clamped, preserving the current helper's behavior.
- **R3-3 (medium).** §5.1's "exact split" omitted `validate_cells`'s
`cell.attachment.is_some()` rejection. It is now classified — and
**shared**, with the reasoning pinned.
### 0.3 Round 2 (rev 2 → rev 3) — 1 blocking, 2 high, 1 medium, all closed
- **R2-1 (blocker).** Rev 2's "one document-bottom seam" conflated two
boundaries that must **diverge** once a panel exists. Several sites it
named are not document-bottom consumers at all: the status-band
background (`main.rs:5908`) must stay at the physical window bottom,
the status text buffers (`:3175`, `:3185`, `:6601`, `:6607`) consume a
*height* and never a bottom coordinate, and status text placement
(`:7134`) sits inside an unchanged band. §5.3 now splits the single
value into **three** named boundaries, classifies every existing
`text_area_bottom` call site, and adds the contrast assertion that
catches a uniformly-wrong implementation moving both together.
- **R2-2 (high).** `accept_frame_geometry -> bool` cannot distinguish
*advanced* from *accepted duplicate* from *rejected*. It now returns an
explicit three-valued result. The exhaustion wording also permitted
retaining stale geometry, which is not fail-closed: §3.1 now clears the
authoritative declaration and reconciles to hidden, and adds the
frontend-side terminal latch.
- **R2-3 (high).** Parent acceptance 52 was assigned wholly to 2A, but
2A has no semantic panel projection — it can only prove the extracted
painter accepts an explicit `None`. 52 is now also reasserted in 2B,
where the contract becomes production-reachable.
- **R2-4 (medium).** §9 names the four touched acceptance suites
explicitly rather than relying on "standing suite".
- Both §8 open items are decided (§5.3): `BASE_DIVIDER_HEIGHT = 4.0` at
scale 1.0, and `TEXT_TOP` stays unscaled.
### 0.4 Round 1 (rev 1 → rev 2) — 2 blocking, 3 high, 3 revision points, all closed
- **R1-1 (blocker).** Rev 1 said all 23 census reads route through
`primary_document_window`. That contradicts Q#BP14, which routes only
the **Projection** class (#1#12, #21#22) that way and leaves focus,
input, chrome, and bell consumers on their own authorities. Rev 1's
rule would have broken remote-op validation, `DispatchIdle`,
presence, focused search/menu/completion routing, and bell ownership.
§3.2 now restores all four classes; §7's criterion pins them
separately. The inherited-reading list above gains Q#BP14 and Q#BP14a.
- **R1-2 (blocker).** Rev 1 treated the three `src/statusline.rs`
active reads as one disposition. Only `:644` selects the wrong
window; `:629` and `:675` must keep tracking **actual focus**. §3.3
is rewritten and the criterion states the required behavior instead
of routing focus away.
- **R1-3 (high).** The `panel_capable` flip needed an exact attach
rule, not "for semantic sessions". §3.5 states it: **v21-or-later
negotiated authenticated semantic session only**.
- **R1-4 (high).** Option 1 accepted, but the epoch needed a state
machine, split APIs, and a fail-closed allocator. §3.1 now carries
the transition table and the API split. Rev 1's phrasing "rejects a
lower-or-equal epoch carrying different data" was itself wrong — a
lower epoch carrying *identical* data is still stale.
- **R1-5 (high).** Rev 1's eleven draft criteria silently omitted
parent 3755. §7 now declares the parent list authoritative, maps it
to 2A/2B, and adds only refinements. The painter-extraction criterion
pins cursor, `view_top`, and passive-window state, not just cells.
- **R1-6.** All four scout obligations are closed in §5.
- **R1-7.** The coherence statement understated journey impact and
overclaimed on background work. §6 names journey steps 710 and
narrows the §9 claim.
- **R1-8.** Factual corrections in §1 and §3.2.
## 1. Anchor re-scout
| Parent anchor | Now at | Verdict |
| --- | --- | --- |
| `paint_frame` returns cursor separately (`editor.rs:2833`) | `src/editor.rs:3171` | Holds |
| Cursor-visible prep (`editor.rs:2883-2935`) | `src/editor.rs:3249+` | Holds; Stage 1 inserted work above it (§2) |
| Per-window paint body (`editor.rs:2937-3040`) | after `src/editor.rs:3260` | Holds |
| `fold_map_for_window` gates on the **active** frontend (`editor_core.rs:566`) | `src/editor_core.rs:734`, gate at `:738` | Holds |
| Stale "semantic session never enters `paint_frame`" (`window.rs:339`) | `src/window.rs:562` | Holds, still stale; now embedded in a longer `fold_projection` doc block, so the edit is a paragraph rewrite |
| `Mouse` is contractually the grid path (`daemon.rs:3122-3130`) | `src/daemon.rs:3123` | Holds |
| Permanent `24×80` placeholder (`attach.rs:420-429`, `:573-577`) | `pmacs-gpu/src/attach.rs:577`, single site | Holds |
| Byte pin `InstanceMessage::InitialTargetResult` | `pmacs-protocol/src/message.rs:1145` | Holds — still the enum's final variant |
| Byte pin `FrontendEvent::TerminalPointer` | final variant of its enum | Holds |
**Protocol was still v20 at this re-scout**; no intervening PR had
bumped it. Q#BP9's conditional resolved: **Stage 2 reserves v21**.
Rev 6 R6-1 adds the server-first compatibility constraint discovered
during 2B-1 review.
Fifteen PRs merged between the parent's last re-scout (`47581f4`) and
this one: #149, #150, #152#155, #158#166. Nothing in the parent's
mechanical model was falsified by any of them.
## 2. What Stage 1 already built for Stage 2
- `DeclaredFrameGeometry { geometry_epoch: u64, total: CellSize }`
(`src/window.rs:522-528`), held as
`FrontendView::frame_geometry: Option<_>` (`:589`) where `None` means
**unknown** — Q#BP15a's "unknown is first-class", already landed.
- `EditorState::sync_frame_geometry` (`src/editor.rs:877-882`) →
`declare_frame_geometry` + `reconcile_panel_layout`, driven from two
daemon sites gated on `panel_capable_for` (`src/daemon.rs:1882-1883`
attach, `:1972-1973` resize).
- `paint_frame` declares geometry itself (`src/editor.rs:3187`), before
the statusline fan-out and before the long mutable core borrow.
- `StatuslineEvaluationTarget` (`src/statusline.rs:212-226`) is already
a two-variant enum, so Q#BP8's fan-out generalization is an added
variant, not a refactor.
- `primary_document_window` (`src/editor_core.rs:2830`) and
`primary_document_buffer` (`:2845`).
## 3. Findings and decisions
### 3.1 Q#BP2S1 — epoch ownership, resolved: frontend-owned, with an exact state machine
**Decision: option 1.** The epoch is owned by the frontend for
negotiated semantic-panel sessions. The deciding argument is one rev 1
missed: **a font or scale transaction can require invalidating an old
`PanelFrame` even when the derived `CellSize` is identical.** Daemon
value dedup cannot detect that case, because the cell totals it
compares are unchanged while the pixels behind them are not.
The landed allocator conflicts in three ways
(`src/editor_core.rs:3155-3172`): it allocates the id itself, it
early-returns when `total` is unchanged (value dedup), and it uses
`saturating_add`, which is neither wrapping nor fail-closed — it pins
at `u64::MAX`, after which two different geometries share one id.
**Acceptance rules for a semantic declaration:**
| Incoming declaration | Result |
| --- | --- |
| epoch **greater** than stored | Accept, store **verbatim**, even if `total` is unchanged |
| same epoch, same `total` | Idempotent no-op |
| same epoch, **different** `total` | Reject |
| **lower** epoch, any `total` | Reject |
The last row is deliberate and corrects rev 1: a lower epoch carrying
identical data is still stale and must not be accepted.
**API split.** Two methods, not one method with an optional epoch:
- `declare_frame_geometry(fid, total)` — the **grid/LOCAL** allocator.
Keeps value dedup (correct there: cells are the unit, and an
unchanged grid means an old frame is still valid under unchanged
metrics). Changes from `saturating_add` to **checked** allocation
with an explicit fail-closed exhaustion arm.
- `accept_frame_geometry(fid, geometry_epoch, total) -> GeometryUpdate`
— the **semantic** path. No value dedup; applies the table above
verbatim.
An ambiguous single method with an `Option<u64>` epoch is rejected
explicitly: it would let a future caller silently take the wrong regime.
**The result is three-valued, not a boolean.** A boolean cannot
distinguish the three outcomes the caller must act on differently:
```rust
enum GeometryUpdate {
/// Epoch advanced: stored verbatim. Run panel reconciliation.
Advanced,
/// Same epoch, same total: already current. Do no work.
Duplicate,
/// Same epoch with different total, or a lower epoch: stale or
/// conflicting. Drop the event before any reconciliation.
Rejected,
}
```
`Advanced` reconciles, `Duplicate` returns without touching panel
state, and `Rejected` drops the event. Collapsing `Duplicate` into
either neighbour is a defect in one direction or the other: folded into
`Advanced` it reconciles on every repeated declaration, folded into
`Rejected` it would log or surface a stale-event condition that never
happened. (If a boolean is kept for a narrower internal caller, it must
be named `advanced`, never `accepted``Duplicate` *is* accepted.)
**Initial epoch.** The frontend's first declaration after attach
acceptance carries epoch `1`. `0` is reserved as "never declared" and
is rejected on the wire.
**Exhaustion fails closed on both sides, and rev 2's wording did not.**
Saying the panel "stays at its last valid geometry" is not fail-closed:
if the real frame resizes after the allocator is exhausted, the daemon
would keep painting a panel sized to geometry that no longer describes
the frontend.
- **Grid/LOCAL path.** On checked-allocation exhaustion, **clear** the
authoritative `frame_geometry` (back to `None` = unknown) and
reconcile. Unknown is already non-presentable under Q#BP2b, so the
panel hides. Stale geometry is never retained.
- **Frontend path.** On exhaustion the frontend sets a **terminal
latch** for the life of the session: it sends no further geometry,
and — critically — an old matching `Present` **cannot** make the band
reappear, because the latch suppresses paint and hit-testing
independently of frame validity. Only a fresh session (reconnect)
clears it. Without the latch, a retained `Present` whose epoch still
matches the last declaration would resurrect a band under geometry
the frontend has disowned.
### 3.2 The census is classified, and it is mostly unrouted
**Correction to rev 1.** Q#BP14 routes only the **Projection** class
through `primary_document_window`. Rev 1's "all 23 reads" was wrong and
would have broken five subsystems. The four classes, restored:
| Class | Census items | Authority |
| --- | --- | --- |
| **Projection** | #1#7, #9, #10, #12, #21, #22 | `primary_document_window` / `primary_document_buffer` |
| **Projection + focus** | #8 (document `Pointer`), #11 (full-window `TerminalPointer`) | Align the primary document window **and then activate it** — the one place the two legitimately move together |
| **Focus / input** | #13 (remote-op validation), #14 (`dispatch_idle_for`), #15 (presence), #23 (remote-op application) | The frontend's **actually focused** window. Q#BP14a: gating is per-window, never per-buffer |
| **Focus chrome / surface-routed** | #16#19 (search, menu, minibuffer, completion) | Q#BP14b's routing table — the currently owned surface, with authoritative clears for the other |
| **Focus / session** | #20 (terminal bell drain) | Per-session counter; the **focused** window chooses which session may drain |
Rerouting any of the last three classes to the document is a defect,
not a simplification: it would break remote-op validation and
application, `DispatchIdle`, presence, focused search/menu/completion
routing, and bell ownership.
**How much is already routed.** `primary_document_window` has **four**
references in `src/` and **two production paths**: directly at
`src/daemon.rs:1639` (#148's initial-target bootstrap, Q#BP11b), and
through `primary_document_buffer` at `src/daemon.rs:2998`, which is
census **#22** and carries a comment naming it. So one census item is
routed and the Projection class is otherwise open. For scale, `src/*.rs`
still holds ~80 non-test direct `.active` reads on top of the
`active_window*` / `active_buffer*` helper family
(`src/editor_core.rs:663-967`).
This is not a Stage 1 defect — with `panel_capable = false` no semantic
frontend can hold a side window, so the unrouted Projection reads are
unreachable from the GPU. It does mean **classified census routing is
the bulk of Stage 2**, which is why it is Stage 2A.
### 3.3 The three statusline reads have two dispositions, not one
All three sites are real, but only one is wrong:
- `src/statusline.rs:644``.get(&view.active)` **selects the wrong
window** when a panel is focused. This is the Projection read (#12).
- `src/statusline.rs:629` and `:675` — `active: window_id ==
view.active` **must continue tracking actual focus**. Three reasons:
grid contexts need a truthful `active`; post-callback revalidation
must notice a focus change; and parent acceptance 42 explicitly
requires that a document provider may observe `active = false` while
the panel is focused.
**The new semantic-layout target** therefore captures the **primary
document window plus the visible side window**, marks each context
`active` iff its `window_id == view.active`, invokes each provider
**exactly once**, and **invalidates the entire evaluation** if a
callback mutates layout or focus. Unprojected document splits run no
callbacks (Q#BP8). Route the primary-document result to semantic
`StatuslineSegments` and the side result to the panel mode line.
### 3.4 Fold projection
Unchanged from Q#BP17, with the anchor corrected: the extracted painter
takes the map as a **parameter**; the panel path passes `None` when the
owning frontend's `fold_projection` is false and must never call
`fold_map_for_window`, which gates on the **active** frontend
(`src/editor_core.rs:734`, gate at `:738`) — right for command-time
reckoning, wrong for painting another frontend's panel. The stale
comment is at `src/window.rs:562`.
### 3.5 The `panel_capable` flip needs a negotiated rule
Not "true for semantic sessions". Exactly:
> `panel_capable = true` **only** for an authenticated semantic session
> that negotiated **v21 or later**.
A v6v20 semantic frontend stays non-panel-capable and takes the
existing Stage 1 fallback: the non-side target with **every
side-specific parameter discarded**, leaving the document window
undedicated (Q#BP2c). "It receives no new events" is insufficient — if
the daemon nevertheless places that frontend's window in a side panel
it cannot render, the window becomes invisible. The gate is on
placement, not only on transport. Parent acceptance 51 pins the mixed
session. **The production daemon does not advertise v21 in 2B-1 or
2B-2.** Because `Hello` is server-first, 2B-3 must add or prove a
compatibility-preserving way to activate v21 before applying this rule;
merely advertising 21 would strand already-shipped v20 clients before
they can identify themselves.
## 4. Revisions to the parent framing
Only these; everything else stands.
- **Q#BP9 resolves to the v21 schema, with production advertisement
held at v20 until 2B-3 supplies compatible activation.**
- **Q#BP15a's epoch ownership is specified** by §3.1's table and API
split, replacing the parent's one-line "frontend-owned" statement.
- **Q#BP8's statusline criterion splits** per §3.3: one read reroutes,
two keep tracking focus.
- **Q#BP17's stale comment is at `src/window.rs:562`**, and parent
acceptance 52's reference to `:339` should be read against that.
## 5. The four scout obligations, closed
### 5.1 The shared cell-grid validator boundary
`TerminalFrame::validate` (`pmacs-protocol/src/terminal.rs:226`)
currently interleaves both concerns. The exact split:
- **Factored into the shared parameterized wire-cell-grid validator:**
checked area (the `checked_mul` + `usize::try_from` guard), the
`MAX_TERMINAL_VISIBLE_CELLS = 262,144` aggregate cap, cell-count
equality against declared area, cursor-in-bounds, and
`validate_cells`'s glyph width / continuation topology and aggregate
glyph-byte checks.
- **Stays terminal-only:** the `MAX_TERMINAL_ROWS/COLS = 512` per-axis
caps in `checked_area`, `validate_metadata` for title/signal/crash
text, `validate_selection`, and the `at_bottom == (scroll_offset ==
0)` coupling.
**Attachment rejection is shared, not terminal-only.** `validate_cells`
also rejects `cell.attachment.is_some()`
(`pmacs-protocol/src/terminal.rs:305`), and its error text reads "A
cell carries a frontend attachment, which terminals never use"
(`:190-191`) — phrased as a terminal-specific fact, which is why rev 3
missed it. **Stage 2 classifies it shared**: panels implement no
attachment rendering, so a `PanelFrame` carrying one describes a
surface the GPU would silently not draw. Shared rejection fails closed
on the producer side rather than shipping an invisible cell. The error
message is reworded away from "which terminals never use" to a
grid-neutral phrasing when it moves. If a later stage gives panels
attachment rendering, this rejection moves back to terminal-only as a
deliberate, reviewed change — not by default.
`PanelFrame` takes the shared half plus its own presence/epoch rules
and does **not** inherit the 512 per-axis cap (Bet B5'), so a 4K
small-font panel wider than 512 columns is legal while the shared area
budget still binds. Parent acceptance 39 pins exactly this.
### 5.2 The GPU outbox needs four more tags
`coalesce_kind` (`pmacs-gpu/src/attach.rs:331`) today returns four
tail-only tags: `Viewport` → 0, `Pointer{Drag}` → 1,
`TerminalPointer{Move}` → 2, `TerminalPointer{Drag}` → 3. Everything
else is `None` = lossless, counting against `OUTBOX_MAX = 8192`.
Stage 2 adds **four distinct tags**: `FrontendCellGeometry` → 4,
`PanelResizeRows` → 5, `PanelPointer{Move}` → 6, `PanelPointer{Drag}`
→ 7. Geometry is latest-wins (epochs need only increase, not be
consecutive); resize drag is latest-wins over the complete event
including its epochs. `PanelPointer` `Down`/`Up`/wheel/context stay
lossless and ordered — repeated left `Down`s are what the daemon click
state reads as a multi-click, and `Down(Right)` is the context-menu
gesture. Tail-only replacement preserves ordering across an
intervening event of any other class.
### 5.3 The pixel formula's inputs — and one trap
The formula in Q#BP15a is contract-level, not an implementation
detail, because its inputs are not all safe to adopt:
| Input | Source | Note |
| --- | --- | --- |
| `status_band_height_px` | `FontMetrics::status_band_height` (`pmacs-gpu/src/main.rs:137`) = `BASE_STATUS_BAND_HEIGHT * scale` | Safe |
| `TEXT_TOP_px` | `const TEXT_TOP: f32 = 16.0` (`main.rs:352`) | Safe; unscaled today |
| `code_line_height_px` | `FontMetrics::code_line_height` (`main.rs:131`) = `BASE_CODE_LINE_HEIGHT * scale` | Safe |
| `resolved_monospace_advance_px` | `State::mono_advance` (`main.rs:4899`) | **Unsafe to adopt blindly** |
| `divider_height_px` | `BASE_DIVIDER_HEIGHT` | **Does not exist yet** |
**The `mono_advance` trap.** `State::mono_advance` returns
`measured_mono_advance` when a `FontFacts` probe has been applied, but
otherwise falls back to **the first shaped glyph of the document
buffer** (`main.rs:4903+`). Panel column count would therefore become
**document-dependent**: two GPU frontends showing different files could
derive different `total.cols` from identical metrics, and the same
frontend's panel width could change when the document's first glyph
changes.
**Decision.** The panel geometry declaration uses a **stable normal-face
probe**, never the document sample. `probe_mono_advance(font_system,
family, metrics)` (`main.rs:323`) already exists and is exactly this: it
shapes `ADVANCE_PROBE` in a scratch buffer, independent of document
contents, dividing total run width by logical cells so ligature
substitution survives. The declaration resolves its advance from that
probe for the current family/metrics. If the probe returns `None` (the
family shapes no width), the frontend declares **zero usable geometry**
under a new epoch — the panel hides — rather than falling back to a
document sample.
**`BASE_DIVIDER_HEIGHT = 4.0`** at scale 1.0, scaled by
`FontMetrics::scale` like `status_band_height`. A 12 px rule is
adequate decoration but too fragile as the drag hit strip; 4 px still
reads as a rule while giving the pointer a usable target. **The entire
strip is painted with `ui.divider`, and that exact rectangle is the
hover/drag hit region** — paint geometry and hit geometry are the same
rect, so they cannot drift apart.
**`TEXT_TOP` stays `16.0`, unscaled.** It is a fixed surface inset
today, like `TEXT_LEFT` and the other paddings, while
`FontMetrics::scale` governs font-derived metrics and row chrome.
Scaling it only inside the declaration formula would disagree with the
actual renderer; scaling every renderer and hit-test occurrence is a
wholesale inset/DPI change and is **named here as separate work**, not
smuggled into Stage 2. The formula is pinned to the real unscaled inset.
Accordingly, **Q#BP15a's "all quantities use the frontend's current
scale" is narrowed**: font-derived metrics and the divider scale; fixed
surface insets keep their current units.
#### The seam is three boundaries, not one
Rev 2 asked for a single document-bottom accessor. That was wrong:
once a panel is installed, today's single value must **diverge into
three**, because some of its consumers must not move at all.
All three clamp at zero, preserving today's `text_area_bottom`
`.max(0.0)` behavior — without the clamps a surface shorter than its
own chrome yields negative coordinates, and the "exact formula" stops
being exact precisely where it matters most:
```
status_band_top = max(0, surface_height - status_band_height)
geometry_capacity_bottom = max(0, status_band_top - divider_height)
// divider reserved even while absent
document_text_bottom = max(0, status_band_top
- installed_panel_height
- installed_divider_height)
```
`geometry_capacity_bottom` is what Q#BP15a's asymmetry already
requires: the divider is subtracted **for sizing purposes even while
the panel is absent**, which is what breaks the first-open cycle, while
the document renderer does not actually lose those pixels until a
`Present` panel is painted.
**Today `text_area_bottom` (`pmacs-gpu/src/main.rs:8490`) is all three
at once**, and its doc comment calls it "the single source for every
bottom-of-text computation" (Q#S3).
The census is **29 matches: 20 production call sites, 1 definition
(`:8490`), and 8 test sites** (`:12887`, `:12937`, `:12997`, `:13109`,
`:13793`, `:14013`, `:15306`, `:15386`). Every production site,
classified individually against the source:
**Status-owned — must stay pixel-identical at the physical window
bottom, using `status_band_top`** (8 sites):
| Site | What it is |
| --- | --- |
| `:5908` | Status-band background rect `y` |
| `:6003` | `mb_visible_window` — rows that fit **above the band** |
| `:6027` | `mb_dropdown_window` origin — dropdown grows up from the band |
| `:7134` | `status_top` for the right status group |
| `:7195` | `status_buffer` `TextBounds.top` — status text bound |
| `:7212` | `status_left_buffer` `TextBounds.top` — status text bound |
| `:7351` | Minibuffer **candidate glyph** clip, anchored to the dropdown's band origin |
| `:7922` | `status_top`, second site |
The minibuffer is **global, bufferless chrome anchored to the status
band** (Q#BP14b keeps `MinibufferPrompt` global), so all four of its
sites — `:6003`, `:6027`, `:7351`, and its `status_left_buffer` bound
`:7212` — stay status-owned. Clipping candidate glyphs at
`document_text_bottom` would clip the dropdown against a boundary it
does not sit above.
**Document-owned — must move when a band is installed, using
`document_text_bottom`** (12 sites):
| Site | What it is |
| --- | --- |
| `:4566` | `terminal_cell_viewport` — drawable height for the cell grid |
| `:6118` | `completion_anchor_px` — anchor visibility bottom |
| `:6140` | `completion_dropdown_layout`**document completion placement**; `band_top - (line_top + line_h)` is the space below the anchor line |
| `:6581` | `code_height` |
| `:7174` | Code text clip bottom |
| `:7242` | Math text clip bottom |
| `:7273` | Gutter clip bottom |
| `:7421` | Terminal clip bottom |
| `:8077` | `code_caret_rect_in_clip`**caret clipping** |
| `:8497` | Minimap drawable height |
| `:8501` | Visible-line estimate |
| `:8561` | `edge_scroll_direction`**document edge scrolling** |
**Geometry declaration** uses `geometry_capacity_bottom`, and is the
Q#BP15a conversion only.
**Sites that consume no bottom coordinate at all** and must not be
touched: `:3175`, `:3185`, `:6601`, `:6607` size the status text
buffers to `status_band_height` directly. Rev 2 listed them as seam
consumers; they are not.
Three of these classifications are the ones a plausible implementation
gets wrong, and each has a visible symptom: document completion
(`:6140`) anchored to `status_band_top` **overlaps the panel**;
minibuffer candidates (`:7351`) clipped at `document_text_bottom` are
**cut off**; and edge scrolling (`:8561`) left on the old bottom
**auto-scrolls from inside the panel**.
Each call site is classified individually. A blanket rewrite of
`text_area_bottom` to subtract the band would move the status chrome
with the document and is the defect this section exists to prevent.
**The contrast assertion (A2B-4).** "Every document consumer moved" is
only half a test — a uniformly wrong implementation that moves
everything passes it. The criterion must assert **both directions in
one scenario**: installing a panel moves every document-owned consumer
**while the status band stays pixel-identical** at the physical window
bottom. That is the assertion a blanket rewrite fails.
The one-accessor-per-boundary rule still holds within each class: a
second, unrouted derivation of any of the three is the exact shape of
Stage 1's `Layout::compute` two-caller defect, where
`src/overlay_paint.rs` derived its own rect and painted peer cursors at
unfixed rows.
### 5.4 Ordering against folding Stage 3
Settled by review round 1: **bottom-panel Stage 2 first, through the
landed GPU band.** Folding Stage 3 then re-scouts the extracted
painter, the panel projection, clipping, and `fold_projection` behavior
exactly once.
## 6. Coherence impact (per `COHERENCE.md` §20)
- **Journey steps touched: four, on the GPU frontend — steps 710**
(find symbol / find file, terminal, build and test, error
inspection). Rev 1 said "none directly", which contradicted its own
next sentence. Today a GPU user who triggers references, project
search, a terminal, compile, or error inspection gets the Stage 1
non-side fallback: the output surface steals a document window
instead of opening a panel. Every one of those steps therefore
behaves differently on GPU than on TUI, and Stage 2 is what closes
the divergence.
- **Interaction islands added: none, and this is a reduction.** §6
grades islands "weak, and growing by one island per modal feature".
Stage 2 extends one already-adopted policy (`display = "panel"`,
used by listview, compile, and terminal) to a second frontend rather
than minting a GPU-only surface. Q#BP14b deliberately reuses the
existing `SearchPrompt` / `MenuPrompt` / `CompletionPopup` messages
instead of panel-specific twins.
- **Config registry adoption: inherited, not extended.** Stage 1's
`window.panel-height` and `window.min-height` already live in the
registry. Stage 2 adds no new user-facing option; if the band needs
one, it enters the registry.
- **Background-work attribution: unchanged, and this stage does not
advance it.** Rev 1 implied Stage 2 helps §9's activity-view gap. It
does not. A panel gives output a coherent *placement*; it does not
make terminal PTYs, LSP servers, or workers appear in the
activity/ownership view §9 describes, and it adds no join key across
the four disjoint activity planes. The §9 gap is untouched.
- **Section this serves:** `COHERENCE.md` §14, which records the panel
primitive as landed for Stage 1 and names "Stage 2 (GPU band)
pending its own framing" as the open item.
- **Which slice pays the coherence debt (rev 5).** The journey claim
above is Stage 2B-3's alone. 2A, 2B-1, and 2B-2 close **no** journey
divergence: with `panel_capable = false`, a GPU user still gets the
Stage 1 non-side fallback on steps 710 after all three land. Stated
explicitly so no slice's PR can claim the arc's coherence benefit
before the flip earns it — three quarters of this stage is
preparation, and only the last quarter is the improvement.
## 7. Acceptance
**Parent criteria 3755 remain authoritative and are not replaced.**
This section maps them to the four slices — 2A, then 2B-1/2B-2/2B-3 —
and adds only refinements. A criterion that spans a slice boundary is
named in each slice it touches, with its half stated.
### 7.1 Stage 2A — classified census routing + painter extraction
No protocol change. Parent criteria that apply in full: **42, 43, 44,
51 (the `LOCAL`-panel inheritance half)**, plus the extraction half of
**52**.
**52 splits across the slices.** 2A has no semantic panel projection
and no `PanelFrame`, so all it can prove is that the extracted painter
honors an explicitly supplied `None` fold map and that the stale
`src/window.rs:562` comment is corrected. The actual contract — *a
semantic panel with `fold_projection = false` never collapses folds and
never calls `fold_map_for_window`* — is production-reachable only once
2B lands the projection and the capability flip. It is therefore
reasserted in 2B (§7.2).
Refinements 2A adds:
- **A2A-1 (replaces rev 1's criterion 1).** Every **Projection** census
item (#1#7, #9, #10, #12, #21, #22) resolves through
`primary_document_window` / `primary_document_buffer`; #8 and #11
align **and then activate**; **#13, #14, #15, #23 continue to resolve
the actually focused window**; #16#19 follow Q#BP14b's routing
table; #20 keeps its per-session counter with focus choosing the
eligible terminal. Each class is asserted separately, at the
outermost user-reachable seam, and falsified by revert. A test that
only proves "the document is used" would pass with the focus classes
wrongly rerouted, so the focus-class assertions are the load-bearing
half.
- **A2A-2 (replaces rev 1's criterion 2).** `src/statusline.rs:644`
resolves the primary document window, while `:629` and `:675`
continue to report **actual focus** — pinned by a document provider
truthfully observing `active = false` while the panel is focused
(parent 42). The semantic-layout target captures primary document +
visible side window, invokes each provider exactly once, and
invalidates the whole evaluation when a callback mutates layout or
focus.
- **A2A-3 (replaces rev 1's criterion 3).** The painter extraction
preserves, for grid frontends: the painted **cells**, the **returned
cursor**, the **focused window's `view_top` mutation** from the
auto-scroll clamp, and **passive windows' untouched `view_top` and
scroll state**. Byte-identical cells alone would not catch a clamp
that silently moved to the wrong window.
### 7.2 Stage 2B — v21 protocol, daemon projection, GPU band
Stage 2B as a whole owns parent criteria **37, 38, 39, 40, 41, 45, 46,
47, 48, 49, 50, 51, 53, 54, 55**, plus re-assertion of **42, 43, 44,
and 52** **through the actual negotiated capability flip** rather than
through a test-only panel-capable semantic view. 52's 2B form is the
production one: a real semantic frontend with `fold_projection = false`
displaying a folded buffer in a panel shows every source line, and the
panel path never reaches `fold_map_for_window`.
Per §0.0 R5-1 those land across three slices. Each slice's own gate run
is the standing suite plus §9's named acceptance suites; **only 2B-3
changes what a user sees.**
#### 7.2.1 Slice 2B-1 — the v21 wire layer
**Authority: `pmacs-protocol`.** The four wire shapes Q#BP9 names, the
version bump, and the shared cell-grid validator. No producer, no
consumer, no capability change.
- **37, in full.** `PanelFrame` round-trips including `panel_epoch` and
`geometry_epoch`, with independent byte pins on the previous final
`InstanceMessage::InitialTargetResult` and
`FrontendEvent::TerminalPointer` variants. **Both pins must be
falsified by revert**, not merely observed passing: a byte pin that
never saw the shift it exists to catch pins nothing.
- **39, the wire half only.** Shared cell/topology/glyph/area
validation; an area-bounded panel wider than 512 columns is accepted
while a terminal frame retains its 512-column PTY cap; the maximum
legal panel encoding stays below the transport limit. **The ratchet's
fixture must be shown to spend the whole aggregate glyph budget** —
otherwise it measures something smaller than the worst case and the
bound it proves is not the bound that matters. The worst case is
`1 × MAX_PANEL_VISIBLE_CELLS`, a legal panel geometry no terminal can
express, so the terminal's own ratchet has never covered it.
**39's receiver half — atomic rejection with retention of the
previous valid frame, and a duplicate doing no work — is 2B-2.**
- **The version ladder moves with the bump.** `PROTOCOL_VERSION`
becomes 21, `SUPPORTED_PROTOCOL_VERSIONS` accepts `6..=21` and
rejects 22, and any test whose *name* encodes the old number is
renamed. A ladder pin that passes across a bump was not pinning the
version. **`ADVERTISED_PROTOCOL_VERSION` remains 20 in 2B-1 and
2B-2** because the unsolicited `Hello` precedes any client version
signal. A real daemon must remain attachable by a client whose
supported range ends at 20.
- **Shared bounds are aliased, not duplicated.** Every constant the
terminal screen and the panel validator both enforce is one
definition with the other as an alias, so truncation and validation
cannot drift apart.
- **Not in this slice:** the daemon arm that drops panel events from a
grid session is exhaustiveness bookkeeping the bump forces, not
projection. It asserts only that a grid session's panel declaration
is dropped rather than trusted.
#### 7.2.2 Slice 2B-2 — the daemon panel projection and epoch machine
**Authority: `src/daemon.rs`.** Produces `PanelFrame`; derives the
grid; owns stale-event rejection. Exercised through a **test-only**
panel-capable semantic view — `panel_capable` stays `false` in
production negotiation until 2B-3.
- **38** (open → replace buffer → hidden by a tiny frame → reappear →
close, with authoritative `Absent` and a new epoch on
replacement/reappearance), **40** (first open at a non-80×24 frame
stays absent until real `FrontendCellGeometry` arrives, never
consulting the 24×80 attach placeholder), **49**, **50**, **51**,
**53**.
- **39's receiver half**, per §7.2.1.
- **41, the daemon half:** the daemon alone derives the grid; an older
retained frame neither paints nor accepts input after a new
`geometry_epoch` until a matching `Present` arrives; row-clamping
preserves the stored request; zero, non-finite, and non-positive
metric inputs fail closed to zero usable geometry. *The pixel→cell
formula and its call sites are 2B-3.*
- **42, 43, 44, 45, 52** in their projection form, through the
test-only panel-capable view. Their production re-assertion through
the real flip is 2B-3.
- **A2B-1.** The epoch state machine of §3.1 is pinned row by row,
including the lower-epoch-identical-data rejection and the
same-epoch-different-total rejection, and each row's
`Advanced`/`Duplicate`/`Rejected` result is asserted — a `Duplicate`
performs no reconciliation and a `Rejected` mutates nothing. Epoch
`0` is rejected on the wire. **Exhaustion is pinned on both sides**:
grid exhaustion clears `frame_geometry` to unknown and the panel
hides (a subsequent real resize must not paint a stale-geometry
panel), and a frontend that exhausts latches — a retained `Present`
whose epoch still matches cannot make the band reappear, and only a
fresh session clears the latch. **A2B-1's grid-exhaustion half is
2B-2; its frontend-latch half needs a real frontend and is 2B-3.**
Both halves are named here so neither is lost at the seam.
#### 7.2.3 Slice 2B-3 — the GPU band and the capability flip
**Authority: `pmacs-gpu`, plus the compatibility-preserving negotiation
activation.** This is the only slice a user can observe, and the only
one that closes the journey divergence in §6. It must not advertise
v21 in the server-first `Hello` until an existing v20 client can still
attach.
- **46** (band + divider shrink the document text area by exactly their
pixel height; carets, hits, and scroll geometry respect the reduced
area), **47** (divider drag, `window.min-height`, `RowResize` hover,
and the stalled-writer tail-coalescing), **48** (`PanelPointer`
driving selection, terminal mouse reporting, and click-to-focus
without disturbing the document mirror), **54** (the
`--headless-probe` run: one real daemon, real PTY, real wgpu, through
a panel-hosted terminal), **55**.
- **41, the GPU half:** the pixel→cell conversion pinned at fractional
widths and heights, and geometry refresh on window resize, font
change, and scale change.
- **42, 43, 44, 45, 52 re-asserted through the production flip**, not
the test-only view. This is the point of the re-assertion: a
test-only panel-capable view can be constructed wrongly and agree
with itself, so the production negotiation path must carry the same
assertions.
- **A2B-1's frontend-latch half**, per §7.2.2.
- **A2B-2.** A font or scale change that leaves `CellSize` **identical**
still produces a new `geometry_epoch`, and the older `PanelFrame`
neither paints nor hit-tests until a matching `Present` arrives. This
is the case daemon value dedup cannot see and is why option 1 was
chosen.
- **A2B-3.** Panel columns are derived from the **stable normal-face
probe**, not `State::mono_advance`'s document-glyph fallback: two GPU
frontends with identical metrics and different documents derive
identical `total.cols`, and a probe returning `None` declares zero
usable geometry rather than falling back to a document sample.
- **A2B-4 (contrast assertion).** Installing a panel moves **all twelve
document-owned consumers** of §5.3 by exactly
`installed_panel_height + divider_height`, **while all eight
status-owned sites stay pixel-identical** at the physical window
bottom. Both halves are asserted in one scenario: a uniformly wrong
implementation that moves the status band too passes the "everything
moved" half alone. Three rows carry their own named symptom because
they are the ones a plausible implementation misclassifies —
**document completion (`:6140`) must not overlap the band**,
**minibuffer candidates (`:7351`) must not be clipped by it**, and
**edge scrolling (`:8561`) must not trigger from inside it**. The
geometry declaration separately reserves the divider while the panel
is `Absent`, and the document loses no pixels until a `Present` is
painted. All three boundaries clamp at zero on a surface shorter than
its chrome.
- **A2B-5.** `panel_capable` is true only for a v21+ negotiated
authenticated semantic session; a v20 semantic session is never
**placed** in a side window, not merely denied the events. The same
acceptance must attach an actual v20 client to the production daemon
after v21 activation, so the new path cannot pass by breaking the old
handshake before placement is evaluated.
## 8. Open items
**None.** Both round-1 open items are decided in §5.3:
`BASE_DIVIDER_HEIGHT = 4.0` at scale 1.0 (scaled, whole strip painted
`ui.divider` and used as the hit rect), and `TEXT_TOP` stays unscaled
with wholesale inset/DPI scaling named as separate work.
One deferral is recorded rather than resolved: **wholesale surface-inset
scaling** (`TEXT_TOP`, `TEXT_LEFT`, and the sibling paddings under
`FontMetrics::scale`) is pre-existing behavior Stage 2 pins rather than
fixes. It belongs to a spacing-system change of its own.
## 9. Slices, branches, and gates
Per review round 1 and §0.0 R5-1: **four serial implementation PRs**,
each a named slice under this framing so one-feature/one-branch/one-PR
holds. **Each slice lands before the next branches** — none are
stacked, and each is cut from `main`.
- **Stage 2A — MERGED as #177** (`main` @ `0a3fcd1`). Classified census
routing + per-window painter extraction. Branch
`bottom-panel-stage2a`. No protocol change. The three-boundary GPU
split is **2B-3**, not 2A: it is only observable once a band can be
installed.
- **Stage 2B-1 — the v21 wire layer.** Branch `bottom-panel-stage2b`.
The four wire shapes, the version bump, the shared cell-grid
validator, and the version-ladder move. The v21 schema is reserved
while the production daemon continues advertising v20. **No
producer, no consumer, no capability change** — `panel_capable`
stays `false`.
- **Stage 2B-2 — the daemon panel projection and epoch machine.** Cut
from `main` after 2B-1 merges. Produces `PanelFrame` and owns
stale-event rejection, exercised through a **test-only**
panel-capable semantic view. Still no production flip.
- **Stage 2B-3 — the GPU band and the negotiated flip.** Cut from
`main` after 2B-2 merges. The three-boundary text-area split, the
divider, pointer routing, the compatibility-preserving v21
activation, and `panel_capable = true` for a v21+ negotiated
authenticated semantic session. **This is the slice that changes
what a user sees**, and it repeats 2A's and 2B-2's relevant
assertions through the real capability flip.
**Each slice runs the full gate set below, not a subset of it.** A
slice that touches only `pmacs-protocol` still runs the GPU and vterm
suites: the shared validator and the wire enums are exactly the kind of
change whose breakage surfaces in a consumer rather than at its own
definition.
Gates for each slice: the standing suite from `CLAUDE.md`, plus the **touched
acceptance suites named explicitly** — the standing rule is to run the
suites a change touches, and "standing suite" does not name them:
- `bottom_panel_stage1_acceptance` — the substrate all four Stage 2
slices build on.
- `bottom_panel_stage2a_acceptance` — Stage 2A's classified census and
painter extraction.
- `bottom_panel_stage2b_protocol_acceptance` — Stage 2B-1's v21 schema,
server-first v20 compatibility, byte pins, and shared validation.
- `bottom_panel_stage2b_daemon_acceptance` — the exact suite name
reserved for Stage 2B-2's projection and epoch machine.
- `bottom_panel_stage2b_gpu_acceptance` — the exact suite name reserved
for Stage 2B-3's band, compatible activation, and capability flip.
- `statusline_segments_acceptance` — the fan-out target change (§3.3).
- `m11_5_semantic_acceptance` — the semantic census (§3.2).
- `gpu_initial_target_acceptance` — parent criterion 55.
- `gpu_font_acceptance` — font/scale geometry refresh (§5.3), including
the normal-face probe and the unscaled-`TEXT_TOP` decision.
- The three vterm suites — the panel hosts terminals.
- Folding Stage 2's 48 — shared projection.
- `PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu`.
Protocol round-trip and byte-pin tests ride 2B. Parent criterion 54's
`--headless-probe` run — one real daemon, real PTY, real wgpu, through a
panel-hosted terminal — is a 2B gate.

View File

@ -1,7 +1,9 @@
# Dired — framing
**Revision 7 — 2026-07-25. Status: APPROVED; Stage 0 MERGED as #162;
Stage 1 IN REVIEW as PR #165, review round 1 addressed.**
Stage 1 MERGED as #165 (`main` @ `c8ec8f3`, one review round). Stage 2
(marks and operations) and Stage 3 (wdired) each still need their own
framing before implementation; the frozen fixture shrinks after Stage 3.**
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

View File

@ -282,8 +282,17 @@ observers; they are not the transport implementation.
- Any `NotFound` from the initial load creates an empty path-backed buffer,
including when a parent is currently absent; save-time errors remain
save-time errors, matching local `pmacs FILE`.
- `PermissionDenied`, `IsADirectory`, invalid path bytes at the OS boundary,
and other non-`NotFound` errors fail startup.
- `PermissionDenied`, invalid path bytes at the OS boundary, and other
non-`NotFound` errors fail startup.
- **`IsADirectory` is superseded by Journey Stage 1a**
(`docs/journey-stage1a-framing.md`). A directory no longer reaches the
load at all: `resolve_target_buffer` answers `ResolvedTarget::Directory`
ahead of it, so a directory target now *succeeds*, dispatching the
`path.open-directory` chain and replying `Opened`. Deliberate
supersession, not drift — the whole point of that stage is that
`pmacs .` must not exit 1, and a daemon/GPU bootstrap that still failed
would leave the two entry points disagreeing about the same argument.
Non-directory failures are unchanged.
- The buffer display name may use `Path::display()` and therefore replacement
characters; this must never replace the raw backing path used for dedup,
load, or save.
@ -550,12 +559,17 @@ process behavior.
9. **New file:** a nonexistent target produces an empty snapshot, `[new file]`
status/path identity, accepts an edit/save through the real session, and
creates the requested file under the launcher cwd—not the daemon cwd.
10. **Open error:** a directory/permission-denied target returns a specific
10. **Open error:** a permission-denied target returns a specific
failure before ready/window creation and makes root fail. The daemon shuts
down that failed session's socket; a client that lingers or sends another
event cannot reach uninstalled session state. An existing daemon remains
connectable; a pre-existing frontend's active buffer and contents remain
unchanged.
**Amended by Journey Stage 1a:** the *directory* case is deliberately
superseded and moved to the success path — see Q#GT6. A directory
target now reaches ready and the document window shows dired, pinned
by `initial_target_directory_reaches_ready` and its two siblings in
`src/daemon.rs`. Permission-denied is unchanged and still fails.
11. **Dedup preserves unsaved edits:** frontend A opens and modifies a file
without saving; target-launch frontend B opens the same normalized path and
receives A's authoritative unsaved text with the same `BufferId`, not disk

View File

@ -81,7 +81,11 @@ character:
| | frames for a static screen | typed `Z` ever visible at the prompt |
|---|---|---|
| `main` today | **730** in a 20 s window | **no** |
| with the guard | **2** | (see Q#GT5 — a separate question) |
| with the fix | **2** | yes |
Bet B2 is **scored TRUE**: with the fix deployed, the reporter confirmed
typing into a GPU terminal works. The earlier caveat here pointed at Q#GT5,
which is now retracted — see "Deferred (named)".
The TUI is unaffected: a grid session has no semantic terminal declaration, so
only one arm ever runs for it. This is a **frontend-kind** defect, which is
@ -281,11 +285,12 @@ change. Stays v20.
snapshot that signals the switch-away). Hence the split in Q#GT1. Recorded
rather than deleted: the failure mode is one a reviewer or a future
simplification will re-propose.
- **B2.** The user's reported symptom is this defect. *Partially scored: the
storm is proven and GUI-only, and its shape (line editor unusable, output
still flowing) matches the report. Not fully scored until the user, or an
acceptance running the **user's own shell**, confirms typing works after the
fix. Q#GT5 is the reason this bet is stated rather than assumed.*
- **B2 — SCORED TRUE 2026-07-25.** "The user's reported symptom is this
defect." Confirmed in real use after the fix was deployed: typing into a GPU
terminal works. The confirmation needed a daemon **restart** built from a
tree containing the fix — the first attempt reported no change because a
pre-fix daemon still owned the socket, which is worth remembering whenever a
daemon-side fix is being validated by hand.
- **B3.** No other pair of per-frontend-kind daemon operations is applied as
siblings rather than alternatives. *Scored by an explicit audit of the
dispatcher's per-frontend loop during implementation — this defect's shape
@ -294,7 +299,14 @@ change. Stays v20.
## Deferred (named)
- Interactive-shell echo on a raw-mode PTY (Q#GT5) — its own scout.
- ~~Interactive-shell echo on a raw-mode PTY (Q#GT5)~~ — **RETRACTED
2026-07-25.** The observation behind it (a `bash --norc -i` fixture not
echoing typed characters) does not reproduce in real use: with the fix
deployed, typing into a GPU terminal echoes normally. The fixture was almost
certainly measuring its own timing — polling a published screen snapshot
before readline had finished initialising — not a product behaviour. Recorded
as retracted rather than deleted so nobody re-derives it from the framing's
earlier revision and spends a scout on it.
- **A geometry change appears to clear the visible screen.** Observed while
building acceptance 4: after the probe's deliberate 25×92 → 20×71 resize,
the next frame's visible grid is entirely blank even though the content

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,258 @@
# Framing — make the PTY terminate failure self-describing (diagnostic only)
**Revision 4.** Status: awaiting review round 4. Lane:
`pty-terminate-eperm`, worktree `../pmacs-math-slice`, based on
`githubsucks/main` @ `ccf29e3`.
**Diagnostic only. No disposition changes, no tolerance rules, no
behavioural fix.** Every rule this document proposed across revisions 1
to 3 is parked (§5). The lane's entire deliverable is that the next
occurrence of the failure explains itself.
## Revision history
**Revision 3 → 4**, after review round 3 (two blocking, one major) and
its scope call. All accepted.
- **Group-directed ESRCH was also unsafe**, for the same reason EPERM
was: it proves the selected *foreground group* vanished, not that the
leader exited. A job-control race — foreground job exits after
`tcgetpgrp` and before `kill`, shell alive and not yet reclaiming the
terminal — would have been reported as success with the leader never
signalled. Rev 3's acceptance 7 pinned that unsafe behaviour. **All
tolerance is parked** (§5).
- **Rev 3's Stage A implemented Stage B.** It declared itself
diagnostic-only, then listed tolerance and bookkeeping acceptances.
Removed.
- **Q#PS6 (already-reaped `terminate` is `Ok`) is parked separately.**
It is an independent behavioural fix answering a different failure;
under one-feature/one-PR it does not ride with instrumentation.
- **"Strictly additive / cannot regress behaviour" was overstated** and
is narrowed (Q#PD3).
- The injected-kill seam is restored as an explicit decision (Q#PD4).
**Rounds 13, for the record.** Rev 1 classified on errno alone and
claimed a live owned child cannot yield EPERM — false. Rev 2 gated on
`try_wait`, which observes the leader while a PTY signal targets the
foreground group — unsound whenever those diverge, and it could not be
shown to fix the observed failure at all. Rev 3 corrected EPERM but left
ESRCH unsafe and mixed the stages. **Three consecutive designs were
wrong in the same direction: each tried to conclude something about a
process from something that was not about that process.**
## 0. Coherence impact (COHERENCE §20)
- **Journey step 8, "Open a terminal"** (§2), teardown half. **No grade
change and no behavioural change** — this lane only improves what a
failure reports.
- **Serves §9 (worker model), failure attribution**, in its most literal
sense: an error that names only an errno cannot be attributed.
- **Interaction islands: none. Config registry: not adopted.
Background-work attribution: unchanged.**
- **No audited claim in COHERENCE.md changes**, so under §25 no
COHERENCE edit rides this PR.
## 1. Ground truth (scouted @ `ccf29e3`, re-verified each revision)
### 1.1 The failure reports an errno and nothing else
`ProcessSupervisor::signal` (`src/process.rs:921`) maps the `kill`
failure to `format!("kill: {e}")` (`:931`). That string is everything a
reader gets.
### 1.2 The signal target is not the observation target
- **Signal target**`signal_target` (`:687`) returns `-pgrp` for a
PTY, where `pgrp = master.process_group_leader()`: the tty's
**current foreground process group**, read at signal time.
- **Observation target**`ChildHandle::try_wait` (`:668`) observes the
**spawned leader**.
They coincide only while the leader owns the terminal. Job control is
precisely the mechanism that makes them diverge, and the PTY path is
**always group-directed by design** — spawn rejects `group = true` for
PTY mode with the rationale that "PTY children already lead their own
session and are signaled group-wide" (`:1428-1429`).
**This is why every tolerance rule across rev 13 failed review**, and
why the diagnostic must record the target and the leader state as
*separate* facts.
### 1.3 The reap ledger is disjoint from this path
`tick_reap_ledger` (`:1075`) treats any probe error as "nothing left we
can reach" for **bounded growth**, asserting EPERM "cannot happen for our
own children". It is armed only for `proc.spec.group`, which PTY mode
cannot set. Rev 1's "asymmetry" argument was a misreading; withdrawn.
### 1.4 The observed failure, and the limits of the evidence
macOS CI, PR #172 (**docs-only** diff), `Test (macos-latest / luajit)`,
`acc28_child_input_and_the_c_c_escape_work_unchanged_in_a_panel`
([attempt 1](https://github.com/levineuwirth/pmacs/actions/runs/30177276839/attempts/1)):
```
in function 'terminate'
cause: ExternalError(Process("kill: EPERM: Operation not permitted"))
```
**Established:** the errno, and the call path
(`pmacs.terminal.terminate` → `session.rs:566``signal`).
**Not established:** that the child had exited (the probe's last source
statement is a file write at
`tests/bottom_panel_stage1_acceptance.rs:2239`; CPython teardown follows
and does not synchronise with it); that any pgid was recycled; or what
the signal target actually was.
**This is the whole reason the lane is diagnostic.** Every candidate fix
needs at least one of those three facts, and none is available.
### 1.5 Caller inventory
| Caller | Disposition |
|---|---|
| `src/lsp.rs:1364`, `:2427` | discards (`let _ =`) |
| `src/mcp.rs:1229`, `:1239`, `:1915` | discards (`let _ =`) |
| `src/terminal/session.rs:319`, `:607`, `:635` | discards (`let _ =`) |
| **`src/terminal/session.rs:566`** (propagating at `:577`) | **propagates** as `TerminalError::Process` |
| supervisor-internal `shutdown` path | discards |
| `src/lua_bindings/mod.rs:8150`, `:8164` | propagates to Lua |
| `src/lua_bindings/mod.rs:8717` | propagates (via `session.rs:566`) |
| `src/daemon.rs:4162` | **test-only** `.expect`, not production |
No test in the repository asserts either error string, so widening the
message breaks nothing.
### 1.6 `portable-pty` caches the exit status on Unix
Pinned `portable-pty 0.9.0`: `spawn_command` returns
`std::process::Child` (`unix.rs:228`), and `impl Child for
std::process::Child::try_wait` delegates to
`std::process::Child::try_wait` (`lib.rs:271-277`), which caches into
`self.status`. Both `ChildHandle` variants therefore cache.
## 2. Decisions
### Q#PD1 — what the widened error records
On a `kill` failure in `signal`, the error carries:
| Field | Why |
|---|---|
| **target source**`tcgetpgrp` vs `group` vs `leader-pid` fallback | which branch of `signal_target` (`:687`) ran |
| **target kind and value**`-pgid` or `pid`, with the number | the entity actually signalled |
| **spawn-time pgid / leader pid** | a divergence from the target is the job-control hypothesis, visible only by comparison |
| **errno** | as today |
| **leader `try_wait` state**`exited(status)` / `live` / `unobservable(e)` | separates "the leader is gone" from "the group we signalled is gone" — the distinction all three failed designs collapsed |
Every candidate Stage B rule is decidable from these five together, and
none is decidable from the errno alone.
### Q#PD2 — the disposition is preserved exactly
The call still fails, with the same `Err`, in every case. No state
transition changes, no ledger arming changes, no tolerance. A reader
diffing behaviour should find none.
### Q#PD3 — the honest claim is "no disposition change", not "strictly additive"
Rev 3 said the diagnostic was only an error-string change and could not
regress behaviour. **That overstated it.** `try_wait` on an exited child
**reaps it and caches the status**, so consulting it in the failure path
is an internal state change: the child may be reaped earlier than it
otherwise would be.
Observably safe, because both variants cache (§1.6) and `poll_one`
(`:1133`) will still see `Ok(Some(_))` and emit its event. But safe by
argument is not safe by assertion, so the terminate-failure-then-tick
event pin is retained (acceptance 5).
### Q#PD4 — the injected-kill seam injects the KILL, never the observation
Acceptance 5 needs a forced `kill` failure while the **real**
`ChildHandle::try_wait` runs against the **real** child. A stubbed
observation would bypass exactly the code path in question.
So the seam is a test-only override of the *kill attempt's result*,
consumed once by the signal path; everything downstream — target
selection, the observation, the error construction — runs for real. This
also makes the diagnostic's own fields testable without racing the
kernel.
### Q#PD5 — nothing else lands here
No tolerance rule, no idempotence change, no `signal_target` change. See
§5.
## 3. Bets (falsifiable)
- **B1 — The five fields are sufficient to discriminate the §1.4
hypotheses.** Falsified if a recurrence carries all five and still
leaves the cause ambiguous — which would itself be a finding worth
having.
- **B2 — Widening the message breaks no caller.** Evidence: §1.5, and no
test asserts the string.
*Retracted across revisions and not reinstated:* rev 1's "a live owned
child cannot yield EPERM"; rev 2's "exit observation suffices"; rev 2's
"this removes the failure class"; rev 3's "group ESRCH is safe to
tolerate".
## 4. Acceptance
1. A group-directed `kill` failure produces an error carrying all five
Q#PD1 fields, with the target rendered as `-pgid` and the leader
state distinct from it.
2. A leader-directed `kill` failure does the same, with the target
rendered as `pid` and the target source recorded as the fallback
branch.
3. The leader state renders each of `exited(status)`, `live`, and
`unobservable(e)` correctly.
4. **The disposition is unchanged**: every injected failure still
returns `Err`, with no state transition and no ledger arming
(Q#PD2). Falsified by revert — flipping any arm to `Ok` fails this.
5. **Forced injected kill failure against the real PTY child
observation**, then tick: exactly one exit event, with the correct
status (Q#PD3/Q#PD4). A fully stubbed observation does not satisfy
this and is rejected as vacuous.
6. The existing suites stay green, pinning "no behavioural change" from
the outside.
## 5. Parked (not deferred-and-forgotten — each needs its own evidence)
- **All tolerance rules.** Group-directed EPERM *and* ESRCH both fail on
the §1.2 entity split; leader-directed tolerance is plausible but
unmotivated until evidence shows the fallback branch is ever taken.
Needs Stage A evidence first.
- **Q#PS6, `terminate` on an already-reaped process returning `Ok`.**
Independent behavioural fix, different failure (§1.6 of rev 3), its
own lane under one-feature/one-PR.
- **`signal_target`'s read-then-kill of `tcgetpgrp`** — still the most
likely real fix site, still unframed.
- `terminate` cancelling pending restarts; PTYs in
`pmacs.process.list`; any change to `C-c` delivery.
## 6. Gates
Full suite per `CLAUDE.md`. Touched suites:
`bottom_panel_stage1_acceptance`, the vterm stages, and
`compile_mode_acceptance`. Sweep with `-- --skip basedpyright`.
## 7. Branch plan
`pty-terminate-eperm`, one PR, diagnostic only. This framing is its first
commit; the instrumentation and its tests are the second.
**The lane then closes.** It does not wait for the flake to recur: the
next occurrence — whenever it happens, under whoever's PR — carries its
own evidence, and Stage B is framed then. Math work proceeds immediately
after this lands.

View File

@ -0,0 +1,850 @@
# Terminal configuration and copy mode
**Revision 4 — scouted against canonical `main` @ `b889873` (protocol v20),
2026-07-25. APPROVED after four review rounds. Stage 1 MERGED as #173
(`main` @ `cf54270`, 2026-07-26). Stage 2 implemented on branch
`terminal-copy-mode` off `main` @ `cf54270`; no protocol change.**
**Stage 2 ships eight of its nine criteria, plus 18a and 18b added in review
round 1 and 16c-16e in rounds 2-3.** Rounds 2 and 3 changed the design, not
just the code: the snapshot is now genuinely `read_only` at the rope, so
**Q#TC6a's analysis below is superseded in part** — read the box at its head
before the analysis. Q#TC6a's conclusion survives; two of its premises do
not, and **criterion 17's bite was restated with them** — the daemon now
refuses the op, so the failure it must look for is mirror mutation plus
divergence, not silent agreement.
Criterion 17's semantic-frontend end-to-end pin is deliberately
absent — see the note under it — because a faithful version requires the real
`pmacs-gpu` optimistic path, and therefore the `a37` foundation, which CI never
compiles and which skips silently. Both halves of the *mechanism* it guards are
pinned ungated instead (16, 16b). No other criterion is partial.
**Review round 1 found four defects, and the pair of them rhymes.** Two were
implementation (18a's foreign-buffer clobber, 18b's name-keyed identity) and
two were vacuous pins (18/19's refresh, 20's tail-follow) — and all four trace
to the same root: **a name is not an identity, and a context-free readout is
not a state observation.** The name mistake produced both P1s; the readout
mistake produced both P2s.
Revision 4 gives the escape-key cache an owner and a lifecycle (Q#TC4c) —
revision 3 named the key but not the storage, and two implementations
satisfied its acceptance while behaving differently on A→B→A. It also corrects
the read-only deferral, which understated the substrate required: the bypass
path is `ensure_writable`-guarded too, so genuine immutability alone would
break every generated buffer that refreshes.
Revision 3 corrects two design errors and decides the chords. The
round-trip failure shape in revision 2 was **wrong in the reporter's favour**:
a Lua intercept does not set `Buffer::read_only`, and there is no Lua binding
that does, so an optimistic `CrdtOp` bypasses the intercept *and* passes
`ensure_writable()` — the daemon buffer mutates too, rather than the mirror
diverging alone (Q#TC6a). Revision 2 also had all three settings resolving
against the terminal identity buffer, which is impossible for the two read
*before* that buffer exists (Q#TC2b). Chords are now decided and
collision-scouted rather than deferred to implementation (Q#TC10, Q#TC8a).
Revision 2 answered seven review findings. Four were load-bearing: the settings
are `Live`, so the registry **accepts buffer-local overrides whether or not we
want them**, and `value_epoch()` does not move on a buffer switch — an
epoch-only cache can serve the wrong terminal's escape chord (Q#TC4); the
double-escape byte is a hardcoded `0x03`, so a configured escape would still
send Ctrl-C and make its own literal chord unreachable (Q#TC4b); the snapshot
buffer needs `set_round_trip_input`, not only a read-only intercept, or a
semantic frontend can optimistically edit it before daemon dispatch (Q#TC6);
and the two stages must be two branches and two PRs. Revision 1's
materialized-copy reframe is unchanged.
Two stages, one arc, no protocol change:
- **Stage 1 — configuration.** Terminal profiles, scrollback, and the escape
key become configurable. Today the terminal has **zero** configuration
surface: the `terminal` command hardcodes `os.getenv("SHELL") or "/bin/sh"`,
`scrollback_rows` is a per-open argument only, and the escape chord is a
literal in Rust.
- **Stage 2 — copy mode and search over scrollback.** A command that turns
the retained terminal screen and scrollback into an ordinary buffer, where
isearch, motion, selection, and the kill ring already work.
Explicitly **not** in this arc: the panel terminal (blocked on bottom-panel
Stage 2), and shell integration (cwd tracking, prompt marks, command zones) —
the keystone that unlocks the VS Code-style cluster, which needs its own
security framing because it decides what a child process may make the editor
do.
## Branch and PR plan
**Two branches, two PRs.** Configuration and copy mode are independently
releasable and have no dependency on each other; one framing covers the arc,
but the one-feature/one-branch/one-PR rule governs the implementation.
1. `terminal-config` — Stage 1. Also carries the **terminal opening
keybinding** (Q#TC10).
2. `terminal-copy-mode` — Stage 2, branched off `main` after Stage 1 merges.
Sequencing is not a dependency but avoids a conflict: both stages edit
`builtin/runtime/terminal.lua`.
## Ground truth (measured, not recalled)
Three facts constrain the design, and two of them rule out the obvious plan.
### 1. Terminal profiles cannot be a config-registry setting
`ConfigValue` is **four scalars**`Bool`, `Int`, `Num`, `Str`
(`src/config_registry.rs:312`) — and its own doc comment says they "are never
stored --- only these four scalars (Q#CR3)". `ConfigKind` adds `Enum`, which
is physically a string validated against choices fixed at `define` time
(`src/config_registry.rs:115-145`). There is no table, list, or map kind.
A terminal profile is inherently a table: `{ command, args, cwd, env }` per
name. **Table-valued settings are an existing named deferral of the config
registry arc** — the same gap that keeps `pmacs.lsp.config`,
`pmacs.pair.sets`, `pmacs.comment.strings`, and the `pmacs.parse.*` proxies as
raw Lua. Profiles join that list rather than forcing that deferral open here.
### 2. Search cannot reuse isearch in place over a terminal
`SearchStore::set(buffer_id, query, matches: Vec<ByteRange>)`
(`src/search.rs:99`) keys matches by buffer and addresses them as **byte
ranges into that buffer's rope**; the painting path materializes the source
with `buf.snapshot_rope().slice(0, buf.len(), ..)` (`src/search.rs:435`).
A terminal identity buffer is **empty and read-only** by construction. Its
content lives in `TerminalScreen` as cells addressed by `(row, col)` across
history plus visible rows — there are no rope bytes to range over. Searching a
terminal in place therefore means a second, parallel search facility with its
own match store and its own highlight path, because terminal painting consumes
owned cells and not document style spans.
### 3. An in-place copy mode would be the seventh dispatch shadow
`dispatch_key`'s terminal-transport arm intercepts **every** key before
ordinary keymap dispatch whenever `active_terminal_key` is `Some`, which keys
purely on `is_terminal(window.buffer_id)` (`src/editor.rs:1098-1107`,
`973-1016`). A mode that keeps the terminal buffer focused while rebinding
keys to motion/selection must therefore add a new precedence rung.
`COHERENCE.md` §6 grades that ladder **weak, "and growing by one island per
modal feature"**, records that **no transient-keymap mechanism exists to
migrate to** (`KeymapStack` has exactly three fixed scopes, no layer stack, no
push/pop, no lifetime), and notes that `describe-key` already lies while a
shadow is active. It also names the counter-example: the entire picker/panel
family uses ordinary **buffer-local keymaps** and is inspectable and
rebindable.
### 4. What already exists and is reusable
- `retained_rows(projection)` (`src/terminal/view.rs:539`) iterates history
plus visible rows; `copy_selection_bytes(rows, selection)`
(`src/terminal/view.rs:849`) serializes a range with the fidelity Stage 2
criterion 21 already pins — soft wraps joined, hard rows separated, trailing
default blanks trimmed, wide glyphs and combining clusters copied once.
- `ConfigRegistry::value_epoch()` (`src/config_registry.rs:1127`) is public and
monotonic — cheap invalidation for a hot-path cache.
- The Lua surface is `define` / `get` / `set` / `set_local` / `on_change` with
a disposable handle (`src/lua_bindings/config.rs`).
- `pmacs.terminal.open` already accepts
`command, args, cwd, env, name, rows, cols, scrollback_rows, display,
window`. **`display = "panel"` already works** (bottom-panel Stage 1) — the
panel terminal is blocked on rendering, not on this surface.
- Terminal buffers already carry buffer-local bindings (`M-w`, `M-v`, `C-v`,
`M-<`, `M->`) installed by `terminal.open` in `builtin/runtime/terminal.lua`.
## Stage 1 — configuration
**Q#TC1 — Profiles are a raw Lua table, not a setting.**
`pmacs.terminal.profiles` maps a name to a spec table, exactly following the
`pmacs.lsp.config` precedent. The registry holds only scalars. Rejected
alternative: widening `ConfigValue` with a table kind — that is the config
arc's own named deferral, it is cross-cutting (persistence, `describe-setting`
rendering, the `custom-file` question all key on the scalar assumption), and
smuggling it into a terminal PR would be the wrong place to decide it.
**Q#TC2 — `terminal.default-profile` is `String`, not `Enum`.** `Enum`
choices are frozen at `define` time; profiles are user-extensible from
`init.lua` and later. Validation happens at open time, and an unknown name
must produce a pointed error that **names the known profiles**, not a bare
"unknown profile".
**Q#TC2a — the exact settings, defaults, and bounds.** All three are `Live`
(see Q#TC2b), and every default reproduces today's behavior exactly, so a tree
with no settings written behaves identically (acceptance 12).
| name | kind | default | bounds |
|---|---|---|---|
| `terminal.default-profile` | `String { allow_empty: true }` | `""` | — |
| `terminal.scrollback-rows` | `Integer` | `10_000` (`DEFAULT_TERMINAL_SCROLLBACK_ROWS`) | `0 ..= 4_000_000` (`MAX_TERMINAL_HISTORY_CELLS`) |
| `terminal.escape-key` | `String { allow_empty: false }` | `"C-c"` | parsed as a chord |
**Zero is a legal scrollback value meaning "retain no history".** The core's
own validation rejects only values *above* `MAX_TERMINAL_HISTORY_CELLS`
(`src/terminal/session.rs:114`), so `scrollback_rows = 0` is accepted through
`terminal.open` today. A `1` minimum here would invent an asymmetry between the
setting and the per-open field for no reason.
`""` is the **"no default profile" sentinel**: an empty string means "fall
through to `$SHELL`", not "a profile named empty". `allow_empty: true` exists
precisely to express it, and the open path treats empty and unset identically.
**Q#TC2b — the settings are `Live`, and the registry therefore accepts
buffer-local overrides. That is specified rather than accidental.**
`ConfigRegistry::set_local` refuses only `StartupOnly` definitions
(`src/config_registry.rs:949`); a `Live` setting can be pinned per buffer by
anyone. Declaring these global-only is **not currently expressible** — a
`scope = "global"` define flag is one of the config registry's own named
deferrals, and `autosave.interval-ms` already has the same latent problem.
Making them `StartupOnly` instead would buy enforcement at the cost of the
feature: the escape key could never be changed mid-session, which kills Q#TC4's
whole point. So they stay `Live`, and resolution is defined **per setting,
because the three are not read at the same moment**:
| setting | read when | resolution |
|---|---|---|
| `terminal.escape-key` | every keystroke in a terminal (cached) | `get(name, terminal_buffer)`**buffer-local → global → default** |
| `terminal.default-profile` | once, **before** the terminal exists | `get(name)`**global chain only** |
| `terminal.scrollback-rows` | once, **before** the terminal exists | `get(name)`**global chain only** |
The split is forced, not stylistic. The two open-time settings are consumed by
`_open` **before it creates the identity buffer**, so there is no terminal
buffer to resolve against — and no caller could have pinned a local override on
a buffer that does not yet exist. `pmacs.config.get(name)` with no buffer
argument already means exactly "the global chain, never an ambient buffer", so
this is the registry's existing semantic rather than a new rule.
Consequences, stated so they are not discovered later:
- a per-terminal escape key is a supported feature, not a bug;
- `set_local` on `terminal.default-profile` or `terminal.scrollback-rows` is
**always inert**, for any buffer, because the open path never consults a
buffer chain. This is deliberate; the alternative — resolving against
whichever buffer happened to be current at open time — would make a
terminal's scrollback depend on what the user was looking at when they
pressed the key.
Rejected alternative: resolving the open-time settings against the *target
window's pre-open buffer*. It is expressible, but it makes an ambient buffer
load-bearing for a value the user set globally, which is the trap
`pmacs.config`'s two-argument/one-argument split exists to avoid.
**Q#TC3 — `terminal.scrollback-rows` is `Integer` with bounds, and an explicit
per-open `scrollback_rows` still wins.** The precedence is
**explicit argument over global setting** — there is no ambient buffer in this
chain at all (Q#TC2b resolves it through `get(name)`), so the rule is simply
that what a caller passes to `terminal.open` beats what the user configured
globally. The bounds above come from the existing validation, so the setting
cannot express a value the core will reject.
**Q#TC3a — profile resolution order, field by field.** `profile` is accepted
by **`pmacs.terminal.open` as well as the command**, so a Lua caller is not
forced through the command to use one. For each field, the first source that
supplies it wins:
1. an explicit `pmacs.terminal.open` field;
2. the named profile's field — `profile` argument, else
`terminal.default-profile` when non-empty;
3. the scalar setting, where one exists (`scrollback_rows` only);
4. the built-in fallback (`command` = `$SHELL`, else `/bin/sh`).
`env` is the one field where "first wins" is ambiguous, so it is stated:
profile `env` and explicit `env` are **merged**, with explicit entries
overriding profile entries of the same name. Any other reading silently drops
half a user's environment.
An explicitly passed `profile` that does not exist is an error even when
`terminal.default-profile` is valid — a typo must not silently fall back to
the default.
**Q#TC4 — `terminal.escape-key` is a `String` chord spelling, parsed once and
cached by `(buffer_id, value_epoch)`.** `is_terminal_escape_chord`
(`src/editor.rs:4413`) currently compares against a literal `C-c`. Reading and
parsing a setting on **every keystroke in a terminal** is not acceptable in
that path.
**The cache key must include the buffer.** `value_epoch()` advances only on
`set` / `set_local` / removal (`src/config_registry.rs:918`, `970`, `1011`,
`1029`) — **it does not move when the focused terminal changes**. An
epoch-only cache therefore serves terminal A's escape chord to terminal B for
as long as no setting is written, which is exactly the case where nothing looks
wrong. Keying on `(buffer_id, value_epoch)` is the minimum correct identity.
**Q#TC4c — the cache lives on `TerminalSession`, so its lifecycle is the
terminal's.** Revision 3 named the key `(buffer_id, value_epoch)` but not the
storage, and the two obvious storages behave differently on A→B→A:
- a **single last-entry cache** reparses on every switch between two
terminals, and re-reports an invalid value each time — a status line that
scolds you for a setting you already know about, forever;
- an **editor-side map** preserves "parsed and reported once" but **leaks an
entry per terminal** unless something purges it, and that purge is a second
thing to get wrong.
`TerminalSession` (`src/terminal/session.rs:215`) is created in
`TerminalManager::open` and dropped on kill/prune, so putting the cache there
gets the lifecycle for free with no purge hook to forget. It carries the parsed
chord, the `value_epoch` it was parsed at, and whether the current invalid
value has already been reported.
**"Reports once" means once per terminal, per effective invalid value.**
A→B→A must not re-report. Changing the setting from one invalid value to a
*different* invalid value **does** re-report, because that is new information
about a new mistake.
**The reporting channel is `EditorCore::status`** — the same channel
`send_terminal_bytes` already uses for terminal failures
(`src/editor.rs:1122`). Explicitly **not** `pmacs.error`: it is not installed
as a module anywhere in `src/lua_bindings`, so its call sites across the
runtime are dead, and a report sent there would be a report nobody sees.
**Q#TC4a — an unparseable escape key must not brick terminal input.** A bad
value falls back to `C-c` and reports once. The failure mode this avoids is
severe: with no escape chord, every key goes to the child and the user cannot
reach any editor binding to fix the setting that broke it.
**Q#TC4b — repeating the configured escape sends THAT chord to the child, not
Ctrl-C.** The double-escape arm currently writes a hardcoded
`&[0x03]` (`src/editor.rs:988`). With `terminal.escape-key = "C-x"`, `C-x C-x`
would send Ctrl-C — and literal Ctrl-X would become unreachable, since the
first `C-x` is always consumed as the escape. The repeat arm must encode the
**configured** chord through the existing `crate::terminal::input::encode_key`
path, which is also how it inherits application-cursor and modifier handling
rather than growing a second encoder.
Corollary worth pinning: after changing the escape away from `C-c`, an ordinary
`C-c` must reach the child as `0x03` like any other unescaped key.
**Q#TC5 — the `terminal` command gains an optional profile argument** and
otherwise keeps its current behavior; `$SHELL` remains the fallback when no
profile is configured. No existing invocation changes meaning.
**Q#TC10 — the terminal opening keybinding is pulled forward into Stage 1.**
`COHERENCE.md` Priority 1 names "a terminal keybinding" as part of protecting
the golden journey, §2 step 8 grades the terminal "works but undiscoverable",
and this stage already edits `terminal.lua`. Panel rendering imposes no
dependency on binding a command that already exists. Close/kill semantics stay
with the panel work, where the entry and exit points get designed together.
The chord is **decided and scouted, not deferred**: `C-c t`, global. See
Q#TC8a for the collision evidence and for why binding under the existing `C-c`
prefix is a new leaf rather than a shadow.
## Stage 2 — copy mode and search
**Q#TC6 — copy mode MATERIALIZES into an ordinary buffer. It does not add a
dispatch shadow.**
`M-x terminal.copy-mode` snapshots the retained rows into a read-only,
path-less buffer (`*terminal-copy: NAME*`) and displays it. That buffer is an
ordinary document buffer, so:
- **isearch works, with no new search substrate** — it is a rope, so
`SearchStore` and the existing match-painting path apply unchanged. Ground
truth 2 is answered by not fighting it.
- **motion, selection, `M-w`, the kill ring, even `M-x occur`-style consumers
work** — everything that operates on a buffer.
- **The "keys must not reach the child" problem dissolves structurally.**
`active_terminal_key` keys on `is_terminal(window.buffer_id)`; the snapshot
buffer is not a terminal, so the transport arm never fires. No new guard, no
new precedence rung, and ground truth 3's coherence cost is avoided rather
than paid.
- **`describe-key` stays truthful**, because the bindings are buffer-local and
inspectable — the idiom `COHERENCE.md` §6 identifies as the right side of
the line.
**Q#TC6a — the snapshot is read-only at the rope AND round-trip-marked, and
each guard covers a copy the other cannot reach: `read_only` refuses the op
at the daemon, `set_round_trip_input` is the ONLY thing standing between a
replica frontend and unauthorized mutation of its own mirror.**
> **SUPERSEDED IN PART BY IMPLEMENTATION (review rounds 2-3). Read this
> box before the analysis below it.** The reasoning is still the correct
> account of the substrate *as it stood when this was written*, and its
> conclusion about round-trip input still holds. Two of its premises no
> longer do:
>
> - "**No Lua binding sets `read_only` at all**" — one does now.
> `pmacs.buffer.set_generated_contents` leaves it asserted, so on the
> daemon side undo, redo, ordinary edits and imported CRDT ops are all
> refused by `ensure_writable()`. That closed a real defect: undo
> bypasses the intercept chain, so `M-x buffer.undo` emptied the
> snapshot.
> - "**`set_round_trip_input` is the ONLY thing**" — it is now the only
> thing standing between a replica and *mirror* mutation, which is the
> half `read_only` cannot reach. A semantic frontend applies
> optimistically in its own mirror before the daemon sees the op; a
> daemon-side refusal cannot prevent that, it can only make the two
> copies disagree.
>
> The protection is therefore **layered, not singular**: rope-level
> read-only protects the daemon copy, round-trip input protects the
> replica copy, and neither substitutes for the other. The intercept
> survives only to give a dispatching edit a named error. The Deferred
> lane below records what this leaves open for `*compilation*` and
> listview, which have **not** adopted the primitive.
The established idiom is two calls: `listview.lua:106` and `compile.lua:272`
each pair `pmacs.buffer.add_intercept` with
`pmacs.buffer.set_round_trip_input(buf, true)`. Revision 2 described the
intercept as the guard and round-trip as defence in depth. **That was wrong,
and the correction matters:**
- A Lua intercept guards the **dispatch/edit** path only. It does **not** set
`Buffer::read_only`, which is "deliberately independent of edit intercepts"
(`src/buffer.rs:493-500`) — that flag is what makes terminal identity buffers
reject rope, undo/redo, and remote-CRDT mutation alike.
- **No Lua binding sets `read_only` at all.** The whole `src/lua_bindings`
tree only ever *reads* it (`fold.rs:313`). A Lua-created "read-only" buffer
is therefore read-only against dispatch and nothing else.
- So an optimistic `CrdtOp` from a semantic frontend bypasses the intercept
**and passes `ensure_writable()`**. It is applied. The daemon buffer mutates
in lockstep with the mirror — the user silently edits a buffer the editor
told them is read-only. There is no divergence to notice, which is worse
than divergence.
`set_round_trip_input` prevents this at the only point it can be prevented: it
makes `dispatch_idle_for` report false while the buffer is focused, so the
frontend never applies optimistically and never emits the op. It is not
hardening — it is the guard.
Two things follow, and both are recorded rather than fixed here:
- **The same exposure exists today** for every Lua-created read-only buffer —
listview panels and `*compilation*` included. They are correct only because
they call `set_round_trip_input`. This arc must not be the place that
unilaterally changes that substrate.
- **Exposing `Buffer::set_read_only` to Lua** would make these buffers
genuinely immutable at the rope/CRDT boundary the way terminal identity
buffers are, turning round-trip back into real defence in depth. That is a
substrate change affecting listview and compile as much as this snapshot, so
it is named in Deferred with its own lane. **Done for this snapshot only**,
and not by exposing the setter — see the Deferred lane and the box above.
**Q#TC7 — the materializer reuses the existing serializer.** A whole-range
variant of `copy_selection_bytes` over `retained_rows` inherits the criterion
21 fidelity rather than re-deriving soft-wrap, wide-glyph, and trailing-blank
behavior. Writing a second serializer would guarantee the two drift.
**Q#TC8 — one snapshot buffer per terminal, reused on re-invoke.** Re-running
the command against the same terminal replaces the contents in place rather
than accumulating buffers. It is killed with its terminal; killing the
snapshot alone leaves the terminal untouched.
**Q#TC8a — the chords, decided and collision-scouted.**
Worth stating first because it is easy to get backwards: in a terminal window
every **unescaped** key goes to the child, so terminal-local bindings are
reached as `<escape> <key>`. The existing `M-w` copy is physically `C-c M-w`.
The escape consumes itself and the next key starts a fresh ordinary sequence,
which is also why `C-c`-leading bindings are structurally unreachable *inside*
a terminal.
| action | scope | binding | physically typed |
|---|---|---|---|
| open a terminal (Q#TC10) | global | `C-c t` | `C-c t` |
| enter copy mode | terminal buffer | `C-t` | `C-c C-t` |
| refresh snapshot | snapshot buffer | `g` | `g` |
| return to terminal | snapshot buffer | `q` | `q` |
Scouted against the real keymaps:
- **`C-c t` is free.** No bare global `C-c` binding exists; `C-c` is already a
live global prefix from `fold.lua:48-52` (`C-c @ …`), and `C-c C-k` is
buffer-scoped in compile/async. `C-c t` is a new leaf under an existing
prefix, not a shadow.
- **`C-t` is globally `edit.transpose-chars`** (`editops.lua:909`), and binding
it **buffer-locally is legitimate**: `keymap.bind`'s strictness rejects
binding a *prefix* of an existing sequence within a scope
(`keymap_bind_conflict_surfaces_at_bind_time` — "would shadow"), not
cross-scope shadowing, which is what scopes are for. Listview already binds
`n`/`p`/`g`/`q`/`RET`/`SPC` buffer-locally. Transpose-chars is meaningless in
a read-only terminal buffer.
- `C-c C-t` matches emacs-libvterm's own `vterm-copy-mode` chord, so the muscle
memory transfers.
- `g` / `q` in the snapshot follow listview's precedent exactly.
**Named limitation:** `C-c t` cannot open a terminal *from inside* a terminal,
because `C-c` is consumed as the escape there. `M-x terminal` still works. This
is the documented consequence of Stage 2 criterion 19, not a new defect.
These are what make acceptance 21's `describe-key` claim testable: named
bindings, in named buffers, that introspection must report truthfully.
**Q#TC9 — the live-terminal keys stay.** `M-w`, `M-v`, `C-v`, `M-<`, `M->` on
the terminal buffer are the live affordances and do not change. Copy mode is
additive, on its own binding, and does not replace scroll-and-select.
## Bets
- **B1.** Materializing gives search for free: no second match store, no
second highlight path, no terminal-specific search UI. *Scored by Stage 2
landing with zero changes under `src/search.rs`.*
- **B2.** Point-in-time is sufficient for read-back/search/copy. *Scored by
use; if false, the live frozen mode in Deferred becomes the real feature and
this becomes its snapshot fallback.*
- **B3.** No protocol change. The snapshot is an ordinary buffer, so both
frontends render it with existing machinery. *Scored by the diff.*
- **B4.** The escape-key cache keyed by `(buffer_id, value_epoch)` never
becomes stale in a way a user can observe. *Scored by two acceptances, not
one: changing the setting mid-session (8) and two terminals with different
buffer-local values and no write between them (7). Revision 1's epoch-only
cache would pass the first and fail the second, which is why the bet now
names both.*
- **B5.** Buffer-local escape keys are a feature rather than a hazard.
*Unscored and honestly so: the registry cannot express global-only, so this
is what we get either way. If per-terminal escapes turn out to confuse more
than they help, the fix is the config registry's `scope = "global"` deferral,
not a terminal change.*
## Deferred (named)
- **Live frozen copy mode** (true `vterm-copy-mode` semantics: freeze the
terminal in place, navigate it, resume). Strictly larger; needs either the
transient-keymap primitive `COHERENCE.md` §6 specifies or a deliberate
seventh shadow.
- **Shell integration** — cwd tracking, prompt marks, command zones, and the
VS Code cluster downstream of it (command decorations, exit-code markers,
rerun, sticky scroll, terminal IntelliSense). Its own arc, with a security
framing.
- **Table-valued settings** — the config registry's own deferral. This arc
adds a **second** blocked adopter (after `pmacs.lsp.config` /
`pmacs.pair.sets`); worth recording as evidence when that deferral is
ranked.
- **A `scope = "global"` define flag** — also the config registry's own
deferral, and this arc is its second live case after `autosave.interval-ms`.
Until it exists, `set_local` on any `Live` setting is accepted whether or not
the owner wants it, so Q#TC2b specifies the behavior instead of pretending
it is prevented.
- **Panel terminal** — blocked on bottom-panel Stage 2 (semantic frontends are
not `panel_capable`). `display = "panel"` already exists and works on the
grid frontend.
- OSC 8 hyperlinks, images (sixel/kitty), `faint`/`blink`/`conceal`/
`strikethrough` (needs a shared `Style` widening, so a protocol bump),
cursor shape/blink, kitty keyboard protocol.
- Terminal session persistence/reconnect across editor restart.
- **A terminal close/kill command** — the remaining half of `COHERENCE.md`
§2 step 8's discoverability gap. It belongs with the panel-terminal work,
where entry and exit points get designed together. The *opening* keybinding
is **no longer deferred**: Stage 1 carries it as Q#TC10.
- **Genuine immutability for generated buffers — and it is bigger than a Lua
setter.** Today no Lua binding sets `read_only` (`src/lua_bindings` only
reads it, `fold.rs:313`), so every Lua-created "read-only" buffer — listview
panels, `*compilation*`, and this snapshot — is read-only against dispatch
alone and relies entirely on `set_round_trip_input` (Q#TC6a).
Merely **exposing `set_read_only` would break all three.** The
intercept-bypass path is `ensure_writable`-guarded too:
`apply_edit_skip_intercepts` calls it first (`src/buffer.rs:994`), and that
is exactly the primitive an owner uses to rewrite its own generated buffer.
Flipping the flag would stop listview refreshing, `*compilation*` streaming,
and this snapshot refreshing — the very operations those buffers exist for.
So the lane needs **two** things, not one: genuine immutability at the
rope/CRDT boundary, *and* an owner-authorized update path that is not simply
"skip the intercepts". Naming only the setter would have made it look like a
one-line follow-up.
**PARTIALLY RETIRED in Stage 2, because review round 2 turned it from a
nice-to-have into a defect.** An intercept guards the dispatch path only,
and `Buffer::undo` reaches the rope through `ensure_writable` without ever
consulting the intercept chain — so a single `C-/` replaced a freshly
rendered snapshot with an empty buffer. Rebinding the undo chords
buffer-locally, which is `*compilation*`'s existing idiom, does **not**
close it: `compile.lua` says so itself ("command/menu undo stays
dispatchable"), and `M-x buffer.undo` needs no keymap.
The fix ships the deferral's two halves together as **one** primitive
rather than exposing the setter: `Buffer::set_generated_contents` (Lua:
`pmacs.buffer.set_generated_contents`) lifts `read_only`, replaces the
contents skipping intercepts, **discards the history**, and re-asserts
`read_only`. Pairing the lock with the write is precisely what makes it
safe — a bare `set_read_only` would let a caller lock a buffer it can no
longer refresh, which is why the lane was deferred in the first place.
Discarding history is load-bearing twice: it removes the entries undo
would replay, and it stops a periodically refreshed buffer accumulating
rope clones that `read_only` guarantees nothing can ever pop.
**What remains of the lane — four writer mechanisms over five
buffers**, not the two this section first named (round 5 found the
inventory short; round 6 found the corrected version misattributing
the search panel). Every remaining intercept-protected writer still
relies on intercept-plus-round-trip over a writable rope, and every one
is still emptiable by `M-x buffer.undo`: listview panels
(`listview.lua:60-61`); `compile.lua`'s `ensure_slot`, which serves
`*compilation*` and `*shell-command*`**not** `*search-results*`,
which `compile.lua` names only in a predicate; the independent
`*search-results*` panel in `builtin/commands/default.lua:869`, with
its own intercept and writes; and dired buffers (`dired.lua:371`). The
primitive they need now exists and is proven, so the remaining work is
adoption plus a streaming-friendly variant — the three appending
buffers need it, while listview and dired already write whole-buffer
replaces and are the cheap half.
**The CRDT half is closed too** (review round 3). Clearing the v0.1
stacks proves nothing in CRDT mode, where they are bypassed entirely and
the history lives in loro's `UndoManager`. `read_only` would stop that
history being *replayed* but not *retained* — a panel refreshed on a
timer still grows without bound, which is the condition the contract
says it eliminates. `UndoManager` exposes no `clear`, but it needs none:
a manager records only what happens after it is constructed, which
`CrdtState::from_bytes` already relies on to keep the seed insert out of
undo. `CrdtState::clear_undo_history` rebinds a fresh manager to the same
doc, and `set_generated_contents` clears whichever history the buffer
actually has.
## Acceptance
### Stage 1 — `terminal-config`
1. `pmacs.terminal.profiles` accepts a strict spec table per name and rejects
unknown fields before anything is spawned, matching `terminal.open`'s
existing transactional contract.
2. `terminal.default-profile` naming an unknown profile fails at open with an
error that **lists the known profile names**, and creates no buffer,
session, or process. An explicitly passed unknown `profile` fails the same
way **even when `terminal.default-profile` is valid** (Q#TC3a).
2a. That diagnostic is **total over a malformed profiles table** (review round
1). `pmacs.terminal.profiles` is a raw user table, so listing its names must
not assume its keys are comparable and rendering a requested name must not
assume it is a string: a table holding both a string and a numeric key made
`table.sort` raise `attempt to compare number with string` *on the
unknown-profile path*, replacing the exact error being asked for, and `%q`
raises on a non-string `profile` argument. Both are partial functions
applied to user input on a diagnostic path — the failure class is
"the error reporter is the thing that fails".
3. Field-by-field resolution follows Q#TC3a: explicit open field beats profile
field beats scalar setting beats `$SHELL`. `env` **merges**, with explicit
entries overriding profile entries of the same name.
4. `""` in `terminal.default-profile` means "no profile" and is
indistinguishable from unset (Q#TC2a).
5. `terminal.scrollback-rows` takes effect for a terminal opened without an
explicit `scrollback_rows`; an explicit per-open value overrides it; values
outside `0 ..= 4_000_000` are rejected by the registry rather than by the
core, and `0` is accepted as "retain no history".
6. `terminal.escape-key` changes which chord escapes to the editor, observed
through the **real dispatch path**, not by calling the predicate directly.
7. **Two terminals with different buffer-local escape keys each honor their
own**, with no setting written in between (Q#TC4/Q#TC2b). Driven as
**A→B→A**, asserting both directions. This is the pin an epoch-only cache
fails.
8. Across that same **A→B→A** switch with no setting written, the parse count
does **not** increase after each terminal's first keystroke (Q#TC4c) —
pinned by counting parses, not by timing. This is the pin a single
last-entry cache fails while still satisfying 7.
8a. A terminal's cache does not outlive it: killing a terminal and opening a
new one does not serve the dead terminal's chord, and no per-terminal cache
entry survives its session (Q#TC4c). This is the pin an unpurged
editor-side map fails.
9. With `terminal.escape-key = "C-x"`: `C-x C-x` sends **Ctrl-X** to the child,
and an ordinary `C-c` reaches the child as `0x03` like any other unescaped
key (Q#TC4b). Bite: against the hardcoded `&[0x03]`, the first assertion
fails.
10. An unparseable `terminal.escape-key` falls back to `C-c`, reports through
`EditorCore::status`, and leaves the terminal usable (Q#TC4a). Bite: with
the fallback removed, the terminal becomes unescapable.
10a. "Reports once" is once per terminal per effective invalid value
(Q#TC4c): an **A→B→A** switch with the same invalid value reports **once**,
while changing it to a *different* invalid value reports again. The report
count is asserted, not the message text.
11. The terminal opening keybinding invokes the existing command, and is
verified to have shadowed nothing (Q#TC10).
12. Existing `terminal` invocations and every existing terminal test behave
identically with no settings defined and no profiles registered.
### Stage 2 — `terminal-copy-mode`
13. `terminal.copy-mode` produces a read-only buffer whose text is
byte-identical to serializing the full retained range through the existing
copy path (Q#TC7) — pinned against the serializer, so the two cannot drift.
14. Soft wraps, hard rows, wide glyphs, combining clusters, and trailing
default blanks appear in the snapshot exactly as Stage 2 criterion 21 pins
them for selection copy.
15. isearch over the snapshot finds content that is **only in scrollback**
(scrolled off the visible screen), with no change to `src/search.rs` (B1).
16c. **Undo cannot empty the snapshot, by chord OR by command** (review
round 2). `Buffer::undo` bypasses the intercept chain entirely, so the
snapshot must be `read_only` at the rope. Pinning only the chords would
be a false pass: `M-x buffer.undo` and the menu reach the command with
no keymap involved, which is why `*compilation*`'s chord-rebinding idiom
does not close this. Pinned through **`invoke_interactive`**, the real
M-x path, plus the chord, plus redo — and paired with an assertion that
the owner's own refresh still works, since that is what plain
`read_only` would have broken.
16d. **A generated write reaches the window, not just the rope** (review
round 3). `set_generated_contents` returns one whole-buffer `Replace`
and its binding fans it out; swallowing it leaves a displaying
window's `TextView` line index describing the *previous* contents.
Pinned by **painting** — a shrinking write, so the stale offsets point
past the buffer end and the next render trips
`assertion failed: end <= self.len()` in `src/rope.rs`, which is the
reported crash rather than merely stale pixels. Driven through the Lua
binding copy mode itself calls, so it covers every future owner of the
primitive.
16e. **The same write is queued for replica mirrors** (review round 3,
CRDT half). The dropped fan-out also skipped
`queue_daemon_origin_crdt_op`, so a replica's mirror never imports the
owner's write and its optimistic edits are generated against content
already replaced. Pinned through the real copy-mode refresh on an
upgraded snapshot. `crdt`-gated, therefore dark in CI — 16d is the half
that actually runs there.
16. **Ungated, runs in CI:** focusing the snapshot buffer makes
`dispatch_idle_for` report **false**. This is the whole mechanism Q#TC6a
depends on, it needs no CRDT, and it fails the moment
`set_round_trip_input` is dropped — so the load-bearing regression is
caught by the default configuration rather than only by a `crdt`-gated
test that CI never compiles.
17. **Through a semantic frontend** (this one does need CRDT): keys typed in
the snapshot buffer reach ordinary dispatch and never the child, and
**neither the daemon buffer nor the frontend's mirror is mutated**
(Q#TC6a). Bite: with `set_round_trip_input` removed, the frontend
applies the edit **optimistically to its own mirror** and emits the op;
the mirror now shows text the user was told is read-only. The daemon
refuses the op at `ensure_writable()``set_generated_contents` leaves
`read_only` asserted — so the two copies **diverge**, and the local
mirror is the one the user is looking at.
**This bite changed in review round 3, and the direction matters.**
Rounds 1-2 specified it as "mutates *both sides*, silently, with no
divergence to notice" — true when nothing set `read_only` from Lua,
and false now. The eventual real-GPU test must assert **mirror
mutation plus daemon refusal**, not silent agreement; written the old
way it would look for a daemon-side edit that can no longer happen and
pass for the wrong reason. That the daemon now holds is exactly why
round-trip input is still load-bearing rather than redundant: a
refusal protects the daemon's copy and does nothing for the replica's.
**NOT PINNED as specified, deliberately, and this is the one gap in
Stage 2.** A faithful test has to drive the *real* `pmacs-gpu` binary:
the optimistic apply lives only in `pmacs-gpu/src/main.rs`
(`optimistic_crdt_insert` / `optimistic_insert_text`), and the headless
`SemanticClient` the other semantic tests use has no optimistic path at
all, so it cannot produce the op whose absence is the claim. That means
building on the `a37` foundation — which is `crdt`-gated so CI never
compiles it, **returns `ok` without running** when `pmacs-gpu` is absent
from the target directory, and is load-sensitive enough to pass and fail
at the same commit twenty minutes apart. A second test on that footing
would add the appearance of coverage without the substance.
What IS pinned instead, ungated and in CI: acceptance 16 asserts the
guard is armed (`dispatch_idle` false while the snapshot is focused, so
no replica can apply optimistically or emit), and acceptance 16b asserts
the buffer is `is_read_only()` **true** at the rope, so an op that did
arrive at the daemon would be refused by `ensure_writable()` rather
than applied. (Rounds 1-2 asserted **false** here, documenting the
hazard; round 2 closed it, and the assertion was flipped with it.
That does not make 17 redundant — a daemon-side refusal cannot stop a
replica mutating its own mirror, which is precisely what
`set_round_trip_input` is for.) Together those cover both halves of
Q#TC6a's *mechanism*. What remains unproven is only the end-to-end wire
behaviour of a real GPU frontend, and it stays an explicit obligation of
the CI `crdt`-coverage lane rather than being quietly dropped.
18. Re-invoking against the same terminal refreshes in place; the buffer count
does not grow (Q#TC8). Killing the snapshot leaves the terminal running;
killing the terminal removes the snapshot.
**The refresh half must be observed by CONTENT, not by buffer count**
(review round 1). Counting buffers, or comparing a quiet terminal's
snapshot against itself, passes with `render_snapshot` replaced by a
no-op. The child is `exec cat`, so the test types a marker into the
focused terminal, requires it **absent** from the existing snapshot, and
only then re-invokes — the "advance the world" discipline.
18a. **A foreign buffer carrying the snapshot's name is never adopted.**
`pmacs.buffer.create` accepts any caller-chosen name, and snapshot writes
use `bypass_intercept`, so found-by-name adoption silently overwrites a
user's data — reproduced in review round 1 as "do not clobber" becoming
23 newlines. Ownership means **"in copy mode's own handle table"**, which
is dired's F7 rule; a taken name yields a `<2>` variant.
18b. **Snapshot identity is the terminal BUFFER, not its name.**
`TerminalManager::open` uniquifies only the *derived* name — an explicit
`name = ...` is inserted verbatim — so two valid terminals can share one.
A name-keyed table hands them a single snapshot: the second invocation
retargets it, `q` returns to the wrong terminal, and killing either one
removes the shared buffer. Keyed instead by comparing buffer handles in
an array, because `BufferIdLua` implements `__eq` but each wrapper is a
distinct table key — comparison works, hashing does not.
19. `C-t` in a terminal buffer (physically `C-c C-t`) enters copy mode; `g`
refreshes the snapshot from the live terminal and `q` returns to the source
terminal (Q#TC8a).
20. The live terminal's own keys are unchanged while a snapshot exists
(Q#TC9), and the terminal keeps following its tail.
**Tail-following must be read through the registered VIEW.** Review
round 1: `TerminalManager::snapshot(buffer_id)` is context-free and
always returns the live screen, so it reports "at the tail" even for a
view forced to the oldest retained row — falsified by doing exactly
that and watching the assertion still pass. `snapshot_for_view`'s
`at_bottom` plus its projected cells are the only observables that can
tell the two apart.
21. The dispatch-shadow count is **unchanged at six** — pinned by asserting
`describe-key` reports the truth for the snapshot buffer's `g` and `q`,
which is the observable difference between the buffer-local idiom and a
shadow.
## Coherence impact (`COHERENCE.md` §20)
- **§6 Interaction islands — this arc deliberately adds none.** It is the
first modal-feeling terminal feature that resolves to the buffer-local
keymap idiom §6 identifies as correct, rather than a seventh rung on the
precedence ladder. The shadow count stays at six and `describe-key` stays
truthful (acceptance 21). Worth recording in §6 as a worked example that the
idiom scales to a case that looks modal.
- **§11 Configuration as typed, layered data** — the terminal gains its first
settings, and produces a second blocked adopter for **two** distinct registry
deferrals: the missing table-valued kind (profiles) and the missing
`scope = "global"` flag (the **two open-time settings**
`terminal.escape-key` deliberately supports buffer-locals, so only
`default-profile` and `scrollback-rows` want an enforcement the registry
cannot express). §11's ground truth should
record both, because the argument for prioritizing them is now cumulative
rather than hypothetical.
- **§2 golden journey, step 8 — partially closed here.** Stage 1 carries the
**terminal opening keybinding** that Priority 1 explicitly names (Q#TC10),
which is the larger half of "works but undiscoverable". Close/kill stays with
the panel work so the entry and exit points are designed together, and is
named in Deferred rather than silently skipped.
- **§5 Unify discovery** — the new commands must carry real descriptions so
M-x rows are useful; no new introspection surface is added.
- No background-work attribution change; no new activity view; no protocol
change.
## Verification plan
Full gate suite per `CLAUDE.md` for each PR separately, plus:
- **The touched terminal suites in BOTH configurations** — default and
`--features crdt` — not only the CRDT one. `vterm_stage1_acceptance`,
`vterm_stage2_acceptance`, and `vterm_stage3_acceptance` all carry tests in
each, and acceptance 12 is a claim about the default configuration too.
- `cargo test --test config_registry_acceptance` for the new settings.
- New suites: `tests/terminal_config_acceptance.rs` (Stage 1) and
`tests/terminal_copy_mode_acceptance.rs` (Stage 2).
- Every behavioral claim bite-verified. The bites that matter most:
**7/8/8a** — three pins that fail against three *different* wrong cache
implementations (epoch-only key, single last-entry, unpurged map), which is
why one pin was not enough; **9** (a hardcoded `0x03` makes the configured
chord unreachable); **10** (its failure mode is a terminal nobody can
escape); and **16** (a read-only buffer whose replica mirror accepts an
edit the user is then looking at — 17's daemon half was closed in review
round 2, and its bite restated in round 3).
- **The observation seams the cache pins need are `escape_parses` (how often)
and `escape_caches` (how many are still held).** Neither is inferable from
behavior: for a *valid* setting a correct per-session cache and a leaking
editor-side map produce identical keystroke results, and both leave the
session count draining normally. Review round 1 caught 8a asserting the
session count instead — which the unpurged-map bite passes, since a map with
no purge hook leaks *while* sessions drain. A lifecycle claim needs a
lifecycle observable; the count of live sessions is not one.
- **Criterion 5 must open a real terminal and read back retained history.**
Round 1 caught it asserting a registry round-trip instead, which is a test of
the registry: it stays green with the setting's only consumer deleted. The
same shape to watch for anywhere — *asserting that a value was stored is not
asserting that anything reads it*.
- **Do not gate the new suites on `#[cfg(feature = "crdt")]` unless a test
genuinely needs CRDT.** CI never enables that feature, so a suite gated that
way is written and then never run — 264 tests are currently dark for exactly
this reason. That measurement and its lane live on **PR #168**, which is open
and unmerged; it is not yet in `docs/active-work.md` on `main`.
Acceptance 17 does need a semantic frontend, so that one test is gated — but
acceptance 16 pins the same mechanism ungated, so the regression is caught in
CI regardless. That pairing is the pattern to reuse whenever a claim's
end-to-end proof needs CRDT.

View File

@ -1674,6 +1674,64 @@ GPU assertions remain in `pmacs-protocol` and `pmacs-gpu` respectively.
- **37:** one real-daemon/real-PTY/headless-wgpu acceptance path; it is not
replaced by a decoded-message fixture.
### 0.12 As-framed audit, 2026-07-25 (after #166)
Prompted by a GPU terminal input defect that shipped in Stage 3 and was fixed
in #166. The arc is structurally complete — all 37 criteria have
implementations, and every test named in the Stage 2 verification map exists —
but the audit found two gaps worth recording against the criteria themselves.
**Criterion 22's "without thrash" was never pinned.** The criterion reads
"unchanged, zero, passive, and failed resize cases preserve prior geometry
*without thrash*". The word appears nowhere in `src/` or `tests/`. The suite
pinned the four enumerated single-arm cases and never the cross-arm
interaction — which is exactly where the thrash lived: the daemon applied
both the grid and the semantic terminal-layout sync to every attached
frontend, so a semantic session's PTY was resized twice per tick forever.
Criterion 31's "only the exact durable controller changes PTY geometry" was
violated in the same event, in spirit rather than letter: the controller was
the right frontend, but the geometry came from the grid projection. #166 adds
the settle pins; the gap was open from #135 (2026-07-22) until then.
**Why the Stage 3 suite could not see it.** Of its nine tests, only three
drive a real daemon; the other six construct `EditorState` directly and never
execute the dispatcher loop where the defect lived. `a31`, which is about two
semantic frontends sharing one session, therefore passes on the broken tree.
The same structural blindness explains why `bottom_panel_stage1_acceptance`
was unaffected. A criterion about *dispatcher* behavior needs a test that
runs the dispatcher.
**Four of the nine Stage 3 tests do not run in CI at all**, because they are
`#[cfg(feature = "crdt")]` and the workflow never enables that feature:
`a37`, the two added by #166, and
`terminal_mode_keeps_reporting_presence_so_peers_drop_the_stale_caret` — which
is Stage 3 review round 1's own regression guard. Stage 1's
`read_only_empty_crdt_bootstrap_is_immutable_against_remote_content`, the CRDT
half of criterion 14, is dark for the same reason. Stage 2 is fully covered
(6/6). This is not a vterm problem: 264 tests workspace-wide are dark,
including 177 in the library. It has its own lane in `docs/active-work.md`.
**And `a37` is darker still than that count implies: it reports `ok` without
running whenever `pmacs-gpu` is absent from the same target directory**
(measured 2026-07-26 while gating #173). It derives the sibling binary from
`CARGO_BIN_EXE_pmacs` and, finding nothing, prints a skip notice and returns.
A fresh worktree reports the suite 9/9 in 0.17 s having executed the arc's
only real-daemon/real-PTY/real-wgpu path zero times; a genuine run takes
about four seconds. `PMACS_REQUIRE_GPU=1` is the only thing that turns that
skip into a failure, and the standing gate list applies that flag to
`cargo test -p pmacs-gpu`, a different package. So the audit's claim that
"only 3 of 9 Stage 3 tests drive a real daemon" was itself optimistic —
**on a target directory without the frontend binary the honest number is 2**,
and nothing in the gate log says so. It is also load-sensitive: it passed and
then failed at the same commit twenty minutes apart under machine
contention. Criterion 22's unpinned "without thrash" and this are the arc's
two standing verification gaps.
**Not audited:** §11's blanket claim that "deferral means graceful ignore or
documented absence, never escape leakage, panic, unbounded allocation, or
child leak". That covers roughly twenty deferred items and none were
spot-checked. It remains an unproven claim rather than a known gap.
## 10. Gates and bite verification
Every PR runs the standing full gates from `AGENTS.md`, sequentially, plus its

View File

@ -779,11 +779,20 @@ fn run_headless_probe(socket: &Path, report: &Path) -> i32 {
.ok()
.and_then(|value| value.parse::<u64>().ok())
.map(std::time::Duration::from_millis);
// Normal probes stop only after their fixture-specific evidence arrives.
// A producer fixture names the text it must paint; an input fixture uses
// the latched echo observation. Keeping that choice outside this generic
// runner prevents one fixture's breadcrumb from forcing another fixture
// to sit on the 20-second safety deadline.
let expected_frame_text = std::env::var("PMACS_GPU_PROBE_EXPECT_TEXT")
.ok()
.filter(|value| !value.is_empty());
let quiet = observe_window.is_some();
let deadline = std::time::Instant::now()
+ observe_window.unwrap_or_else(|| std::time::Duration::from_secs(20));
let mut sent_input = false;
let mut sent_resize = false;
let mut completion_observed = false;
while std::time::Instant::now() < deadline {
let Ok(event) = rx.recv_timeout(std::time::Duration::from_millis(200)) else {
continue;
@ -857,7 +866,20 @@ fn run_headless_probe(socket: &Path, report: &Path) -> i32 {
facts.observed_resized_frame = true;
}
}
if !quiet && facts.observed_resized_frame && facts.rendered_nonuniform_frames >= 2 {
let fixture_evidence_observed = expected_frame_text.as_deref().map_or_else(
|| facts.input_echo_observed,
|expected| facts.last_frame_text.contains(expected),
);
// Do not exit merely because resize/composition happened
// first: that races the fixture's required PTY evidence and
// produces a self-contradictory "successful" probe report
// whose later acceptance assertion must reject it.
if !quiet
&& facts.observed_resized_frame
&& facts.rendered_nonuniform_frames >= 2
&& fixture_evidence_observed
{
completion_observed = true;
break;
}
}
@ -890,6 +912,7 @@ fn run_headless_probe(socket: &Path, report: &Path) -> i32 {
let _ = writeln!(out, "last_title={}", facts.last_title.unwrap_or_default());
let _ = writeln!(out, "last_frame_text={}", facts.last_frame_text);
let _ = writeln!(out, "input_echo_observed={}", facts.input_echo_observed);
let _ = writeln!(out, "completion_observed={completion_observed}");
let _ = writeln!(out, "disconnect={}", facts.disconnect.unwrap_or_default());
if let Err(error) = std::fs::write(report, out) {
eprintln!(
@ -936,10 +959,20 @@ fn run_headless_managed_probe(
}
};
let mut client = managed.client;
let initial_message = client.take_initial_message();
let initial_target_ready = matches!(
client.take_initial_message(),
initial_message.as_ref(),
Some(InstanceMessage::BufferSnapshot { .. })
);
let mut buffer_facts = ManagedProbeBufferFacts::default();
if let Some(message) = initial_message.as_ref()
&& let Err(error) = buffer_facts.observe(message)
{
let contents = format!("phase=error\nerror={error}\n");
let _ = write_probe_report(report, &contents);
eprintln!("pmacs-gpu managed probe: {error}");
return 7;
}
let daemon = managed.daemon;
let protocol = client.server_protocol_version();
@ -961,8 +994,14 @@ fn run_headless_managed_probe(
let mut last_wait_result = None;
let mut last_disconnect = String::new();
if ready
&& let Err(error) =
write_managed_probe_report(report, "ready", protocol, &daemon, &disconnect)
&& let Err(error) = write_managed_probe_report(
report,
"ready",
protocol,
&daemon,
&buffer_facts,
&disconnect,
)
{
eprintln!(
"pmacs-gpu managed probe: writing {} failed: {error}",
@ -976,11 +1015,23 @@ fn run_headless_managed_probe(
}
match event_rx.recv_timeout(Duration::from_millis(50)) {
Ok(AttachEvent::Message(message)) => {
if matches!(*message, InstanceMessage::BufferSnapshot { .. }) && !ready {
let is_snapshot = matches!(*message, InstanceMessage::BufferSnapshot { .. });
if let Err(error) = buffer_facts.observe(&message) {
let contents = format!("phase=error\nerror={error}\n");
let _ = write_probe_report(report, &contents);
eprintln!("pmacs-gpu managed probe: {error}");
return 7;
}
if is_snapshot {
ready = true;
if let Err(error) =
write_managed_probe_report(report, "ready", protocol, &daemon, &disconnect)
{
if let Err(error) = write_managed_probe_report(
report,
"ready",
protocol,
&daemon,
&buffer_facts,
&disconnect,
) {
eprintln!(
"pmacs-gpu managed probe: writing {} failed: {error}",
report.display()
@ -1006,9 +1057,14 @@ fn run_headless_managed_probe(
|| wait_result != last_wait_result
|| disconnect != last_disconnect)
{
if let Err(error) =
write_managed_probe_report(report, "ready", protocol, &daemon, &disconnect)
{
if let Err(error) = write_managed_probe_report(
report,
"ready",
protocol,
&daemon,
&buffer_facts,
&disconnect,
) {
eprintln!(
"pmacs-gpu managed probe: writing {} failed: {error}",
report.display()
@ -1021,9 +1077,14 @@ fn run_headless_managed_probe(
}
if ready && stdin_closed {
if let Err(error) =
write_managed_probe_report(report, "complete", protocol, &daemon, &disconnect)
{
if let Err(error) = write_managed_probe_report(
report,
"complete",
protocol,
&daemon,
&buffer_facts,
&disconnect,
) {
eprintln!(
"pmacs-gpu managed probe: writing {} failed: {error}",
report.display()
@ -1043,11 +1104,42 @@ fn run_headless_managed_probe(
}
}
#[derive(Default)]
struct ManagedProbeBufferFacts {
snapshots: u32,
last_snapshot_text: String,
}
impl ManagedProbeBufferFacts {
fn observe(&mut self, message: &InstanceMessage) -> Result<(), String> {
let InstanceMessage::BufferSnapshot { crdt_snapshot, .. } = message else {
return Ok(());
};
let doc = loro::LoroDoc::new();
doc.import(crdt_snapshot)
.map_err(|error| format!("BufferSnapshot import failed: {error:?}"))?;
self.snapshots += 1;
self.last_snapshot_text = doc.get_text(LORO_TEXT_CONTAINER).to_string();
Ok(())
}
}
fn hex_bytes(bytes: &[u8]) -> String {
use std::fmt::Write as _;
let mut encoded = String::with_capacity(bytes.len() * 2);
for byte in bytes {
let _ = write!(encoded, "{byte:02x}");
}
encoded
}
fn write_managed_probe_report(
report: &Path,
phase: &str,
protocol: u32,
daemon: &attach::ManagedDaemonFacts,
buffer_facts: &ManagedProbeBufferFacts,
disconnect: &str,
) -> std::io::Result<()> {
use std::fmt::Write as _;
@ -1056,6 +1148,12 @@ fn write_managed_probe_report(
let _ = writeln!(out, "phase={phase}");
let _ = writeln!(out, "server_protocol_version={protocol}");
let _ = writeln!(out, "buffer_snapshot=true");
let _ = writeln!(out, "buffer_snapshots={}", buffer_facts.snapshots);
let _ = writeln!(
out,
"last_snapshot_hex={}",
hex_bytes(buffer_facts.last_snapshot_text.as_bytes())
);
let _ = writeln!(out, "spawned_daemon={}", daemon.spawned_daemon());
let _ = writeln!(
out,
@ -8904,6 +9002,7 @@ fn instance_message_label(msg: &InstanceMessage) -> &'static str {
InstanceMessage::StatuslineSegments { .. } => "StatuslineSegments",
InstanceMessage::TerminalFrame(_) => "TerminalFrame",
InstanceMessage::InitialTargetResult(_) => "InitialTargetResult",
InstanceMessage::PanelFrame(_) => "PanelFrame",
}
}

View File

@ -40,8 +40,10 @@ pub mod cell;
pub mod crdt;
pub mod ids;
pub mod message;
pub mod panel;
pub mod terminal;
pub mod transport;
pub mod wire_grid;
/// Logical display columns between fixed buffer-text tab stops.
///
@ -55,21 +57,27 @@ pub use cell::{
pub use crdt::CrdtOp;
pub use ids::{BufferId, ByteRange, FrontendId, Position};
pub use message::{
AdornmentContent, AdornmentPlacement, AttachRequest, BUILTIN_PAIR_CHARS, BlockAdornment,
CompletionPopupRow, CursorState, Decoration, DecorationKind, DecorationSegment,
FrontendCapabilities, FrontendEvent, GoodbyeReason, Hello, InitialTarget, InitialTargetResult,
InlineAdornment, InstanceCapabilities, InstanceIdentity, InstanceMessage, InstanceSignal, Key,
KeyEvent, LineNumberMode, MAX_INITIAL_TARGET_ERROR_BYTES, MAX_INITIAL_TARGET_PATH_BYTES,
MAX_STATUSLINE_FACE_BYTES, MAX_STATUSLINE_PROVIDER_NAME_BYTES, MAX_STATUSLINE_PROVIDERS,
MAX_STATUSLINE_SEGMENT_BYTES, MAX_STATUSLINE_TOTAL_TEXT_BYTES, MenuPromptRow, Modifiers,
MouseButton, MouseEvent, MouseKind, NegotiatedCapabilities, PROTOCOL_VERSION, PointerKind,
ResourceBody, SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot, SessionBootstrapRequest,
StatuslineSegment, StyleSegment, StyleSpan, ThemeFace, is_builtin_pair_char,
is_modeline_face_name, is_supported_protocol_version, is_ui_face_name, negotiate_capabilities,
ADVERTISED_PROTOCOL_VERSION, AdornmentContent, AdornmentPlacement, AttachRequest,
BUILTIN_PAIR_CHARS, BlockAdornment, CompletionPopupRow, CursorState, Decoration,
DecorationKind, DecorationSegment, FrontendCapabilities, FrontendEvent, GoodbyeReason, Hello,
InitialTarget, InitialTargetResult, InlineAdornment, InstanceCapabilities, InstanceIdentity,
InstanceMessage, InstanceSignal, Key, KeyEvent, LineNumberMode, MAX_INITIAL_TARGET_ERROR_BYTES,
MAX_INITIAL_TARGET_PATH_BYTES, MAX_STATUSLINE_FACE_BYTES, MAX_STATUSLINE_PROVIDER_NAME_BYTES,
MAX_STATUSLINE_PROVIDERS, MAX_STATUSLINE_SEGMENT_BYTES, MAX_STATUSLINE_TOTAL_TEXT_BYTES,
MenuPromptRow, Modifiers, MouseButton, MouseEvent, MouseKind, NegotiatedCapabilities,
PROTOCOL_VERSION, PointerKind, ResourceBody, SUPPORTED_PROTOCOL_VERSIONS, SelectionSnapshot,
SessionBootstrapRequest, StatuslineSegment, StyleSegment, StyleSpan, ThemeFace,
is_builtin_pair_char, is_modeline_face_name, is_supported_protocol_version, is_ui_face_name,
negotiate_capabilities,
};
pub use panel::{MAX_PANEL_VISIBLE_CELLS, PanelFrame, PanelFrameError, PanelFramePayload};
pub use terminal::{
MAX_TERMINAL_COLS, MAX_TERMINAL_FRAME_GLYPH_BYTES, MAX_TERMINAL_GRAPHEME_BYTES,
MAX_TERMINAL_METADATA_BYTES, MAX_TERMINAL_ROWS, MAX_TERMINAL_VISIBLE_CELLS, TerminalFrame,
TerminalFrameError, TerminalProcessState, TerminalSelectionSpan,
};
pub use transport::{MAX_FRAME_BYTES, TransportError, read_message, write_message};
pub use wire_grid::{
MAX_WIRE_GRID_GLYPH_BYTES, MAX_WIRE_GRID_GRAPHEME_BYTES, WireGridError, WireGridLimits,
checked_area, validate_wire_grid,
};

View File

@ -442,6 +442,77 @@ pub enum FrontendEvent {
/// Modifiers held during the gesture.
mods: Modifiers,
},
/// Bottom panel Stage 2 (protocol v21): the frontend's authoritative
/// cell-equivalent layout capacity (Q#BP15a).
///
/// Valid **without** a side window — the daemon needs columns before
/// it can paint a first panel frame, so gating this on panel
/// presence would deadlock the first open. "Without" refers to
/// side-window presence only; the protocol and session gates still
/// apply, and the event is accepted only from an authenticated,
/// negotiated panel-capable semantic session.
///
/// Sent immediately after attach acceptance and refreshed on window
/// resize, font change, and scale change. `geometry_epoch` is
/// frontend-owned because a font or scale change can invalidate an
/// old panel frame while `total` is **identical**, which daemon-side
/// value dedup cannot detect.
FrontendCellGeometry {
/// Which frontend declared this (untrusted; checked against the
/// transport source).
frontend_id: FrontendId,
/// Monotonic frontend-owned declaration id; `0` is reserved for
/// "never declared" and is rejected on the wire.
geometry_epoch: u64,
/// Whole-cell capacity of the frontend's frame.
total: CellSize,
},
/// Bottom panel Stage 2 (protocol v21): requested fixed panel rows
/// from a divider drag (Q#BP15a).
///
/// Rows are the only size component; the epochs are identities, not
/// geometry. Accepted only for the currently visible `Present` panel
/// matching both the latest geometry declaration and the current
/// presentation epoch, then clamped by Q#BP2's interactive
/// preference.
PanelResizeRows {
/// Which frontend produced the drag (untrusted, as above).
frontend_id: FrontendId,
/// Geometry declaration this request is measured against.
geometry_epoch: u64,
/// Presentation identity this request addresses.
panel_epoch: u64,
/// Requested fixed panel rows.
rows: u32,
},
/// Bottom panel Stage 2 (protocol v21): a pointer gesture a semantic
/// frontend hit-tested to a panel CELL (Q#BP16).
///
/// Carries both epochs so a gesture aimed at a panel that has since
/// been replaced or reopened cannot be applied to its successor.
/// Unlike [`Self::Pointer`], accepting this **activates the panel**.
///
/// `buffer_id` and `panel_epoch` close different holes and neither
/// subsumes the other: `buffer_id` catches an A→B buffer
/// replacement, while `panel_epoch` catches close/hide/reopen of the
/// **same** persistent buffer — which a buffer id alone cannot
/// distinguish — without putting a `WindowId` on the wire.
PanelPointer {
/// Which frontend produced the gesture (untrusted, as above).
frontend_id: FrontendId,
/// Geometry declaration this gesture was hit-tested against.
geometry_epoch: u64,
/// Presentation identity this gesture addresses.
panel_epoch: u64,
/// Buffer the frontend believed the panel was displaying.
buffer_id: crate::BufferId,
/// Cell the pointer is over, within the declared panel grid.
coord: CellCoord,
/// Which gesture step this is.
kind: MouseKind,
/// Modifiers held during the gesture.
mods: Modifiers,
},
}
/// Gesture step for [`FrontendEvent::Pointer`]. Double-click
@ -488,7 +559,10 @@ impl FrontendEvent {
| Self::Pointer { frontend_id, .. }
| Self::MenuPointer { frontend_id, .. }
| Self::TerminalResize { frontend_id, .. }
| Self::TerminalPointer { frontend_id, .. } => *frontend_id,
| Self::TerminalPointer { frontend_id, .. }
| Self::FrontendCellGeometry { frontend_id, .. }
| Self::PanelResizeRows { frontend_id, .. }
| Self::PanelPointer { frontend_id, .. } => *frontend_id,
}
}
}
@ -1143,6 +1217,20 @@ pub enum InstanceMessage {
/// Appended after [`Self::TerminalFrame`], the final v19 variant, so no
/// legacy postcard discriminant moves.
InitialTargetResult(InitialTargetResult),
/// Bottom panel Stage 2 (protocol v21): the daemon's painted
/// projection of one side window, or its authoritative absence
/// (Q#BP15).
///
/// `Absent` is sent on close **and** on hide: the receiver retains
/// its last valid frame, so silence would leave a stale band on
/// screen indefinitely. `Absent` is duplicate-suppressed like any
/// payload, and applying it clears the last declared panel size and
/// presentation epoch before any later event can validate against
/// them.
///
/// Appended after [`Self::InitialTargetResult`], the final v20
/// variant, so no existing postcard discriminant moves.
PanelFrame(crate::panel::PanelFramePayload),
}
/// One resolved UI face for [`InstanceMessage::ThemeFacts`]: a full
@ -1565,7 +1653,27 @@ pub enum ResourceBody {
/// handshake extension is read only from v20 semantic sessions; the result is
/// sent only when such a session requested a target. v6v19 handshakes and
/// message discriminants remain unchanged.
pub const PROTOCOL_VERSION: u32 = 20;
///
/// Bottom panel Stage 2 (Q#BP9): bumped 20 → 21 for
/// [`InstanceMessage::PanelFrame`] and
/// [`FrontendEvent::{FrontendCellGeometry, PanelResizeRows, PanelPointer}`].
/// All four are appended after their enum's previous final variant, so
/// no v6v20 discriminant moves and the encoding of every existing
/// message is byte-identical. The new traffic is gated in both
/// directions: a v20 peer neither receives `PanelFrame` nor is placed in
/// a side window, because denying only the events would leave its
/// window invisible.
pub const PROTOCOL_VERSION: u32 = 21;
/// Protocol version placed in the daemon's server-first [`Hello`].
///
/// Bottom-panel Stage 2B-1 reserves the additive v21 wire family, but
/// production attachment remains on v20 until the Stage 2B-3 capability
/// activation can preserve compatibility with existing v20 frontends.
/// Those frontends reject an unknown server-first version before they can
/// send [`AttachRequest`], so advertising [`PROTOCOL_VERSION`] here would
/// make the otherwise-dark protocol slice user-visible.
pub const ADVERTISED_PROTOCOL_VERSION: u32 = 20;
/// T M10.5: the set of protocol versions a v1.0 binary accepts on
/// the wire. v0.1 binaries only accepted `[1]`; v1.0 binaries accept
@ -1643,8 +1751,15 @@ pub const PROTOCOL_VERSION: u32 = 20;
/// GPU initial target (Q#GT4): extended to `[6, ..., 20]`. v20 semantic
/// sessions send a bounded bootstrap envelope after `AttachRequest`; legacy
/// and non-semantic sessions retain their existing handshake shape.
///
/// Bottom panel Stage 2 (Q#BP9): extended to `[6, ..., 21]`. Stage 2B-1
/// reserves and validates the v21 wire while production daemons continue
/// to send [`ADVERTISED_PROTOCOL_VERSION`] in their server-first
/// [`Hello`]. The later capability-activation slice owns moving production
/// negotiation to v21 without making existing v20 frontends reject the
/// handshake.
pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] =
&[6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20];
&[6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21];
/// T M10.5: predicate for the handshake check. Returns `true` if
/// `peer_version` is in [`SUPPORTED_PROTOCOL_VERSIONS`].
@ -2024,7 +2139,10 @@ pub fn negotiate_capabilities(
/// frontend will use as the `FrontendId` on every event it sends.
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Hello {
/// The instance's `PROTOCOL_VERSION`.
/// The protocol version this attachment should use.
///
/// This can deliberately trail [`PROTOCOL_VERSION`] while an additive
/// wire family is reserved but not yet activated in production.
pub protocol_version: u32,
/// `FrontendId` assigned to this attachment by the instance. The
/// frontend stamps this onto subsequent events. v0.1 daemons start

213
pmacs-protocol/src/panel.rs Normal file
View File

@ -0,0 +1,213 @@
//! Bottom-panel wire types (Q#BP15, Q#BP15a, Q#BP16).
//!
//! A panel frame is the daemon's painted projection of one side window.
//! It shares [`crate::wire_grid`]'s cell rules with
//! [`crate::terminal::TerminalFrame`] but not its per-axis PTY caps: a
//! 4K surface at a small font is legitimately wider than 512 columns,
//! and the area bound is what keeps the encoding inside the transport
//! budget.
//!
//! Presence is explicit. [`PanelFramePayload::Absent`] is authoritative
//! and must be sent on close *and* on hide, because the receiver
//! retains its last valid frame: silence would leave a stale band on
//! screen indefinitely.
use crate::cell::{Cell, CellCoord, CellSize};
use crate::ids::BufferId;
use crate::wire_grid::{
MAX_WIRE_GRID_GLYPH_BYTES, WireGridError, WireGridLimits, validate_wire_grid,
};
/// Shared visible-cell ceiling for a panel grid.
///
/// Identical to the terminal bound: it is the transport-safety limit,
/// not a PTY policy, so both messages answer to it.
pub const MAX_PANEL_VISIBLE_CELLS: usize = crate::wire_grid::MAX_WIRE_GRID_VISIBLE_CELLS;
/// Bounds a panel frame enforces on its cell grid.
///
/// The per-axis ceilings are the area bound itself rather than 512: any
/// axis larger than the area bound is already rejected by the area
/// check, so this expresses "no independent per-axis policy" without
/// leaving the multiplication unchecked.
const PANEL_GRID_LIMITS: WireGridLimits = WireGridLimits {
max_rows: MAX_PANEL_VISIBLE_CELLS as u32,
max_cols: MAX_PANEL_VISIBLE_CELLS as u32,
max_visible_cells: MAX_PANEL_VISIBLE_CELLS,
max_glyph_bytes: MAX_WIRE_GRID_GLYPH_BYTES,
};
/// The daemon's painted projection of one side window.
///
/// `panel_epoch` is opaque and monotonic per frontend: stable across
/// ordinary frames of one continuously present window/buffer, and
/// changed on buffer replacement, new side-window creation, and every
/// `Absent` → `Present` transition. That is what stops a stale
/// `PanelPointer` from addressing a reopened panel as if it were the
/// old one (Q#BP16).
///
/// `geometry_epoch` answers a *frontend* declaration and moves whenever
/// the frontend declares new effective cell geometry — including a font
/// or scale change that leaves [`CellSize`] identical, which is exactly
/// the case daemon-side value dedup cannot see (Q#BP2S1).
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct PanelFrame {
/// Buffer this frame projects.
pub buffer_id: BufferId,
/// Presentation identity, monotonic per frontend.
pub panel_epoch: u64,
/// The frontend geometry declaration this frame answers.
pub geometry_epoch: u64,
/// Panel grid dimensions in cells.
pub size: CellSize,
/// Row-major cells; exactly `size.area()` entries.
pub cells: Vec<Cell>,
/// Panel caret, or `None` when the panel shows no cursor.
///
/// `paint_frame` returns the cursor separately from the cells, so a
/// frame carrying cells alone would lose the caret.
pub cursor: Option<CellCoord>,
/// Whether the panel owns focus.
///
/// Presentation and focus-chrome routing only (Q#BP14b) — the
/// *keys* decision is `DispatchIdle` (Q#BP14a).
pub focused: bool,
}
/// Explicit panel presence.
///
/// `Absent` is authoritative rather than implied by silence, and is
/// duplicate-suppressed like any other payload.
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum PanelFramePayload {
/// A panel is visible and this is its current frame.
Present(PanelFrame),
/// No panel is visible; clear any retained frame.
Absent,
}
/// Why a [`PanelFrame`] is not structurally valid.
///
/// Validation is atomic: the frame is rejected whole and the receiver
/// retains its previous valid frame.
#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
pub enum PanelFrameError {
/// Rows or columns are zero or above the area-derived bounds.
#[error("panel size {rows}x{cols} is outside 1..={max_rows}x1..={max_cols}")]
Size {
/// Declared rows.
rows: u32,
/// Declared columns.
cols: u32,
/// Row bound in force.
max_rows: u32,
/// Column bound in force.
max_cols: u32,
},
/// The checked area exceeds the shared visible-cell bound.
#[error("panel area {area} exceeds the visible-cell bound {max}")]
Area {
/// Checked `rows * cols`.
area: usize,
/// Shared visible-cell bound.
max: usize,
},
/// `cells.len()` disagrees with the declared area.
#[error("panel frame carries {actual} cells for a {expected}-cell area")]
CellCount {
/// Declared area.
expected: usize,
/// Supplied cell count.
actual: usize,
},
/// The cursor lies outside the declared grid.
#[error("panel cursor ({row},{col}) is outside the {rows}x{cols} grid")]
Cursor {
/// Cursor row.
row: u32,
/// Cursor column.
col: u32,
/// Declared rows.
rows: u32,
/// Declared columns.
cols: u32,
},
/// A cell's glyph is not a legal wire glyph.
#[error("panel cell {index} has an invalid glyph: {reason}")]
Glyph {
/// Row-major cell index.
index: usize,
/// Why the glyph failed.
reason: &'static str,
},
/// A cell carries a frontend attachment, which panels never use.
#[error("panel cell {index} carries an attachment")]
Attachment {
/// Row-major cell index.
index: usize,
},
/// Aggregate glyph bytes exceed the shared budget.
#[error("panel frame glyph bytes exceed the aggregate bound {max}")]
GlyphBudget {
/// Shared aggregate bound.
max: usize,
},
/// An epoch is zero, which is reserved for "never declared".
#[error("panel {field} epoch is zero, which is reserved for 'never declared'")]
ZeroEpoch {
/// Which epoch was zero.
field: &'static str,
},
}
impl PanelFrame {
/// Check every structural rule a panel frame must satisfy.
///
/// Pure: a rejected frame mutates nothing, so callers get atomic
/// rejection for free.
pub fn validate(&self) -> Result<(), PanelFrameError> {
if self.panel_epoch == 0 {
return Err(PanelFrameError::ZeroEpoch { field: "panel" });
}
if self.geometry_epoch == 0 {
return Err(PanelFrameError::ZeroEpoch { field: "geometry" });
}
validate_wire_grid(self.size, &self.cells, self.cursor, PANEL_GRID_LIMITS)
.map_err(panel_grid_error)
}
}
/// Map a shared wire-grid failure onto this message's error type.
fn panel_grid_error(error: WireGridError) -> PanelFrameError {
match error {
WireGridError::Size {
rows,
cols,
max_rows,
max_cols,
} => PanelFrameError::Size {
rows,
cols,
max_rows,
max_cols,
},
WireGridError::Area { area, max } => PanelFrameError::Area { area, max },
WireGridError::CellCount { expected, actual } => {
PanelFrameError::CellCount { expected, actual }
}
WireGridError::Cursor {
row,
col,
rows,
cols,
} => PanelFrameError::Cursor {
row,
col,
rows,
cols,
},
WireGridError::Glyph { index, reason } => PanelFrameError::Glyph { index, reason },
WireGridError::Attachment { index } => PanelFrameError::Attachment { index },
WireGridError::GlyphBudget { max } => PanelFrameError::GlyphBudget { max },
}
}

View File

@ -12,13 +12,18 @@
//! that single structural policy. A second implementation of these rules
//! in a frontend is a bug, not a convenience.
//!
//! This module owns the crate's only `unicode-width` use: glyph column
//! width and wide-continuation topology cannot be checked without it.
//! Glyph column width and wide-continuation topology moved to
//! [`crate::wire_grid`] in bottom-panel Stage 2B, which is now the
//! crate's only non-test `unicode-width` use: those rules are shared
//! with [`crate::panel::PanelFrame`]. The 512 per-axis PTY caps,
//! metadata, selection spans, and the `at_bottom`/`scroll_offset`
//! coupling stay here, because a panel does not inherit them.
use crate::cell::{Cell, CellCoord, CellSize, Glyph};
use crate::cell::{Cell, CellCoord, CellSize};
use crate::ids::BufferId;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
#[cfg(test)]
use unicode_width::UnicodeWidthStr;
// ---------------------------------------------------------------------------
// Shared limits
@ -32,10 +37,20 @@ pub const MAX_TERMINAL_COLS: u16 = 512;
/// Maximum visible terminal cells accepted at creation, resize, or on
/// the wire.
pub const MAX_TERMINAL_VISIBLE_CELLS: usize = 262_144;
///
/// An alias of the shared wire-grid bound: this is transport safety, not
/// a PTY policy, so it must not drift from the panel's.
pub const MAX_TERMINAL_VISIBLE_CELLS: usize = crate::wire_grid::MAX_WIRE_GRID_VISIBLE_CELLS;
/// Maximum UTF-8 bytes retained in one terminal grapheme cluster.
pub const MAX_TERMINAL_GRAPHEME_BYTES: usize = 256;
///
/// An **alias** of the shared wire-grid bound, not an independent value.
/// The terminal screen truncates clusters to this constant while
/// [`crate::wire_grid`] validates against its own; if the two were
/// separate literals, raising one would make the producer emit clusters
/// its own validator rejects — or, worse, accept clusters no frontend
/// budgeted for. Keeping this a re-export means they cannot drift.
pub const MAX_TERMINAL_GRAPHEME_BYTES: usize = crate::wire_grid::MAX_WIRE_GRID_GRAPHEME_BYTES;
/// Shared cap for terminal title and process-outcome metadata.
pub const MAX_TERMINAL_METADATA_BYTES: usize = 1_024;
@ -51,7 +66,7 @@ pub const MAX_TERMINAL_METADATA_BYTES: usize = 1_024;
/// protocol test `maximum_legal_terminal_frame_encodes_below_the_transport_cap`
/// measures the largest legal frame this bound admits and pins it below
/// the unchanged 16 MiB cap.
pub const MAX_TERMINAL_FRAME_GLYPH_BYTES: usize = 8 * 1024 * 1024;
pub const MAX_TERMINAL_FRAME_GLYPH_BYTES: usize = crate::wire_grid::MAX_WIRE_GRID_GLYPH_BYTES;
// ---------------------------------------------------------------------------
// Payload types
@ -217,6 +232,58 @@ pub enum TerminalFrameError {
},
}
/// Bounds a terminal frame enforces on its cell grid.
///
/// The per-axis caps are the PTY-specific half of the split: a panel
/// frame shares every other rule but not these, because a panel is
/// sized by the frontend's surface rather than by a pty window size.
const TERMINAL_GRID_LIMITS: crate::wire_grid::WireGridLimits = crate::wire_grid::WireGridLimits {
max_rows: MAX_TERMINAL_ROWS as u32,
max_cols: MAX_TERMINAL_COLS as u32,
max_visible_cells: MAX_TERMINAL_VISIBLE_CELLS,
max_glyph_bytes: MAX_TERMINAL_FRAME_GLYPH_BYTES,
};
/// Map a shared wire-grid failure onto this message's error type.
///
/// The variants and their text are unchanged by the Stage 2B factoring:
/// every existing terminal-frame assertion still observes exactly what
/// it observed before.
fn terminal_grid_error(error: crate::wire_grid::WireGridError) -> TerminalFrameError {
use crate::wire_grid::WireGridError;
match error {
WireGridError::Size {
rows,
cols,
max_rows,
max_cols,
} => TerminalFrameError::Size {
rows,
cols,
max_rows,
max_cols,
},
WireGridError::Area { area, max } => TerminalFrameError::Area { area, max },
WireGridError::CellCount { expected, actual } => {
TerminalFrameError::CellCount { expected, actual }
}
WireGridError::Cursor {
row,
col,
rows,
cols,
} => TerminalFrameError::Cursor {
row,
col,
rows,
cols,
},
WireGridError::Glyph { index, reason } => TerminalFrameError::Glyph { index, reason },
WireGridError::Attachment { index } => TerminalFrameError::Attachment { index },
WireGridError::GlyphBudget { max } => TerminalFrameError::GlyphBudget { max },
}
}
impl TerminalFrame {
/// Check every structural rule a terminal frame must satisfy.
///
@ -224,23 +291,13 @@ impl TerminalFrame {
/// by a frontend after decode. It is pure: a rejected frame mutates
/// nothing, so callers get atomic rejection for free.
pub fn validate(&self) -> Result<(), TerminalFrameError> {
let area = self.checked_area()?;
if self.cells.len() != area {
return Err(TerminalFrameError::CellCount {
expected: area,
actual: self.cells.len(),
});
}
if let Some(cursor) = self.cursor
&& (cursor.row >= self.size.rows || cursor.col >= self.size.cols)
{
return Err(TerminalFrameError::Cursor {
row: cursor.row,
col: cursor.col,
rows: self.size.rows,
cols: self.size.cols,
});
}
crate::wire_grid::validate_wire_grid(
self.size,
&self.cells,
self.cursor,
TERMINAL_GRID_LIMITS,
)
.map_err(terminal_grid_error)?;
if let Some(title) = &self.title {
validate_metadata("title", title)?;
}
@ -249,7 +306,6 @@ impl TerminalFrame {
TerminalProcessState::Crashed(text) => validate_metadata("crash", text)?,
TerminalProcessState::Running | TerminalProcessState::Exited(_) => {}
}
self.validate_cells()?;
self.validate_selection()?;
if self.at_bottom != (self.scroll_offset == 0) {
return Err(TerminalFrameError::BottomState {
@ -260,107 +316,6 @@ impl TerminalFrame {
Ok(())
}
/// Declared cell area, checked against both shared bounds.
fn checked_area(&self) -> Result<usize, TerminalFrameError> {
let rows = self.size.rows;
let cols = self.size.cols;
if rows == 0
|| cols == 0
|| rows > u32::from(MAX_TERMINAL_ROWS)
|| cols > u32::from(MAX_TERMINAL_COLS)
{
return Err(TerminalFrameError::Size {
rows,
cols,
max_rows: u32::from(MAX_TERMINAL_ROWS),
max_cols: u32::from(MAX_TERMINAL_COLS),
});
}
// Both factors are bounded above by 512, so the product cannot
// overflow; `checked_mul` keeps that an assertion rather than an
// assumption a later bound change could quietly break.
let area = rows
.checked_mul(cols)
.and_then(|area| usize::try_from(area).ok())
.ok_or(TerminalFrameError::Area {
area: usize::MAX,
max: MAX_TERMINAL_VISIBLE_CELLS,
})?;
if area > MAX_TERMINAL_VISIBLE_CELLS {
return Err(TerminalFrameError::Area {
area,
max: MAX_TERMINAL_VISIBLE_CELLS,
});
}
Ok(area)
}
/// Glyph legality, wide-continuation topology, and the glyph budget.
fn validate_cells(&self) -> Result<(), TerminalFrameError> {
let cols = self.size.cols as usize;
let mut glyph_bytes = 0usize;
// Columns still owed to the preceding wide lead on this row.
let mut pending_continuation = false;
for (index, cell) in self.cells.iter().enumerate() {
if cell.attachment.is_some() {
return Err(TerminalFrameError::Attachment { index });
}
let col = index % cols;
if col == 0 && pending_continuation {
// A wide lead in the final column would have to be
// completed on the next row, which is not a footprint a
// terminal grid can express.
return Err(TerminalFrameError::Glyph {
index: index - 1,
reason: "wide glyph has no continuation column on its row",
});
}
match &cell.glyph {
Glyph::Continuation => {
if !pending_continuation {
return Err(TerminalFrameError::Glyph {
index,
reason: "continuation without a preceding wide glyph",
});
}
pending_continuation = false;
}
Glyph::Char(ch) => {
if pending_continuation {
return Err(TerminalFrameError::Glyph {
index,
reason: "wide glyph is not followed by its continuation",
});
}
let width = char_display_width(*ch).ok_or(TerminalFrameError::Glyph {
index,
reason: "glyph is a control or zero-width character",
})?;
glyph_bytes = add_glyph_bytes(glyph_bytes, ch.len_utf8())?;
pending_continuation = width == 2;
}
Glyph::Cluster(bytes) => {
if pending_continuation {
return Err(TerminalFrameError::Glyph {
index,
reason: "wide glyph is not followed by its continuation",
});
}
let width = cluster_display_width(bytes, index)?;
glyph_bytes = add_glyph_bytes(glyph_bytes, bytes.len())?;
pending_continuation = width == 2;
}
}
}
if pending_continuation {
return Err(TerminalFrameError::Glyph {
index: self.cells.len() - 1,
reason: "wide glyph has no continuation column on its row",
});
}
Ok(())
}
/// One nonempty in-bounds span per row, strictly increasing by row.
fn validate_selection(&self) -> Result<(), TerminalFrameError> {
let mut previous_row: Option<u32> = None;
@ -395,73 +350,6 @@ impl TerminalFrame {
}
}
/// Column width of a leading `Char` glyph, or `None` when it cannot lead.
fn char_display_width(ch: char) -> Option<usize> {
if ch.is_control() {
return None;
}
match UnicodeWidthChar::width(ch) {
Some(1) => Some(1),
Some(2) => Some(2),
_ => None,
}
}
/// Column width of a leading `Cluster` glyph.
///
/// Width is clamped into `1..=2` exactly as the terminal screen clamps it
/// when it writes the cluster: a base plus combining marks may measure
/// wider than two columns, and the screen occupies two. Clamping in one
/// place and measuring in another is how a frame that renders correctly
/// gets rejected on the wire.
fn cluster_display_width(bytes: &[u8], index: usize) -> Result<usize, TerminalFrameError> {
if bytes.is_empty() {
return Err(TerminalFrameError::Glyph {
index,
reason: "cluster is empty",
});
}
if bytes.len() > MAX_TERMINAL_GRAPHEME_BYTES {
return Err(TerminalFrameError::Glyph {
index,
reason: "cluster exceeds the per-cluster byte limit",
});
}
let text = std::str::from_utf8(bytes).map_err(|_| TerminalFrameError::Glyph {
index,
reason: "cluster is not valid UTF-8",
})?;
if text.chars().any(char::is_control) {
return Err(TerminalFrameError::Glyph {
index,
reason: "cluster carries a control character",
});
}
let width = UnicodeWidthStr::width(text);
if width == 0 {
return Err(TerminalFrameError::Glyph {
index,
reason: "cluster occupies no columns",
});
}
Ok(width.min(2))
}
/// Accumulate glyph bytes under the aggregate bound with checked addition.
fn add_glyph_bytes(total: usize, add: usize) -> Result<usize, TerminalFrameError> {
let next = total
.checked_add(add)
.ok_or(TerminalFrameError::GlyphBudget {
max: MAX_TERMINAL_FRAME_GLYPH_BYTES,
})?;
if next > MAX_TERMINAL_FRAME_GLYPH_BYTES {
return Err(TerminalFrameError::GlyphBudget {
max: MAX_TERMINAL_FRAME_GLYPH_BYTES,
});
}
Ok(next)
}
/// Length and control-character rules shared by title and process text.
fn validate_metadata(field: &'static str, text: &str) -> Result<(), TerminalFrameError> {
if text.len() > MAX_TERMINAL_METADATA_BYTES {
@ -482,7 +370,10 @@ fn validate_metadata(field: &'static str, text: &str) -> Result<(), TerminalFram
#[cfg(test)]
mod tests {
use super::*;
use crate::cell::{Color, Style, UnderlineStyle};
// `Glyph` is no longer used by this module's production code — the
// glyph rules moved to `crate::wire_grid` — but these tests still
// construct frames cell by cell.
use crate::cell::{Color, Glyph, Style, UnderlineStyle};
use crate::message::InstanceMessage;
use crate::transport::MAX_FRAME_BYTES;
@ -952,14 +843,14 @@ mod tests {
let mut over = exact.clone();
// One more byte of glyph, nothing else changed.
let last = over.cells.len() - 1;
over.cells[last] = cell_with(Glyph::Cluster(cluster_of_len(3).into_boxed_slice()));
over.cells[last] = cell_with(Glyph::Cluster(cluster_of_len(2).into_boxed_slice()));
(exact, over)
}
#[test]
fn maximum_legal_terminal_frame_encodes_below_the_transport_cap() {
let (exact, _) = budget_boundary_frames();
let (exact, over) = budget_boundary_frames();
assert_eq!(exact.validate(), Ok(()));
let mut glyph_bytes = 0usize;
@ -974,6 +865,20 @@ mod tests {
glyph_bytes, MAX_TERMINAL_FRAME_GLYPH_BYTES,
"the measured fixture must spend the whole aggregate budget"
);
let over_glyph_bytes = over
.cells
.iter()
.map(|cell| match &cell.glyph {
Glyph::Char(ch) => ch.len_utf8(),
Glyph::Cluster(bytes) => bytes.len(),
Glyph::Continuation => 0,
})
.sum::<usize>();
assert_eq!(
over_glyph_bytes,
MAX_TERMINAL_FRAME_GLYPH_BYTES + 1,
"the rejecting twin must be exactly one byte over the aggregate budget"
);
let msg = InstanceMessage::TerminalFrame(exact);
let bytes = postcard::to_allocvec(&msg).expect("encode");

View File

@ -0,0 +1,329 @@
//! Shared cell-grid validation for every wire message that carries a
//! rectangular grid of [`Cell`]s.
//!
//! Bottom-panel Stage 2B (Q#BP15) factors this out of
//! [`crate::terminal`], which was the only such message until
//! [`crate::panel::PanelFrame`] arrived. The split follows the boundary
//! the framing names:
//!
//! - **Shared** — the checked area, the visible-cell bound, the cell
//! count, cursor bounds, glyph legality, wide-continuation topology,
//! the aggregate glyph-byte budget, and the attachment rejection.
//! - **Terminal-only** — the 512 per-axis PTY caps, title/process
//! metadata, selection spans, and the `at_bottom == (scroll_offset ==
//! 0)` coupling.
//!
//! The per-axis caps are a [`WireGridLimits`] parameter rather than a
//! constant precisely because a panel does not inherit them: a 4K
//! surface at a small font is legitimately wider than 512 columns, and
//! the area bound is what keeps the encoding inside the transport
//! budget.
//!
//! The attachment rejection is deliberately **shared**, not
//! terminal-only, even though its terminal-side message reads "which
//! terminals never use". Panels render no attachments either, so
//! rejecting them here fails closed for both; classifying it as
//! terminal-only would let a panel ship a cell no frontend can paint.
use crate::cell::{Cell, CellCoord, CellSize, Glyph};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
/// Aggregate glyph-byte ceiling shared by every wire grid.
///
/// A grid at the visible-cell bound where every cell carries a maximum
/// cluster would exceed the transport frame limit; this keeps the
/// encoded size bounded independently of the per-cell rule.
pub const MAX_WIRE_GRID_GLYPH_BYTES: usize = 8 * 1024 * 1024;
/// Per-cell grapheme-cluster byte ceiling shared by every wire grid.
pub const MAX_WIRE_GRID_GRAPHEME_BYTES: usize = 256;
/// Visible-cell ceiling shared by every wire grid.
///
/// This is the transport-safety bound, not a per-message policy: it is
/// what keeps `rows * cols * per-cell` inside the transport frame limit,
/// so both the terminal and the panel answer to it even though they
/// carry different per-axis caps.
pub const MAX_WIRE_GRID_VISIBLE_CELLS: usize = 262_144;
/// Bounds a particular wire grid enforces.
///
/// `max_rows` / `max_cols` are per-message policy. `max_visible_cells`
/// is the shared area bound and is what actually keeps the encoding
/// inside the transport budget.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub struct WireGridLimits {
/// Inclusive row ceiling.
pub max_rows: u32,
/// Inclusive column ceiling.
pub max_cols: u32,
/// Inclusive `rows * cols` ceiling.
pub max_visible_cells: usize,
/// Inclusive aggregate glyph-byte ceiling.
pub max_glyph_bytes: usize,
}
/// Why a wire grid is not structurally valid.
///
/// Callers map these onto their own message-specific error types so
/// existing wire errors keep their exact variants and text.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum WireGridError {
/// Rows or columns are zero or above this grid's bounds.
Size {
/// Declared rows.
rows: u32,
/// Declared columns.
cols: u32,
/// Row bound in force.
max_rows: u32,
/// Column bound in force.
max_cols: u32,
},
/// The checked area exceeds the visible-cell bound.
Area {
/// Checked `rows * cols`.
area: usize,
/// Bound in force.
max: usize,
},
/// `cells.len()` disagrees with the declared area.
CellCount {
/// Declared area.
expected: usize,
/// Supplied cell count.
actual: usize,
},
/// The cursor lies outside the declared grid.
Cursor {
/// Cursor row.
row: u32,
/// Cursor column.
col: u32,
/// Declared rows.
rows: u32,
/// Declared columns.
cols: u32,
},
/// A cell's glyph is not legal in a wire grid.
Glyph {
/// Row-major cell index.
index: usize,
/// Why the glyph failed.
reason: &'static str,
},
/// A cell carries a frontend attachment, which no wire grid uses.
Attachment {
/// Row-major cell index.
index: usize,
},
/// Aggregate glyph bytes exceed the budget.
GlyphBudget {
/// Bound in force.
max: usize,
},
}
/// Declared cell area, checked against this grid's bounds.
///
/// Separate from [`validate_wire_grid`] because callers need the area
/// before they have cells to check against it.
pub fn checked_area(size: CellSize, limits: WireGridLimits) -> Result<usize, WireGridError> {
let rows = size.rows;
let cols = size.cols;
if rows == 0 || cols == 0 || rows > limits.max_rows || cols > limits.max_cols {
return Err(WireGridError::Size {
rows,
cols,
max_rows: limits.max_rows,
max_cols: limits.max_cols,
});
}
// `checked_mul` rather than a bound-derived assumption: a panel's
// axis ceilings are large enough that the product genuinely can
// overflow, which the terminal's 512x512 could not.
let area = rows
.checked_mul(cols)
.and_then(|area| usize::try_from(area).ok())
.ok_or(WireGridError::Area {
area: usize::MAX,
max: limits.max_visible_cells,
})?;
if area > limits.max_visible_cells {
return Err(WireGridError::Area {
area,
max: limits.max_visible_cells,
});
}
Ok(area)
}
/// Check every structural rule shared by wire grids.
///
/// Pure: a rejected grid mutates nothing, so callers get atomic
/// rejection for free.
pub fn validate_wire_grid(
size: CellSize,
cells: &[Cell],
cursor: Option<CellCoord>,
limits: WireGridLimits,
) -> Result<(), WireGridError> {
let area = checked_area(size, limits)?;
if cells.len() != area {
return Err(WireGridError::CellCount {
expected: area,
actual: cells.len(),
});
}
if let Some(cursor) = cursor
&& (cursor.row >= size.rows || cursor.col >= size.cols)
{
return Err(WireGridError::Cursor {
row: cursor.row,
col: cursor.col,
rows: size.rows,
cols: size.cols,
});
}
validate_cells(size, cells, limits)
}
/// Glyph legality, wide-continuation topology, and the glyph budget.
fn validate_cells(
size: CellSize,
cells: &[Cell],
limits: WireGridLimits,
) -> Result<(), WireGridError> {
let cols = size.cols as usize;
let mut glyph_bytes = 0usize;
// Columns still owed to the preceding wide lead on this row.
let mut pending_continuation = false;
for (index, cell) in cells.iter().enumerate() {
if cell.attachment.is_some() {
return Err(WireGridError::Attachment { index });
}
let col = index % cols;
if col == 0 && pending_continuation {
// A wide lead in the final column would have to be completed
// on the next row, which is not a footprint a cell grid can
// express.
return Err(WireGridError::Glyph {
index: index - 1,
reason: "wide glyph has no continuation column on its row",
});
}
match &cell.glyph {
Glyph::Continuation => {
if !pending_continuation {
return Err(WireGridError::Glyph {
index,
reason: "continuation without a preceding wide glyph",
});
}
pending_continuation = false;
}
Glyph::Char(ch) => {
if pending_continuation {
return Err(WireGridError::Glyph {
index,
reason: "wide glyph is not followed by its continuation",
});
}
let width = char_display_width(*ch).ok_or(WireGridError::Glyph {
index,
reason: "glyph is a control or zero-width character",
})?;
glyph_bytes = add_glyph_bytes(glyph_bytes, ch.len_utf8(), limits)?;
pending_continuation = width == 2;
}
Glyph::Cluster(bytes) => {
if pending_continuation {
return Err(WireGridError::Glyph {
index,
reason: "wide glyph is not followed by its continuation",
});
}
let width = cluster_display_width(bytes, index)?;
glyph_bytes = add_glyph_bytes(glyph_bytes, bytes.len(), limits)?;
pending_continuation = width == 2;
}
}
}
if pending_continuation {
return Err(WireGridError::Glyph {
index: cells.len() - 1,
reason: "wide glyph has no continuation column on its row",
});
}
Ok(())
}
/// Column width of a leading `Char` glyph, or `None` when it cannot lead.
pub(crate) fn char_display_width(ch: char) -> Option<usize> {
if ch.is_control() {
return None;
}
match UnicodeWidthChar::width(ch) {
Some(1) => Some(1),
Some(2) => Some(2),
_ => None,
}
}
/// Column width of a leading `Cluster` glyph.
///
/// Width is clamped into `1..=2` exactly as the terminal screen clamps it
/// when it writes the cluster: a base plus combining marks may measure
/// wider than two columns, and the screen occupies two. Clamping in one
/// place and measuring in another is how a frame that renders correctly
/// gets rejected on the wire.
fn cluster_display_width(bytes: &[u8], index: usize) -> Result<usize, WireGridError> {
if bytes.is_empty() {
return Err(WireGridError::Glyph {
index,
reason: "cluster is empty",
});
}
if bytes.len() > MAX_WIRE_GRID_GRAPHEME_BYTES {
return Err(WireGridError::Glyph {
index,
reason: "cluster exceeds the per-cluster byte limit",
});
}
let text = std::str::from_utf8(bytes).map_err(|_| WireGridError::Glyph {
index,
reason: "cluster is not valid UTF-8",
})?;
if text.chars().any(char::is_control) {
return Err(WireGridError::Glyph {
index,
reason: "cluster carries a control character",
});
}
let width = UnicodeWidthStr::width(text);
if width == 0 {
return Err(WireGridError::Glyph {
index,
reason: "cluster occupies no columns",
});
}
Ok(width.min(2))
}
/// Accumulate glyph bytes against the aggregate budget.
fn add_glyph_bytes(
total: usize,
add: usize,
limits: WireGridLimits,
) -> Result<usize, WireGridError> {
let next = total.checked_add(add).ok_or(WireGridError::GlyphBudget {
max: limits.max_glyph_bytes,
})?;
if next > limits.max_glyph_bytes {
return Err(WireGridError::GlyphBudget {
max: limits.max_glyph_bytes,
});
}
Ok(next)
}

254
scripts/regen-lean-abbrev Executable file
View File

@ -0,0 +1,254 @@
#!/usr/bin/env python3
"""Regenerate builtin/runtime/lean_abbrev.lua from vscode-lean4.
Usage: scripts/regen-lean-abbrev <vscode-lean4-commit>
Fetches `lean4-unicode-input/src/abbreviations.json` at the given commit
and rewrites the vendored Lua table, including the provenance header, so
the artifact is self-describing to whoever next touches it. A refresh is
an ordinary PR with a visible diff — the diff is the review.
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
(docs/lean4-mode-framing.md Q#LN11).
The emit is an ORDERED SEQUENCE, not a map. Upstream resolves
equal-length abbreviation ties by source declaration order — 101
prefixes depend on it — and a Lua `{ [key] = symbol }` table iterated
with `pairs` cannot carry that. A map-shaped emit would also be
nondeterministic across builds and, once a hash order happened to be
stable, stably wrong.
This script ABORTS rather than emitting something plausible when the
source is corrupt: a duplicate key after decoding (JSON permits them,
the table must not), a key or symbol that is not well-formed UTF-8, or a
round-trip mismatch. That last check re-parses the script's own output
with an independent unescaper and compares the full ordered sequence to
the source, entry for entry. It is what makes the vendored file
trustworthy, and it belongs here rather than in the acceptance suite:
the suite cannot see `abbreviations.json`, which is not shipped.
"""
import json
import pathlib
import sys
import urllib.request
REPO = "leanprover/vscode-lean4"
PATH = "lean4-unicode-input/src/abbreviations.json"
LICENSE = "Apache-2.0"
OUT = pathlib.Path(__file__).resolve().parent.parent / "builtin/runtime/lean_abbrev.lua"
# Canonical, lossless, byte-deterministic. Rev 6 of the framing said the
# generator should abort on "a key containing a character the emitted Lua
# would have to escape"; that rule rejects the real table, where `\` is a
# key and `"` begins eleven of them.
SHORT = {"\\": "\\\\", '"': '\\"', "\n": "\\n", "\r": "\\r", "\t": "\\t"}
def die(msg):
print(f"regen-lean-abbrev: {msg}", file=sys.stderr)
raise SystemExit(1)
def lua_escape(s):
"""Escape one string for a Lua double-quoted literal.
Operates on CHARACTERS, not bytes. Decomposing to UTF-8 bytes and
emitting each as `chr(byte)` produces a latin-1-shaped string that
`write_text(..., encoding="utf-8")` then re-encodes — every
non-ASCII symbol lands in the file double-encoded, and a round-trip
check that compares in-memory strings agrees with itself and misses
it entirely. Only control bytes, which are single-byte by
definition, become `\\ddd`.
"""
out = []
for ch in s:
if ch in SHORT:
out.append(SHORT[ch])
elif ord(ch) < 0x20 or ord(ch) == 0x7F:
out.append(f"\\{ord(ch):03d}")
else:
out.append(ch)
return "".join(out)
def lua_unescape(s):
"""Independent reader for the round-trip check.
Deliberately not the inverse of `lua_escape` sharing its table: a
check that reuses the encoder's own assumptions cannot detect that
those assumptions are wrong.
"""
out = bytearray()
i = 0
raw = s.encode("utf-8")
while i < len(raw):
b = raw[i]
if b != ord("\\"):
out.append(b)
i += 1
continue
i += 1
if i >= len(raw):
die("round-trip: trailing backslash in emitted string")
nxt = chr(raw[i])
if nxt in ("\\", '"'):
out.append(ord(nxt))
i += 1
elif nxt in ("n", "r", "t"):
out.append({"n": 10, "r": 13, "t": 9}[nxt])
i += 1
elif nxt.isdigit():
digits = ""
while i < len(raw) and chr(raw[i]).isdigit() and len(digits) < 3:
digits += chr(raw[i])
i += 1
out.append(int(digits))
else:
die(f"round-trip: unknown escape \\{nxt} in emitted string")
return out.decode("utf-8")
def main():
if len(sys.argv) != 2:
die(f"usage: {sys.argv[0]} <vscode-lean4-commit>")
commit = sys.argv[1]
url = f"https://raw.githubusercontent.com/{REPO}/{commit}/{PATH}"
with urllib.request.urlopen(url, timeout=60) as resp:
raw = resp.read()
try:
raw.decode("utf-8")
except UnicodeDecodeError as e:
die(f"source is not well-formed UTF-8: {e}")
# `object_pairs_hook` keeps declaration order AND exposes duplicate
# keys, which a plain dict would silently collapse.
pairs = json.loads(raw, object_pairs_hook=lambda kv: kv)
seen = {}
for i, (key, symbol) in enumerate(pairs):
if key in seen:
die(f"duplicate key {key!r} at entries {seen[key]} and {i}")
seen[key] = i
for label, s in (("key", key), ("symbol", symbol)):
if not isinstance(s, str):
die(f"{label} at entry {i} is not a string: {s!r}")
try:
s.encode("utf-8")
except UnicodeEncodeError as e:
die(f"{label} at entry {i} is not well-formed UTF-8: {e}")
cursor = sum(1 for _, v in pairs if "$CURSOR" in v)
for i, (key, symbol) in enumerate(pairs):
if symbol.count("$CURSOR") > 1:
die(f"symbol for {key!r} at entry {i} has more than one $CURSOR")
body = "".join(
f' {{ "{lua_escape(k)}", "{lua_escape(v)}" }},\n' for k, v in pairs
)
text = HEADER.format(
repo=REPO,
path=PATH,
commit=commit,
license=LICENSE,
count=len(pairs),
cursor=cursor,
bytes=len(raw),
script=pathlib.Path(sys.argv[0]).name,
) + "pmacs.lean_abbrev = {\n" + body + "}\n"
# Round-trip against the BYTES ON DISK, not the string in memory.
# The file is staged beside its destination, re-read, parsed, and
# only renamed into place once it compares equal entry for entry. A
# check that compares in-memory strings cannot see an encoding
# applied by the write itself, which is exactly how a
# double-encoding bug survived the first version of this script.
staged = OUT.with_suffix(".lua.staged")
staged.write_text(text, encoding="utf-8")
on_disk = staged.read_bytes().decode("utf-8")
got = []
# `str.splitlines()` is WRONG here: it also splits on U+2028, U+2029,
# U+0085 and the vertical-tab family, and 53 symbols in the real
# table contain one of those literally. It silently loses entries and
# the round-trip then reports a count mismatch that is the checker's
# bug, not the emit's. The emitted file's line structure is defined
# by the LF we write, and nothing else.
for line in on_disk.split("\n"):
line = line.strip()
if not line.startswith('{ "') or not line.endswith("},"):
continue
inner = line[1:-2].strip()
if not (inner.startswith('"') and inner.endswith('"')):
die(f"round-trip: unparsable emitted line: {line!r}")
fields, buf, esc, depth = [], [], False, 0
for ch in inner:
if esc:
buf.append(ch)
esc = False
elif ch == "\\":
buf.append(ch)
esc = True
elif ch == '"':
depth += 1
if depth % 2 == 0:
fields.append("".join(buf))
buf = []
elif depth % 2 == 1:
buf.append(ch)
if len(fields) != 2:
die(f"round-trip: expected 2 fields, got {len(fields)}: {line!r}")
got.append((lua_unescape(fields[0]), lua_unescape(fields[1])))
def fail(msg):
staged.unlink(missing_ok=True)
die(msg)
if len(got) != len(pairs):
fail(f"round-trip: emitted {len(got)} entries, source has {len(pairs)}")
for i, (want, have) in enumerate(zip(pairs, got)):
if tuple(want) != have:
fail(f"round-trip: entry {i} differs: source {want!r} vs emitted {have!r}")
staged.replace(OUT)
print(
f"wrote {OUT} — {len(pairs)} entries from {REPO}@{commit} "
f"({len(raw)} source bytes, {OUT.stat().st_size} emitted bytes), "
"round-trip verified against the bytes on disk"
)
HEADER = """\
-- lean_abbrev.lua --- VENDORED DATA. Do not edit by hand.
--
-- The Lean 4 abbreviation table, generated from:
--
-- repo: https://github.com/{repo}
-- path: {path}
-- commit: {commit}
-- license: {license}
-- entries: {count} ({cursor} carry $CURSOR)
-- source: {bytes} bytes
--
-- Regenerate with:
--
-- scripts/{script} {commit}
--
-- An ORDERED SEQUENCE, not a map: upstream resolves equal-length ties
-- by source declaration order (101 prefixes depend on it), and a
-- `pairs`-iterated Lua map cannot express that. The file's own line
-- order is the audit trail. Consumers must not reorder it.
--
-- Not fetched at runtime and not a package dependency: the input method
-- has to work offline and on first launch. Upkeep is a documented
-- manual process — see docs/lean4-mode-framing.md Q#LN11.
pmacs = pmacs or {{}}
"""
if __name__ == "__main__":
main()

View File

@ -483,6 +483,27 @@ fn main() {
}
});
write_frame(&mut stdout, &echo);
// Arc 8 Stage 3b: `leanprogress` mode emits one
// `$/lean/fileProgress` covering line 0, so the Lean
// subscriber can be pinned end-to-end through the real
// drain rather than by calling its handler directly.
if mode == "leanprogress" && uri.is_string() {
let progress = serde_json::json!({
"jsonrpc": "2.0",
"method": "$/lean/fileProgress",
"params": {
"textDocument": { "uri": uri, "version": 1 },
"processing": [{
"range": {
"start": { "line": 0, "character": 0 },
"end": { "line": 1, "character": 0 }
},
"kind": 1
}]
}
});
write_frame(&mut stdout, &progress);
}
// Also push a synthetic `publishDiagnostics`
// notification with two entries (one Error, one
// Warning) so M4.6 tests can exercise the store.
@ -1062,6 +1083,39 @@ fn main() {
});
write_frame(&mut stdout, &resp);
}
("textDocument/waitForDiagnostics", Some(idv)) => {
// Arc 8 Stage 3b: Lean's `WaitForDiagnosticsParams` is
// `{ uri, version }` (v4.9.0
// `src/Lean/Data/Lsp/Extra.lean`). Validated here rather
// than echoed, because the generic echo arm below
// accepts anything — which is exactly how a client
// sending only `uri` shipped looking correct. A client
// that omits `version`, or sends a non-integer, gets an
// InvalidParams error the way a real server would.
let uri_ok = params
.get("uri")
.and_then(serde_json::Value::as_str)
.is_some();
let version_ok = params
.get("version")
.and_then(serde_json::Value::as_i64)
.is_some();
let resp = if uri_ok && version_ok {
serde_json::json!({
"jsonrpc": "2.0", "id": idv, "result": serde_json::Value::Null
})
} else {
serde_json::json!({
"jsonrpc": "2.0",
"id": idv,
"error": {
"code": -32602,
"message": "waitForDiagnostics requires { uri, version }"
}
})
};
write_frame(&mut stdout, &resp);
}
(_, Some(idv)) => {
// Generic echo response.
let resp = serde_json::json!({

View File

@ -504,6 +504,67 @@ impl Buffer {
self.read_only = read_only;
}
/// Replace a generated buffer's entire contents on behalf of its owner,
/// and leave it genuinely immutable.
///
/// This is the **owner-authorized update path** that genuine
/// immutability for generated buffers requires. A snapshot, panel or
/// `*compilation*` buffer must reject ordinary edits, **undo, redo**,
/// and remote CRDT imports alike — and only [`read_only`] does that.
/// An edit intercept is not enough: it guards the dispatch/edit path
/// only, while [`Buffer::undo`] reaches the rope through
/// `ensure_writable` without ever consulting the intercept chain. A
/// buffer protected by an intercept alone can therefore be emptied by
/// `C-/`, by `M-x buffer.undo`, or by the menu — the command is
/// reachable even where the chords are rebound to no-ops.
///
/// But `read_only` also blocks the owner's own refresh, which is the
/// operation such buffers exist for. So the owner needs exactly one
/// door, and this is it: lift the flag, replace the contents skipping
/// intercepts, **discard the resulting history**, re-assert the flag.
///
/// Discarding history is not tidiness. Without it every refresh pushes
/// undo entries holding full rope clones that nothing can ever pop —
/// `read_only` guarantees they are unreachable — so a periodically
/// refreshed buffer would grow without bound. In CRDT mode the same
/// retention lives in loro's `UndoManager`, so both are cleared.
///
/// # The returned edit must be fanned out
///
/// One whole-buffer [`EditOp::Replace`] is applied, and its [`Edit`]
/// is returned rather than swallowed, because a rope write is only
/// half of an edit. Callers **must** route the result through their
/// normal edit-notification path (for the Lua surface,
/// `notify_buffer_edit_to_windows`). A window already displaying the
/// buffer keeps a stale `TextView` line cache otherwise, and the next
/// paint indexes the new rope with old ranges; and in CRDT mode the
/// op never reaches replica mirrors, so their optimistic edits are
/// generated against content the owner has already replaced.
///
/// [`read_only`]: Self::set_read_only
pub fn set_generated_contents(&mut self, bytes: &[u8]) -> Result<Edit, BufferError> {
self.read_only = false;
let result = self.apply_edit_skip_intercepts(EditOp::Replace {
range: Range::new(0, self.len()),
bytes,
});
// Cleared even on failure: a partial replace must not leave a
// half-applied edit reachable through an undo the owner cannot see.
self.clear_history();
self.read_only = true;
result
}
/// Drop undo and redo history in whichever mode this buffer is in.
fn clear_history(&mut self) {
self.undo.clear();
self.redo.clear();
#[cfg(feature = "crdt")]
if let Some(crdt) = self.crdt.as_ref() {
crdt.clear_undo_history();
}
}
fn ensure_writable(&self) -> Result<(), BufferError> {
if self.read_only {
Err(BufferError::ReadOnly {
@ -1955,6 +2016,99 @@ mod tests {
}
);
/// The whole point of the primitive: after an owner write the buffer
/// is immutable, and `undo` — which never consults the intercept
/// chain — cannot reach back past it.
#[test]
fn set_generated_contents_writes_then_locks_and_leaves_nothing_to_undo() {
let mut buf = Buffer::new(BufferId::next(), "*generated*");
buf.set_generated_contents(b"first render").expect("write");
assert_eq!(buf.len(), 12);
assert!(buf.is_read_only(), "the buffer ends immutable");
assert!(
matches!(buf.undo(), Err(BufferError::ReadOnly { .. })),
"undo must be refused at the rope, not merely at dispatch"
);
assert!(matches!(buf.redo(), Err(BufferError::ReadOnly { .. })));
// Even with the lock lifted there is no history to replay — the
// protection does not depend on the flag alone.
buf.set_read_only(false);
assert!(matches!(buf.undo(), Err(BufferError::NothingToUndo)));
assert!(matches!(buf.redo(), Err(BufferError::NothingToRedo)));
}
/// Refreshing repeatedly must not accumulate unreachable history.
/// Each render would otherwise push entries holding full rope clones
/// that `read_only` guarantees nothing can ever pop.
#[test]
fn repeated_generated_writes_do_not_accumulate_history() {
let mut buf = Buffer::new(BufferId::next(), "*generated*");
for i in 0..10 {
buf.set_generated_contents(format!("render {i}").as_bytes())
.expect("write");
}
let mut bytes = vec![0u8; buf.len() as usize];
buf.snapshot_rope().slice(0, buf.len(), &mut bytes);
assert_eq!(String::from_utf8(bytes).expect("utf8"), "render 9");
buf.set_read_only(false);
assert!(
matches!(buf.undo(), Err(BufferError::NothingToUndo)),
"ten renders must leave an empty undo stack, not ten entries"
);
}
/// Review round 3, P2. In CRDT mode the v0.1 stacks are bypassed
/// entirely, so clearing them proves nothing: the history the
/// primitive promises to discard lives in loro's `UndoManager`.
/// The lock is lifted deliberately — `read_only` stops the replay,
/// but the contract is that there is nothing left to replay.
#[cfg(feature = "crdt")]
#[test]
fn generated_writes_accumulate_no_crdt_history_either() {
let mut buf =
Buffer::new_with_crdt(BufferId::next(), "*generated*", 1).expect("crdt construction");
for i in 0..10 {
buf.set_generated_contents(format!("render {i}").as_bytes())
.expect("write");
}
assert_eq!(rope_string(&buf), "render 9");
assert!(
!buf.crdt_state().expect("crdt-backed").can_undo(),
"the UndoManager must have nothing recorded"
);
buf.set_read_only(false);
assert!(
matches!(buf.undo(), Err(BufferError::NothingToUndo)),
"CRDT-mode undo must find no history either"
);
}
/// An ordinary edit is still refused after a generated write, so the
/// primitive does not quietly leave the buffer writable.
#[test]
fn set_generated_contents_still_refuses_ordinary_edits() {
let mut buf = Buffer::new(BufferId::next(), "*generated*");
buf.set_generated_contents(b"content").expect("write");
assert!(matches!(
buf.apply_edit(EditOp::Insert {
pos: 0,
bytes: b"x"
}),
Err(BufferError::ReadOnly { .. })
));
assert!(matches!(
buf.apply_edit_skip_intercepts(EditOp::Insert {
pos: 0,
bytes: b"x"
}),
Err(BufferError::ReadOnly { .. })
));
}
#[cfg(feature = "crdt")]
#[test]
fn read_only_rejects_remote_crdt_before_import_and_allows_empty_bootstrap() {

View File

@ -486,6 +486,26 @@ impl CrdtState {
pub fn record_checkpoint(&self) -> LoroResult<()> {
self.undo.borrow_mut().record_new_checkpoint()
}
/// Discard the bound peer's undo and redo history, keeping the
/// document itself untouched.
///
/// Loro's `UndoManager` exposes no `clear`, but it does not need
/// one: a manager records only what happens **after** it is
/// constructed. [`Self::from_bytes`] already relies on exactly
/// that property to keep the seed insert out of undo. Replacing
/// the manager with a fresh one bound to the same doc therefore
/// leaves nothing to undo, and drops the old manager's retained
/// stacks with it.
///
/// Used by [`crate::buffer::Buffer::set_generated_contents`], whose
/// contract is that a generated buffer accumulates no history
/// across refreshes. Marking the buffer read-only would stop the
/// history being *replayed*, but not being *retained* — a panel
/// refreshed on a timer would grow without bound.
pub fn clear_undo_history(&self) {
*self.undo.borrow_mut() = Self::create_undo_manager(&self.doc);
}
}
/// T M10.3: map a [`crate::protocol::FrontendId`] to the loro `PeerID`

File diff suppressed because it is too large Load Diff

View File

@ -26,7 +26,6 @@ use unicode_width::UnicodeWidthStr;
use crate::async_runtime::SharedAsyncRuntime;
use crate::cell::{CellCoord, CellSize};
use crate::editor_core::EditorCore;
use crate::file_io::load_file;
use crate::frontend::{Event, Frontend, KeyEvent, KeyEventKind, MouseEvent, install_panic_hook};
use crate::key::{Chord, display_sequence};
use crate::keymap_stack::{Action, KeyDispatcher};
@ -80,6 +79,109 @@ impl Drop for InteractiveCommandOriginGuard {
}
}
/// A frontend scope for **background** work — deliberately NOT
/// [`InteractiveCommandOrigin`] (Journey Stage 1a, Q#JR14e).
///
/// An async continuation (a settled directory listing, and eventually
/// any other post-await window work) needs to act for the frontend that
/// *requested* it rather than whichever one happens to be ambient when
/// the worker finishes. Reusing the interactive origin for that would be
/// wrong twice over:
///
/// 1. **It does not scope enough.** Only `acting_frontend` consults it,
/// so `pmacs.window.display` would be scoped while no-arg
/// `pmacs.window.buffer()` (which reads `active_buffer_id()`
/// directly) and `pmacs.editor.move_to_line` (which mutates the
/// core's ambient active window) stayed ambient — and those are
/// precisely the calls that capture and seat.
/// 2. **It is authenticated user-command authority.** It is what
/// distinguishes a user command's edit from a plugin's or the data
/// API's: the pre-edit unfold guard, `invoke_interactive`'s
/// command-boundary rotation, and the terminal surface's "requires an
/// interactive frontend context" checks all key off it. A background
/// listing must not acquire any of that.
///
/// So this is a separate slot, resolved *ahead* of the interactive
/// origin, whose guard **also** swaps `EditorCore::active_frontend` —
/// which is what covers the core-ambient APIs `acting_frontend` never
/// sees. That swap is not a workaround: `pmacs.window.buffer()`'s no-arg
/// arm documents its own correctness as resting on "dispatch sets
/// `active_frontend` to the acting frontend before running a command",
/// and this restores that invariant for a continuation.
#[derive(Clone, Default)]
pub(crate) struct ScopedFrontend(Rc<Cell<Option<FrontendId>>>);
impl ScopedFrontend {
/// The override in force, if any.
#[must_use]
pub(crate) fn current(&self) -> Option<FrontendId> {
self.0.get()
}
/// Enter a background frontend scope, also swapping the core's
/// ambient `active_frontend`. Both are restored on drop, on every
/// exit path including a raising callback.
pub(crate) fn enter(
&self,
core: &SharedCore,
commit_scope: &CommitScopeActive,
frontend_id: FrontendId,
) -> ScopedFrontendGuard {
let previous = self.0.replace(Some(frontend_id));
let previous_active = {
let mut core = core.borrow_mut();
let was = core.active_frontend;
core.active_frontend = frontend_id;
was
};
let previous_commit = commit_scope.0.replace(true);
ScopedFrontendGuard {
scope: self.clone(),
core: core.clone(),
previous,
previous_active,
commit_scope: commit_scope.clone(),
previous_commit,
}
}
}
pub(crate) struct ScopedFrontendGuard {
scope: ScopedFrontend,
core: SharedCore,
previous: Option<FrontendId>,
previous_active: FrontendId,
/// Cleared together with the scope, so an awaiting callback cannot
/// leave `await` refused after the commit ends (Q#JR14b).
commit_scope: CommitScopeActive,
previous_commit: bool,
}
impl Drop for ScopedFrontendGuard {
fn drop(&mut self) {
self.scope.0.set(self.previous);
self.core.borrow_mut().active_frontend = self.previous_active;
self.commit_scope.0.set(self.previous_commit);
}
}
/// Whether a `pmacs.window.commit_to` callback is currently running
/// (Journey Stage 1a, Q#JR14b).
///
/// Read from Lua as `pmacs._async._in_commit_scope()`; `Handle:await`
/// refuses while it is set. Lives beside the scope guard so the two can
/// never disagree.
#[derive(Clone, Default)]
pub struct CommitScopeActive(Rc<Cell<bool>>);
impl CommitScopeActive {
/// Whether a commit callback is on the stack.
#[must_use]
pub fn active(&self) -> bool {
self.0.get()
}
}
// ---------------------------------------------------------------------------
// EditorState
// ---------------------------------------------------------------------------
@ -261,6 +363,13 @@ impl EditorState {
let mut lua_host = LuaHost::with_registry(registry).expect("Lua runtime initialization");
let interactive_origin = InteractiveCommandOrigin::default();
lua_host.lua().set_app_data(interactive_origin.clone());
// Q#JR14e/Q#JR14b: the background frontend scope and the
// commit-scope flag live only as Lua app data -- `commit_to` and
// `Handle:await` are the only readers, and both reach them that
// way. No `EditorState` field, so there is no second handle that
// could disagree with the one the guard restores.
lua_host.lua().set_app_data(ScopedFrontend::default());
lua_host.lua().set_app_data(CommitScopeActive::default());
lua_host
.attach_editor(&core)
.expect("editor bindings + builtin chunks");
@ -415,6 +524,18 @@ impl EditorState {
include_str!("../builtin/runtime/listview.lua"),
)
.expect("load listview builtin chunk");
// The typed-edit consumer chain (Arc 8 Stage 4a, Q#LN10) —
// ORDERING CONTRACT: typed_edit.lua must load BEFORE pair.lua,
// which registers a consumer into it, and therefore before
// lsp.lua. It owns the single `buffer.after-edit` subscriber
// that reads the one-shot typed-edit record, so its
// registration position is what preserves Q#AP7 below.
lua_host
.eval(
Some("@pmacs/builtin/runtime/typed_edit.lua"),
include_str!("../builtin/runtime/typed_edit.lua"),
)
.expect("load typed_edit builtin chunk");
// Auto-pairing (Arc 2, Q#AP7) — ORDERING CONTRACT: pair.lua
// must load BEFORE lsp.lua. Hook callbacks run in registration
// order, and lsp.lua's `buffer.after-edit` callback flushes
@ -424,18 +545,53 @@ impl EditorState {
// the closer stays unsynchronized until the next edit (hook
// edits don't re-fire the hook). pair.lua's `pmacs.lsp.*`
// lookups are lazy and nil-guarded for the same reason.
// Since Stage 4a the closer is inserted from the chain's
// subscriber rather than pair.lua's own, which is registered
// one chunk earlier — strictly safer for this contract.
lua_host
.eval(
Some("@pmacs/builtin/runtime/pair.lua"),
include_str!("../builtin/runtime/pair.lua"),
)
.expect("load pair builtin chunk");
// Arc 8 Stage 4b: the Lean 4 Unicode input method. The vendored
// abbreviation table first — lean_input.lua reads it at chunk
// load to build its prefix and eager-key indexes. Both load
// after typed_edit.lua, which they register into.
//
// Load order does NOT decide whether abbreviation expansion or
// auto-pairing sees a keystroke first — the chain's priority
// does (50 vs 100), which is why Stage 4a exists. It matters
// only that the chain itself is already there.
lua_host
.eval(
Some("@pmacs/builtin/runtime/lean_abbrev.lua"),
include_str!("../builtin/runtime/lean_abbrev.lua"),
)
.expect("load lean_abbrev builtin chunk");
lua_host
.eval(
Some("@pmacs/builtin/runtime/lean_input.lua"),
include_str!("../builtin/runtime/lean_input.lua"),
)
.expect("load lean_input builtin chunk");
lua_host
.eval(
Some("@pmacs/builtin/runtime/lsp.lua"),
include_str!("../builtin/runtime/lsp.lua"),
)
.expect("load lsp builtin chunk");
// Arc 8 Stage 3b: the Lean 4 language server. Loaded after
// lsp.lua because it registers `pmacs.lsp.config.lean4`,
// subscribes on the Stage 3a notification seam, and adds a
// `buffer.after-load` hook that must run AFTER lsp.lua's own
// (it reads the attachment lsp.lua creates).
lua_host
.eval(
Some("@pmacs/builtin/runtime/lean.lua"),
include_str!("../builtin/runtime/lean.lua"),
)
.expect("load lean builtin chunk");
// Arc 1a: the in-buffer completion popup driver. Loaded after
// lsp.lua because it drives `pmacs.lsp.request_completion` /
// `pmacs.lsp.attachment_for_request` and after the framework
@ -743,35 +899,65 @@ impl EditorState {
/// Construct an editor for a path. Empty buffer with `[new file]`
/// status if the path does not exist; loaded contents otherwise.
///
/// Journey Stage 1a (Q#JR1): this is a thin caller of
/// [`EditorCore::resolve_target_buffer`], not a second
/// implementation of it. That primitive documents itself as "one
/// primitive, so two path-normalization, dedup, and hook
/// transactions cannot drift apart" — and local startup, which had
/// hand-written the same three-arm shape, was not one of its callers
/// until now.
///
/// Two things this caller still owns, and must keep owning:
///
/// * **The window install.** `resolve_target_buffer` deliberately
/// does not touch windows, so the caller places the buffer.
/// Startup uses [`Self::replace_active_buffer`], which switches
/// the ACTIVE window — an `install_buffer_in_window` into some
/// other window would load the file and leave the user looking at
/// scratch (Q#JR3).
///
/// It does **not** destroy the scratch buffer, despite what
/// `replace_active_buffer`'s own doc comment has long claimed:
/// that function only calls `switch_active_buffer`, which
/// reassigns the window's `buffer_id` and removes nothing. The
/// startup scratch survives in the registry, and did before this
/// stage too. Changing that is buffer-lifetime work with its own
/// consequences (what else may hold the id, what `C-x b` should
/// list) and is deliberately not smuggled in here.
/// * **Firing the hook outside the core borrow.** Listeners
/// re-enter `pmacs.editor.*`, which re-borrows the core
/// (Q#JR1a) — the same reason the daemon bootstrap and
/// `display_file` both fire theirs after their borrow blocks end.
///
/// A directory resolves to [`ResolvedTarget::Directory`] and is
/// dispatched to the directory resolver chain rather than opened as
/// a buffer (Q#JR6); see [`Self::open_directory_target`].
#[allow(
clippy::needless_pass_by_value,
reason = "stable public entry point mirroring `pmacs PATH` and \
`run(Option<PathBuf>)`; the body stopped consuming the \
PathBuf when this became a `resolve_target_buffer` caller, \
and churning the signature would touch every caller for no \
behavioral gain"
)]
pub fn open(path: PathBuf) -> io::Result<Self> {
let display_name = path.display().to_string();
let state = Self::new();
let mut state = Self::new();
let resolved = state
.core
.borrow_mut()
.resolve_target_buffer(&path)
.map_err(io::Error::other)?;
let mut fire_after_load = false;
match load_file(&path) {
Ok((bytes, meta)) => {
let new_id = state
.lua_host
.registry()
.borrow_mut()
.create_from_bytes(display_name, &bytes);
state.replace_active_buffer(new_id);
let mut core = state.core.borrow_mut();
core.set_buffer_path(new_id, Some(path));
core.set_buffer_meta(new_id, Some(meta));
fire_after_load = true;
Ok(())
match resolved {
crate::editor_core::ResolvedTarget::Buffer { id, fire } => {
state.replace_active_buffer(id);
fire_after_load = matches!(fire, crate::editor_core::HookKind::AfterLoad);
}
Err(e) if e.kind() == io::ErrorKind::NotFound => {
let new_id = state.lua_host.registry().borrow_mut().create(display_name);
state.replace_active_buffer(new_id);
let mut core = state.core.borrow_mut();
core.set_buffer_path(new_id, Some(path));
core.status = "[new file]".into();
Ok(())
crate::editor_core::ResolvedTarget::Directory { path } => {
state.open_directory_target(&path);
}
Err(e) => Err(e),
}?;
let mut state = state;
}
if fire_after_load {
// Fire the hook *after* the borrow on `core` is released
// (block above ends). Listeners may legitimately re-enter
@ -783,9 +969,147 @@ impl EditorState {
Ok(state)
}
/// Switch the active window to `buffer_id`, dropping any old
/// scratch buffer if the active window's previous buffer has no
/// other windows referencing it. Returns silently on a stale id.
/// Capture the destination a directory open must commit to
/// (Q#JR14), or `None` when `frontend` has no document window.
///
/// Synchronous by necessity: the listing settles a tick or more
/// later, and by then the ambient frontend, selected window, and
/// active buffer may all name something else.
pub(crate) fn capture_directory_destination(
&self,
frontend: crate::protocol::FrontendId,
window: crate::window::WindowId,
) -> Option<crate::editor_core::DirectoryDestination> {
let core = self.core.borrow();
let buffer = core.windows.get(&window)?.buffer_id;
Some(crate::editor_core::DirectoryDestination {
frontend,
window,
buffer,
})
}
/// Local-startup directory open (Q#JR6): resolve the destination
/// from `LOCAL`'s document window and dispatch the resolver chain.
///
/// Public because it is the whole of what `pmacs DIRECTORY` does
/// after resolution — acceptance drives this rather than
/// `resolve_target_buffer`, so a directory arm with no production
/// caller cannot pass.
pub fn open_directory_target(&mut self, path: &std::path::Path) {
// Canonicalize here as well as in the resolver arm. The two are
// not redundant: this is a public "open this directory" seam, so
// a caller that did not come through `resolve_target_buffer`
// must still hand the chain a canonical path (Q#JR8) --- and
// normalization is idempotent, so the startup path pays nothing.
let path = crate::editor_core::normalize_buffer_path(path.to_path_buf());
let path = path.as_path();
let window = self
.core
.borrow()
.primary_document_window(crate::protocol::FrontendId::LOCAL);
let dest = window.and_then(|window| {
self.capture_directory_destination(crate::protocol::FrontendId::LOCAL, window)
});
let Some(dest) = dest else {
self.core.borrow_mut().status =
format!("cannot open {}: no document window", path.display());
return;
};
self.dispatch_directory_open(path, dest);
}
/// Run the directory resolver chain for `path`, then its fallback
/// (Journey Stage 1a, Q#JR7/Q#JR15).
///
/// Order is user chain first, builtin default second — see
/// `install_path_module` for why that cannot be expressed as two
/// hook subscriptions.
///
/// **A raising listener stops the chain AND suppresses the
/// fallback.** `run_short_circuit` returns `proceed = false` both
/// for a literal `false` (a claim) and for a raise, so `proceed`
/// alone already suppresses correctly; `errors` is what distinguishes
/// them, and it decides only whether to *report*. Running the
/// fallback after a user's resolver crashed would open dired on a
/// directory that resolver may have been part-way through handling,
/// so a crash is treated as a claim that failed — reported through
/// the `*errors*` buffer (which `run_hook` already does) and the
/// status line (which it does not), and visible in both.
pub(crate) fn dispatch_directory_open(
&mut self,
path: &std::path::Path,
dest: crate::editor_core::DirectoryDestination,
) {
let display = path.display().to_string();
let args = {
let lua = self.lua_host.lua();
let destination =
match lua.create_userdata(crate::lua_bindings::DirectoryDestinationLua(dest)) {
Ok(userdata) => mlua::Value::UserData(userdata),
Err(error) => {
self.core.borrow_mut().status = format!("cannot open {display}: {error}");
return;
}
};
let path_value = match lua.create_string(display.as_bytes()) {
Ok(string) => mlua::Value::String(string),
Err(error) => {
self.core.borrow_mut().status = format!("cannot open {display}: {error}");
return;
}
};
mlua::MultiValue::from_vec(vec![path_value, destination])
};
match self.lua_host.run_hook("path.open-directory", args.clone()) {
// A listener raised. `run_hook` has already appended the
// record to *errors*; add the status line, and do NOT fall
// back (Q#JR15).
Some(outcome) if !outcome.errors.is_empty() => {
self.core.borrow_mut().status =
format!("cannot open {display}: a path.open-directory listener failed");
return;
}
// Claimed: a listener returned false.
Some(outcome) if !outcome.proceed => return,
// Declined, or no listeners at all.
_ => {}
}
let handler = {
let lua = self.lua_host.lua();
lua.globals()
.get::<mlua::Table>("pmacs")
.and_then(|pmacs| pmacs.get::<mlua::Table>("path"))
.and_then(|path| path.get::<mlua::Value>("directory_handler"))
.unwrap_or(mlua::Value::Nil)
};
let mlua::Value::Function(handler) = handler else {
// The slot is clear: nothing surfaces directories. The
// session started fine and simply has nothing to show for
// the argument, so this is a status message and NOT a
// startup failure (Q#JR10).
self.core.borrow_mut().status = format!("no handler for directory {display}");
return;
};
if let Err(error) = handler.call::<()>(args) {
self.core.borrow_mut().status = format!("cannot open {display}: {error}");
}
}
/// Switch the active window to `buffer_id`. Returns silently on a
/// stale id.
///
/// **Corrected (Journey Stage 1a).** This comment previously claimed
/// it dropped "any old scratch buffer if the active window's
/// previous buffer has no other windows referencing it". It never
/// did: the body is one `switch_active_buffer` call, which reassigns
/// `aw.buffer_id` and removes nothing from the registry. The claim
/// was load-bearing enough that a framing decision (Q#JR3) and an
/// acceptance pin were written against it before anyone checked the
/// body. Removing the stale scratch may well be worth doing; it is
/// separate work, and this comment no longer promises it.
fn replace_active_buffer(&self, buffer_id: crate::buffer::BufferId) {
let mut core = self.core.borrow_mut();
let _ = core.switch_active_buffer(buffer_id);
@ -989,19 +1313,28 @@ impl EditorState {
.get(&frontend_id)
.is_some_and(|state| state.terminal_escape);
if let Some(view_key) = terminal_key {
// Q#TC4: the escape chord is per terminal, resolved through
// `terminal.escape-key` and cached on the session so this
// hot path parses at most once per (terminal, config epoch).
let escape_chord = self.terminal_escape_chord(view_key.buffer_id);
if escaped {
self.dispatchers
.entry(frontend_id)
.or_default()
.terminal_escape = false;
if chord.is_some_and(is_terminal_escape_chord) {
if chord == Some(escape_chord) {
// Q#TC4b: repeating the escape sends THAT chord to the
// child, not a hardcoded ETX. With a configured escape
// of `C-x`, sending Ctrl-C here would both surprise the
// user and make literal Ctrl-X unreachable, since the
// first press is always consumed as the escape.
self.claim_terminal_controller(view_key);
self.send_terminal_bytes(view_key.buffer_id, &[0x03]);
self.send_terminal_escape_literal(view_key, escape_chord);
return;
}
// The post-escape key starts a fresh ordinary sequence below.
} else if !dispatcher_pending {
if chord.is_some_and(is_terminal_escape_chord) {
if chord == Some(escape_chord) {
let state = self.dispatchers.entry(frontend_id).or_default();
state.terminal_escape = true;
state.dispatcher = KeyDispatcher::new();
@ -1117,6 +1450,54 @@ impl EditorState {
.then_some(key)
}
/// This terminal's effective escape chord (Q#TC4).
///
/// Resolution is `get("terminal.escape-key", terminal_buffer)` —
/// buffer-local, then global, then default — because unlike the two
/// open-time settings this one is read while the terminal exists, so
/// a per-terminal escape is expressible and supported (Q#TC2b).
///
/// The parse and the once-per-terminal invalid-value report both live
/// in [`crate::terminal::TerminalManager::escape_chord`]; this method
/// only supplies the resolved spelling and the epoch that keys the
/// cache, and surfaces any report through the status line — the same
/// channel `send_terminal_bytes` uses for terminal failures.
fn terminal_escape_chord(&self, buffer_id: crate::buffer::BufferId) -> Chord {
let lua = self.lua_host.lua();
let (spelling, epoch) = crate::lua_bindings::config_string_and_epoch(
lua,
"terminal.escape-key",
Some(buffer_id),
crate::terminal::DEFAULT_TERMINAL_ESCAPE_KEY,
);
let (chord, report) = self
.terminal_manager
.borrow_mut()
.escape_chord(buffer_id, epoch, &spelling);
if let Some(message) = report {
self.core.borrow_mut().status = message;
}
chord
}
/// Send the configured escape chord to the child as literal input
/// (Q#TC4b), through the same encoder ordinary keys use so it
/// inherits application-cursor and modifier handling.
fn send_terminal_escape_literal(&self, key: TerminalViewKey, chord: Chord) {
let event = KeyEvent::new(chord.code, chord.modifiers);
let Some((terminal_key, modifiers)) = terminal_key_from_crossterm(event) else {
return;
};
let modes = self
.terminal_manager
.borrow()
.modes_for_view(key)
.unwrap_or_default();
if let Some(bytes) = crate::terminal::input::encode_key(terminal_key, modifiers, modes) {
self.send_terminal_bytes(key.buffer_id, &bytes);
}
}
fn claim_terminal_controller(&self, key: TerminalViewKey) {
let mut manager = self.terminal_manager.borrow_mut();
let _ = manager.register_view(key);
@ -1341,9 +1722,16 @@ impl EditorState {
frontend_id: FrontendId,
buffer_id: crate::buffer::BufferId,
) -> Option<TerminalViewKey> {
// Bottom-panel §1.3 #6/#10/#11 — Projection. The full-window
// semantic terminal declaration, its snapshot/sync, and its
// frame suppression all describe the frontend's PRIMARY DOCUMENT
// surface, never a panel band: panel terminals get `PanelFrame`
// / `PanelPointer` in Stage 2B instead. Resolving through
// `view.active` would let a focused panel terminal both claim
// the document declaration and suppress the document pass.
let core = self.core.borrow();
let view = core.views.get(&frontend_id)?;
let window = core.windows.get(&view.active)?;
let win_id = core.primary_document_window(frontend_id)?;
let window = core.windows.get(&win_id)?;
if window.buffer_id != buffer_id {
return None;
}
@ -1472,6 +1860,16 @@ impl EditorState {
if coord.row >= size.rows || coord.col >= size.cols {
return false;
}
// Bottom-panel §1.3 #11 — Projection + focus. A non-hover
// gesture on the DOCUMENT terminal means "work here", so it
// takes focus back out of a panel before the gesture replays;
// bare hover neither focuses nor claims the controller.
if !matches!(kind, TerminalMouseKind::Move) {
let mut core = self.core.borrow_mut();
if let Some(win_id) = core.primary_document_window(frontend_id) {
core.focus_window(frontend_id, win_id);
}
}
self.core.borrow_mut().active_frontend = frontend_id;
self.apply_terminal_gesture(key, size, coord, kind, mods, (coord.row, coord.col));
true
@ -3152,6 +3550,170 @@ impl CompletionPopupKey {
}
}
/// Scroll one window so its cursor stays visible, reckoning in
/// **visible** lines when a fold map is supplied (Arc 6 Q#FD18).
///
/// Extracted from `paint_frame` for bottom-panel Stage 2 (Q#BP8): the
/// panel band runs this for its own window when that window owns focus,
/// against the same supplied map, and leaves a passive panel's
/// `view_top` untouched.
///
/// **The fold map is a parameter, never built here (Q#BP17).** A panel
/// painted for a frontend whose `fold_projection` is false must pass
/// `None`; `EditorCore::fold_map_for_window` is the wrong source there
/// because it gates on the **active** frontend, which is right for
/// command-time reckoning and wrong for painting another frontend's
/// panel.
fn prepare_window_cursor_visible(
window: &mut crate::window::Window,
buf: &crate::buffer::Buffer,
inner_rows: u32,
folds: Option<&crate::fold_view::VisibleLineMap>,
) {
let cursor_row = window
.text_view
.pos_to_display(buf, window.cursor)
.map_or(0, |d| d.row as usize);
match folds {
// The logical cursor may sit on a hidden line (a shared fold, or
// goto-line into one); the row that actually renders — and so
// the row to scroll to — is its visible head (Q#FD16/FD18,
// framing acceptance 8).
Some(map) => {
let anchor = map.visible_head_of(cursor_row);
let top = map.clamp_view_top(window.view_top);
window.view_top = if anchor < top {
anchor
} else if inner_rows > 0 && map.visible_rows_between(top, anchor) >= inner_rows as usize
{
map.nth_visible_back(anchor, inner_rows as usize - 1)
} else {
top
};
}
None => {
if cursor_row < window.view_top {
window.view_top = cursor_row;
} else if inner_rows > 0 && cursor_row >= window.view_top + inner_rows as usize {
window.view_top = cursor_row + 1 - inner_rows as usize;
}
}
}
}
/// Paint one window's document content: text, gutter, overlays,
/// selection, and its mode line.
///
/// Extracted from `paint_frame`'s per-window loop for bottom-panel
/// Stage 2 (Q#BP8) — the panel band paints its window into a
/// panel-sized grid at the same origin-agnostic `Viewport`, so this is
/// that body lifted out rather than a second painter. No concrete
/// text/gutter/overlay/mode-line painter forks (Bet B2').
///
/// **`folds` is a parameter, never built here (Q#BP17).** Folding's
/// "a semantic session never enters `paint_frame`" premise is what the
/// panel band breaks; the panel path passes `None` when the owning
/// frontend's `fold_projection` is false, and must not call
/// `EditorCore::fold_map_for_window`, which gates on the **active**
/// frontend.
#[allow(clippy::too_many_arguments)]
fn paint_window_content(
grid: &mut crate::cell::CellGrid<'_>,
window: &mut crate::window::Window,
buf: &crate::buffer::Buffer,
placement: WindowPlacement,
folds: Option<&crate::fold_view::VisibleLineMap>,
focused: bool,
theme: &crate::highlight::Theme,
statusline: Option<&crate::statusline::StatuslineWindowSegments>,
diag_store: &std::sync::Arc<std::sync::Mutex<crate::diag::DiagnosticStore>>,
) {
let rect = placement.outer;
let inner_rows = placement.content.size.rows;
if let Some(map) = folds {
window.view_top = map.clamp_view_top(window.view_top);
}
let viewport_buffer_start = window.text_view.line_offset(window.view_top).unwrap_or(0);
// UX gutter (Q#UX2): reserve a left strip for line numbers and
// shrink+shift the text area into the remainder, so every
// viewport-relative painter (text, syntax, diagnostics, search)
// stays gutter-agnostic. A window too narrow for the gutter falls
// back to no gutter this frame rather than starving the text.
let gutter_w = {
let w = window.gutter_width();
if w >= rect.size.cols { 0 } else { w }
};
let viewport = Viewport {
buffer_start: viewport_buffer_start,
buffer_end: buf.len(),
cell_origin: CellCoord::new(rect.origin.row, rect.origin.col + gutter_w),
cell_size: crate::cell::CellSize::new(inner_rows, rect.size.cols - gutter_w),
gutter_w,
folds,
};
// Composition (T M2.9): base text_view paints first, then the
// gutter numbers — before the overlays, so a diagnostic overlay
// can draw its severity sign into the gutter's leading column
// without the gutter's own blank pass erasing it — then each
// overlay in attach order. See [`crate::view::View`].
window.text_view.render(buf, viewport, grid);
if gutter_w > 0 {
paint_line_number_gutter(grid, window, &rect, inner_rows, gutter_w, folds, theme);
}
for overlay in &mut window.overlays {
overlay.render(buf, viewport, grid);
}
paint_local_selection(grid, buf, window, &rect, inner_rows, gutter_w, folds, theme);
// Mode line for this window. Painted last so the line
// itself is always visible regardless of overlay activity.
let coord = window
.text_view
.pos_to_display(buf, window.cursor)
.unwrap_or_default();
// Arc 6 Stage 2 (Q#FD18): All/Top/Bot/% are reckoned in
// VISIBLE-line space — a buffer whose remainder is collapsed
// reads "All", not "Top". The cursor's ordinal anchors on its
// visible head, since that is the row it renders on.
let (ind_top, ind_total, ind_cursor) = match folds {
Some(map) => (
map.visible_rows_between(0, window.view_top),
map.visible_line_count(window.text_view.line_count()),
map.visible_rows_between(0, map.visible_head_of(coord.row as usize)),
),
None => (
window.view_top,
window.text_view.line_count(),
coord.row as usize,
),
};
let scroll = format_scroll_indicator(ind_top, inner_rows as usize, ind_total, ind_cursor);
// Lock scoped to the summary computation only: the overlay
// renders above include `DiagnosticView`, which takes this
// same mutex — holding the guard across the loop deadlocked
// the daemon on the first frame after a file (and thus a
// diagnostic overlay) was opened.
let diags = {
let guard = diag_store.lock().expect("diag store mutex poisoned");
diag_mode_line_summary(&guard, buf)
};
let custom = statusline;
paint_mode_line(
grid,
&rect,
buf.name(),
buf.is_modified(),
focused,
coord.row,
coord.col,
&scroll,
&diags,
mode_line_style(theme),
custom.map_or(&[], |segments| segments.left.as_slice()),
custom.map_or(&[], |segments| segments.right.as_slice()),
theme,
);
}
/// Paint one full frame into `grid` and return the desired terminal
/// cursor position.
///
@ -3261,6 +3823,11 @@ pub fn paint_frame(
// Arc 6 Stage 2 (Q#FD18): the auto-scroll clamp reckons in
// VISIBLE lines. Built from the active window itself, before
// the mutable borrow below.
//
// Bottom-panel Q#BP17: built HERE and passed in, because the
// panel path (Stage 2B) must supply `None` for a frontend
// whose `fold_projection` is false. Building it inside the
// clamp would hard-wire the grid's answer.
let folds = core
.windows
.get(&active)
@ -3268,36 +3835,7 @@ pub fn paint_frame(
let aw = core.windows.get_mut(&active).expect(
"invariant: active_window_id always references a live window in core.windows",
);
let cursor_row = aw
.text_view
.pos_to_display(buf, aw.cursor)
.map_or(0, |d| d.row as usize);
match folds.as_ref() {
// The logical cursor may sit on a hidden line (a shared
// fold, or goto-line into one); the row that actually
// renders — and so the row to scroll to — is its visible
// head (Q#FD16/FD18, framing acceptance 8).
Some(map) => {
let anchor = map.visible_head_of(cursor_row);
let top = map.clamp_view_top(aw.view_top);
aw.view_top = if anchor < top {
anchor
} else if inner_rows > 0
&& map.visible_rows_between(top, anchor) >= inner_rows as usize
{
map.nth_visible_back(anchor, inner_rows as usize - 1)
} else {
top
};
}
None => {
if cursor_row < aw.view_top {
aw.view_top = cursor_row;
} else if inner_rows > 0 && cursor_row >= aw.view_top + inner_rows as usize {
aw.view_top = cursor_row + 1 - inner_rows as usize;
}
}
}
prepare_window_cursor_visible(aw, buf, inner_rows, folds.as_ref());
}
}
@ -3349,114 +3887,17 @@ pub fn paint_frame(
let Ok(buf) = reg.get(window.buffer_id) else {
continue;
};
// Arc 6 Stage 2 (Q#FD12, round-2 F2): ONE visible-line map per
// rendered document window, keyed on that window's own buffer and
// line offsets. A split may show different buffers with only one
// folded, so a per-frame singleton would leak one pane's folds
// into the other. `None` when this buffer has no folds — the
// unfolded path then paints exactly as before.
let folds = crate::fold_view::map_for_window(&state.fold_registry, window);
// `view_top` stays a source-line index (Bet B5) but must never
// rest on a hidden line: clamp BACKWARD so a fold at the top of
// the viewport shows its head (Q#FD18, acceptance 8).
if let Some(map) = folds.as_ref() {
window.view_top = map.clamp_view_top(window.view_top);
}
let viewport_buffer_start = window.text_view.line_offset(window.view_top).unwrap_or(0);
// UX gutter (Q#UX2): reserve a left strip for line numbers and
// shrink+shift the text area into the remainder, so every
// viewport-relative painter (text, syntax, diagnostics, search)
// stays gutter-agnostic. A window too narrow for the gutter falls
// back to no gutter this frame rather than starving the text.
let gutter_w = {
let w = window.gutter_width();
if w >= rect.size.cols { 0 } else { w }
};
let viewport = Viewport {
buffer_start: viewport_buffer_start,
buffer_end: buf.len(),
cell_origin: CellCoord::new(rect.origin.row, rect.origin.col + gutter_w),
cell_size: crate::cell::CellSize::new(inner_rows, rect.size.cols - gutter_w),
gutter_w,
folds: folds.as_ref(),
};
// Composition (T M2.9): base text_view paints first, then the
// gutter numbers — before the overlays, so a diagnostic overlay
// can draw its severity sign into the gutter's leading column
// without the gutter's own blank pass erasing it — then each
// overlay in attach order. See [`crate::view::View`].
window.text_view.render(buf, viewport, grid);
if gutter_w > 0 {
paint_line_number_gutter(
grid,
window,
&rect,
inner_rows,
gutter_w,
folds.as_ref(),
&theme,
);
}
for overlay in &mut window.overlays {
overlay.render(buf, viewport, grid);
}
paint_local_selection(
paint_window_content(
grid,
buf,
window,
&rect,
inner_rows,
gutter_w,
buf,
placement,
folds.as_ref(),
&theme,
);
// Mode line for this window. Painted last so the line
// itself is always visible regardless of overlay activity.
let coord = window
.text_view
.pos_to_display(buf, window.cursor)
.unwrap_or_default();
// Arc 6 Stage 2 (Q#FD18): All/Top/Bot/% are reckoned in
// VISIBLE-line space — a buffer whose remainder is collapsed
// reads "All", not "Top". The cursor's ordinal anchors on its
// visible head, since that is the row it renders on.
let (ind_top, ind_total, ind_cursor) = match folds.as_ref() {
Some(map) => (
map.visible_rows_between(0, window.view_top),
map.visible_line_count(window.text_view.line_count()),
map.visible_rows_between(0, map.visible_head_of(coord.row as usize)),
),
None => (
window.view_top,
window.text_view.line_count(),
coord.row as usize,
),
};
let scroll = format_scroll_indicator(ind_top, inner_rows as usize, ind_total, ind_cursor);
// Lock scoped to the summary computation only: the overlay
// renders above include `DiagnosticView`, which takes this
// same mutex — holding the guard across the loop deadlocked
// the daemon on the first frame after a file (and thus a
// diagnostic overlay) was opened.
let diags = {
let guard = diag_store.lock().expect("diag store mutex poisoned");
diag_mode_line_summary(&guard, buf)
};
let custom = statusline_by_window.get(id);
paint_mode_line(
grid,
&rect,
buf.name(),
buf.is_modified(),
*id == active,
coord.row,
coord.col,
&scroll,
&diags,
mode_line_style(&theme),
custom.map_or(&[], |segments| segments.left.as_slice()),
custom.map_or(&[], |segments| segments.right.as_slice()),
&theme,
statusline_by_window.get(id),
&diag_store,
);
}
drop(reg);
@ -4421,10 +4862,6 @@ fn sanitize_single_line(s: &str) -> String {
.collect()
}
fn is_terminal_escape_chord(chord: Chord) -> bool {
chord.code == KeyCode::Char('c') && chord.modifiers == KeyModifiers::CONTROL
}
fn terminal_key_from_crossterm(key: KeyEvent) -> Option<(TerminalKey, TerminalModifiers)> {
let modifiers = crate::protocol::crossterm_translate::mods_from_crossterm(key.modifiers);
let key = crate::protocol::crossterm_translate::keycode_from_crossterm(key.code);

View File

@ -94,6 +94,77 @@ pub enum HookKind {
None,
}
/// What a path resolved to (Journey Stage 1a, Q#JR5).
///
/// A sum type rather than `(Option<BufferId>, HookKind)`: that pair
/// admits three states that cannot occur (`None` with `AfterLoad`,
/// `Some` with a directory, …), and every caller would have to
/// re-establish by hand which combinations are real.
///
/// **Do not confuse [`HookKind`] here with [`crate::hook::HookKind`]** —
/// unrelated types sharing a name. This one says *which* lifecycle hook
/// to fire; that one says how a hook's callbacks fan out. Every site
/// touching both writes them path-qualified (Q#JR5b).
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ResolvedTarget {
/// A file buffer, plus the hook the caller must fire with the
/// destination window active.
Buffer {
/// The resolved buffer.
id: BufferId,
/// Which lifecycle hook this resolution owes.
fire: HookKind,
},
/// A directory. No buffer is created (Q#JR6) — the directory
/// resolver chain decides what surfaces it, and dired builds its own
/// buffer through `claim_handle` rather than adopting one.
///
/// `path` is **normalized** — absolute, tilde-expanded, lexically
/// clean. This is not free and must not be assumed: normalization
/// otherwise happens inside [`Self::set_buffer_path`], which never
/// runs on this arm, so a caller resolving `"."` would keep `"."`
/// (Q#JR8). A handler keying state by path needs the canonical form.
Directory {
/// The normalized directory path.
path: PathBuf,
},
}
/// Where a directory open was requested, captured **synchronously** at
/// resolve time (Journey Stage 1a, Q#JR14).
///
/// The listing that satisfies a directory open is asynchronous
/// (`pmacs.fs.read_dir` is worker-dispatched and must be awaited), so the
/// code that finally builds and displays the listing runs a tick or more
/// later — outside interactive dispatch, where `pmacs.window.*` acts on
/// the *ambient* frontend by documented design (`builtin/runtime/dired.lua`).
/// Without a captured destination, a second frontend dispatching in the
/// meantime silently redirects the listing.
///
/// All three fields are load-bearing:
///
/// * `frontend` — the scope the commit must run in.
/// * `window` — the exact destination; the ambient selected window is
/// not it.
/// * `buffer` — what that window held at capture time, so **stale
/// intent loses to the user** (Q#JR14c). A user who replaced the
/// buffer while the listing was in flight is newer information than
/// the launch argument, and must not be overwritten.
///
/// Exposed to Lua only as nonconstructible userdata (Q#JR14d): as a
/// table, the *same* value is handed to every resolver listener in turn,
/// so one could mutate it and then decline — redirecting later listeners
/// — and any Lua could fabricate a plausible triple.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct DirectoryDestination {
/// Frontend that requested the directory.
pub frontend: FrontendId,
/// Window the listing must land in.
pub window: WindowId,
/// Buffer that window held at capture time (stale-intent check).
pub buffer: BufferId,
}
/// A `display_buffer` request (Q#BP3).
///
/// `height` and `dedicated` are deliberately option-valued at the policy
@ -880,18 +951,41 @@ impl EditorCore {
/// One primitive, so two path-normalization, dedup, and hook
/// transactions cannot drift apart.
///
/// A **directory** resolves to [`ResolvedTarget::Directory`] before
/// any load is attempted (Journey Stage 1a, Q#JR5/Q#JR6). Without
/// that arm the load runs and fails: `File::open` succeeds on a
/// directory and `read_to_end` then returns `EISDIR`, which is not
/// `NotFound`, so the `[new file]` arm never fires and every caller
/// saw a hard error — the reason `pmacs .` exited 1 and the golden
/// journey was graded broken at step 3 (`COHERENCE.md` §2).
///
/// # Errors
/// Any load failure other than `NotFound`.
pub fn resolve_target_buffer(&mut self, path: &Path) -> Result<(BufferId, HookKind), String> {
pub fn resolve_target_buffer(&mut self, path: &Path) -> Result<ResolvedTarget, String> {
// Ahead of the load, deliberately: see the EISDIR note above.
if path.is_dir() {
return Ok(ResolvedTarget::Directory {
path: normalize_buffer_path(path.to_path_buf()),
});
}
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)),
Ok((id, true)) => Ok(ResolvedTarget::Buffer {
id,
fire: HookKind::AfterLoad,
}),
Ok((id, false)) => Ok(ResolvedTarget::Buffer {
id,
fire: 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))
Ok(ResolvedTarget::Buffer {
id: buffer_id,
fire: HookKind::None,
})
}
Err(error) => Err(format!("cannot open {}: {error}", path.display())),
}
@ -3472,16 +3566,56 @@ impl EditorCore {
fid: FrontendId,
existing: Option<BufferId>,
window: Option<WindowId>,
) -> Result<WindowId, String> {
self.probe_display_target_inner(fid, existing, window)
}
/// Whether `window` will accept `incoming` as its buffer — the one
/// dedication rule, shared by every consumer (Journey Stage 1a,
/// Q#JR14f).
///
/// A dedicated window refuses anything other than what it already
/// shows; an undedicated one accepts anything. `incoming` is
/// deliberately optional, and the `None` case is not a degenerate
/// spelling of "don't care" — it means **the replacement buffer does
/// not exist yet**, and a dedicated window must therefore be treated
/// as ineligible:
///
/// | caller | `incoming` | dedicated window |
/// |---|---|---|
/// | [`Self::display_buffer`] exact-target arm | `Some(request.buffer_id)` | eligible only when already showing it |
/// | [`Self::probe_display_target`] | its existing-buffer result | preserves the load-before-placement probe |
/// | `commit_to` preflight | `None` | always ineligible |
///
/// `commit_to` passes `None` because a directory open's destination
/// is validated *before* the handler builds its buffer. Passing the
/// captured bootstrap buffer instead would approve a window
/// dedicated to *that* buffer, the handler would then claim and paint
/// a different one, and the exact display would refuse afterwards —
/// after the mutations the preflight exists to prevent.
///
/// Extracted rather than reimplemented per caller: two copies of a
/// rule that must agree is exactly the drift this stage's
/// path-resolution unification exists to close, and a future
/// eligibility rule added to only one copy would reopen it.
#[must_use]
pub fn window_accepts_buffer(&self, window: WindowId, incoming: Option<BufferId>) -> bool {
self.windows.get(&window).is_some_and(|w| {
!w.params.dedicated || incoming.is_some_and(|buffer_id| w.buffer_id == buffer_id)
})
}
fn probe_display_target_inner(
&self,
fid: FrontendId,
existing: Option<BufferId>,
window: Option<WindowId>,
) -> Result<WindowId, String> {
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)
})
};
let eligible = |id: WindowId| self.window_accepts_buffer(id, existing);
if let Some(target) = window {
if !view.layout.iter_ids().contains(&target) {
return Err(format!(
@ -3563,7 +3697,7 @@ impl EditorCore {
.windows
.get(&target)
.ok_or_else(|| format!("display: window {} is not live", target.raw()))?;
if window.params.dedicated && window.buffer_id != request.buffer_id {
if !self.window_accepts_buffer(target, Some(request.buffer_id)) {
return Err(format!(
"display: window {} is dedicated to another buffer",
target.raw()
@ -5300,6 +5434,45 @@ mod tests {
assert!(s.active_window_for(FrontendId::LOCAL).is_some());
}
/// Journey Stage 1a (Q#JR14f): the three decisive rows of the shared
/// eligibility predicate.
///
/// The `None` row is the one that exists for `commit_to`, and it is
/// not a "don't care": a directory open validates its destination
/// *before* the handler creates the buffer that will land there, so
/// there is no incoming id to compare and a dedicated window must be
/// refused. Approving it would let the handler claim and paint, and
/// the display would refuse afterwards — after the mutations the
/// preflight exists to prevent.
#[test]
fn window_accepts_buffer_matrix() {
let mut s = fresh();
let window = s.views[&FrontendId::LOCAL].active;
let current = s.windows[&window].buffer_id;
let other = s.registry.borrow_mut().create(String::from("other"));
// Undedicated: accepts anything, including "not decided yet".
assert!(s.window_accepts_buffer(window, Some(current)));
assert!(s.window_accepts_buffer(window, Some(other)));
assert!(s.window_accepts_buffer(window, None));
s.windows.get_mut(&window).expect("live").params.dedicated = true;
// Dedicated: only what it already shows.
assert!(
s.window_accepts_buffer(window, Some(current)),
"a dedicated window still accepts the buffer it displays"
);
assert!(
!s.window_accepts_buffer(window, Some(other)),
"a dedicated window refuses a different buffer"
);
assert!(
!s.window_accepts_buffer(window, None),
"a dedicated window refuses an as-yet-unbuilt replacement"
);
}
#[test]
fn register_and_unregister_frontend_view() {
// T M10.8 — the lifecycle API the dispatcher uses on attach

View File

@ -439,6 +439,11 @@ impl Frontend {
// Q#GT4 — this pre-window semantic bootstrap result cannot
// legitimately reach the grid TUI.
| InstanceMessage::InitialTargetResult(_)
// Q#BP15 — the panel band is painted by the GPU frontend;
// the grid TUI renders its side windows through the cell
// grid and negotiates no panel capability, so this cannot
// legitimately reach here.
| InstanceMessage::PanelFrame(_)
| InstanceMessage::ResourceOffer { .. }
// T M11.6 — DispatchIdle is consumed by `attach.rs`'s
// optimistic-apply gate; if any reaches this render path

View File

@ -668,6 +668,33 @@ pub fn config_u32(lua: &Lua, name: &str, buffer_id: Option<BufferId>, fallback:
}
}
/// Read a `String` setting plus the registry epoch that keys any cache
/// built from it (Q#TC4c).
///
/// The epoch is returned WITH the value deliberately: a caller caching a
/// parsed form needs both, and reading them in two calls would let a
/// `set` land between them and produce a cache stamped with the wrong
/// epoch. `fallback` covers a bare core whose runtime never defined the
/// setting, matching [`config_u32`].
#[must_use]
pub fn config_string_and_epoch(
lua: &Lua,
name: &str,
buffer_id: Option<BufferId>,
fallback: &str,
) -> (String, u64) {
let Some(registry) = lua.app_data_ref::<config::SharedConfigRegistry>() else {
return (fallback.to_owned(), 0);
};
let borrowed = registry.borrow();
let epoch = borrowed.value_epoch();
let value = match borrowed.get(name, buffer_id) {
Ok(crate::config_registry::ConfigValue::Str(v)) => v.clone(),
_ => fallback.to_owned(),
};
(value, epoch)
}
/// Short-circuit a binding when the init phase has completed.
///
/// Lifecycle-affecting Lua APIs (currently just `pmacs.attach`; M5.6d+)
@ -3038,6 +3065,36 @@ fn install_buffer_module(lua: &Lua, registry: &SharedRegistry) -> mlua::Result<T
)?;
}
{
let reg = registry.clone();
buffer.set(
// Replace a generated buffer's contents and leave it genuinely
// immutable — the one authorized door through `read_only`.
//
// Deliberately NOT an exposed `set_read_only`: that would let a
// caller lock a buffer with no way to refresh it, which is the
// failure mode that kept generated-buffer immutability deferred.
// Pairing the lock with the write in a single call is what makes
// it safe to ship.
"set_generated_contents",
lua.create_function(move |lua, (id, text): (BufferIdLua, mlua::String)| {
let edit = {
let mut registry = reg.borrow_mut();
let buffer = registry.get_mut(id.0).map_err(mlua::Error::external)?;
buffer
.set_generated_contents(&text.as_bytes())
.map_err(mlua::Error::external)?
};
// The registry borrow is released first: the fan-out
// re-enters the core, and a live borrow would panic.
// Skipping it is not an option — see
// `Buffer::set_generated_contents`.
notify_buffer_edit_to_windows(lua, id.0, &edit);
Ok(())
})?,
)?;
}
{
let reg = registry.clone();
buffer.set(
@ -3593,9 +3650,74 @@ fn install_path_module(lua: &Lua) -> mlua::Result<Table> {
)
})?,
)?;
// Journey Stage 1a (Q#JR7): the directory fallback.
//
// The resolver for a directory open is a two-tier arrangement, and
// the split is forced by how registration works rather than chosen
// for elegance. `path.open-directory` is a short-circuit hook that
// **no builtin subscribes to** — because `HookRegistry::add` only
// appends and builtins load before `init.lua`, a subscribing builtin
// would always claim first and no user listener could ever run. So
// the hook is the user's chain, and the default surface is this
// slot, consulted only when the chain declines.
//
// A slot, not a `pmacs.config` setting: `ConfigValue` is four
// scalars and a handler is none of them (the same reason terminal
// profiles could not be settings). It is an UNOWNED singleton —
// last writer wins, no owning package, no `SourceLocation`, no
// removal lifecycle, absent from every inspection surface. That is a
// real `COHERENCE.md` §13 gap, recorded rather than dressed up: when
// §20 Priority 3 lands registration ownership and `hook.remove`,
// this becomes an ordinary lowest-priority subscription carrying its
// owner and this slot is deleted rather than extended.
//
// Readable as `pmacs.path.directory_handler` so a replacement can
// capture and chain to the previous one; `nil` disables directory
// opening entirely, which is what makes that path testable.
path.set("directory_handler", mlua::Value::Nil)?;
path.set(
"set_directory_handler",
lua.create_function(|lua, handler: mlua::Value| {
match &handler {
mlua::Value::Nil | mlua::Value::Function(_) => {}
other => {
return Err(mlua::Error::runtime(format!(
"pmacs.path.set_directory_handler: expected a function or nil, got {}",
other.type_name()
)));
}
}
let pmacs: Table = lua.globals().get("pmacs")?;
let path: Table = pmacs.get("path")?;
path.set("directory_handler", handler)?;
Ok(())
})?,
)?;
Ok(path)
}
/// Lua handle for a captured directory destination (Q#JR14d).
///
/// Deliberately **nonconstructible from Lua** and read-only. The same
/// value is passed to every `path.open-directory` listener in turn: as a
/// table, an earlier listener could mutate it and then decline,
/// redirecting later listeners or the fallback to a window the user
/// never asked for — and any Lua could fabricate a plausible
/// frontend/window/buffer triple and hand it to `commit_to`. Userdata
/// with no constructor and no setters makes both unrepresentable rather
/// than merely discouraged.
///
/// The single accessor exists because dired needs the exact window for
/// its `display{window = …}` target; nothing needs the frontend or the
/// captured buffer, which stay private to the preflight.
pub(crate) struct DirectoryDestinationLua(pub(crate) crate::editor_core::DirectoryDestination);
impl mlua::UserData for DirectoryDestinationLua {
fn add_methods<M: mlua::UserDataMethods<Self>>(methods: &mut M) {
methods.add_method("window", |_, this, ()| Ok(this.0.window.raw()));
}
}
/// Build the `pmacs.ansi.*` table. The only entry today is
/// `parser()`; future additions (e.g. an event-table-validator
/// helper) live alongside it.
@ -6594,6 +6716,54 @@ pub fn install_async(
) -> mlua::Result<()> {
lua.set_app_data(runtime.clone());
let pmacs: Table = lua.globals().get("pmacs")?;
// Arc 8 Stage 3a (framing Q#LN20): the one *synchronous* filesystem
// primitive Lua has. `pmacs.fs` is otherwise an async, handle-
// returning surface built in `builtin/runtime/fs.lua`, so this
// arrives through a private table that file re-exports rather than
// joining the `_dispatch_fs_*` family it would not belong to.
//
// Installed here, alongside those dispatchers, purely for load
// order: `make_async_runtime` runs before `fs.lua` is evaluated,
// whereas `install_project` — the other plausible home — runs after
// it, so a canonicalizer placed there is nil when `fs.lua` reads it.
//
// Synchronous on purpose, and that is the whole point. The consumer
// is a function-valued `pmacs.lsp.config[lang].root`, which
// `project_root_for` calls from `ensure_server` <- `attach_buffer`
// <- the `buffer.after-load` hook — no coroutine, nothing to await
// on. An awaitable canonicalizer would be unusable there for exactly
// the reason `pmacs.fs.stat` already is, leaving #161's
// canonical-root obligation undischarged. The cost is one syscall on
// a path the editor is already opening; `pmacs.project.detect`
// canonicalizes synchronously on the same hook today.
{
let fs_priv = lua.create_table()?;
fs_priv.set(
"canonicalize",
lua.create_function(|_, path: String| {
// nil rather than an error for a path that cannot be
// resolved: asking about a deleted file or a broken
// symlink is ordinary, and raising would surface through
// `resolve_root_fn`'s pcall as a config bug, which it is
// not.
//
// `to_str`, NOT `display()`. A resolution that lands on
// non-UTF-8 bytes has no faithful string form, and
// `display()` would substitute U+FFFD and hand back a
// path that does not exist on disk — strictly worse than
// nil here, because this value becomes a server-affinity
// key via `file_uri_for` and would silently fail to
// round-trip. Unrepresentable is a decline, matching how
// the fs layer already treats non-UTF-8 symlink targets.
Ok(std::fs::canonicalize(&path)
.ok()
.and_then(|p| p.to_str().map(str::to_owned)))
})?,
)?;
pmacs.set("_fs", fs_priv)?;
}
let async_mod = lua.create_table()?;
{
@ -6833,6 +7003,26 @@ pub fn install_async(
)?;
}
// Journey Stage 1a (Q#JR14b): `pmacs.window.commit_to` runs its
// callback inside a Rust-stack RAII scope. Yielding out of that
// scope would let the guard's dynamic extent and the coroutine's
// suspension diverge — the guard would restore the frontend override
// while the continuation is still parked, so the rest of the commit
// would silently run ambient again, which is the exact bug the scope
// exists to prevent. `Handle:await` therefore refuses inside it.
//
// Enforced here rather than documented in the framing, because a
// rule that only exists in prose is one a future caller breaks
// without noticing.
async_mod.set(
"_in_commit_scope",
lua.create_function(|lua, ()| {
Ok(lua
.app_data_ref::<crate::editor::CommitScopeActive>()
.is_some_and(|scope| scope.active()))
})?,
)?;
{
let rt = runtime.clone();
async_mod.set(
@ -8809,6 +8999,25 @@ fn install_terminal(
)?;
}
{
let manager = manager.clone();
terminal.set(
"_copy_retained",
// Q#TC7: returns the whole retained range as a string, through
// the same serializer selection-copy uses. Takes an explicit
// buffer rather than resolving the active view, because copy
// mode reads a terminal that may not be displayed — and
// because the caller already holds the handle it keyed its
// snapshot on.
lua.create_function(move |lua, buffer: BufferIdLua| {
let Some(bytes) = manager.borrow().copy_retained(buffer.0) else {
return Ok(None);
};
Ok(Some(lua.create_string(&bytes)?))
})?,
)?;
}
pmacs.set("terminal", terminal)
}

View File

@ -44,8 +44,22 @@ use crate::window::{DEFAULT_PANEL_ROWS, MIN_WINDOW_OUTER_ROWS, Side, WindowId};
/// 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::<crate::editor::InteractiveCommandOrigin>()
.and_then(|origin| origin.current())
// Journey Stage 1a (Q#JR14e): the background scope wins.
//
// Order is deliberate — scoped override, then interactive origin,
// then ambient. A `commit_to` callback runs for the frontend that
// *requested* the work, and it must win over whatever happens to be
// dispatching when the worker settles. It is a separate slot rather
// than a reuse of the interactive origin because that origin is
// authenticated user-command authority (the pre-edit unfold guard,
// command-boundary rotation, and the terminal surface all key off
// it), and a background continuation must not acquire it.
lua.app_data_ref::<crate::editor::ScopedFrontend>()
.and_then(|scope| scope.current())
.or_else(|| {
lua.app_data_ref::<crate::editor::InteractiveCommandOrigin>()
.and_then(|origin| origin.current())
})
.unwrap_or_else(|| core.borrow().active_frontend_key())
}
@ -350,6 +364,123 @@ pub(crate) fn finish_adopter_placement(
a coherent surface"
)]
pub(crate) fn install(lua: &Lua, core: &SharedCore, win: &Table) -> mlua::Result<()> {
{
let cc = core.clone();
win.set(
"commit_to",
lua.create_function(
move |lua,
(dest, body): (mlua::Value, mlua::Function)|
-> mlua::Result<mlua::MultiValue> {
// Journey Stage 1a (Q#JR14). Preflight FIRST, then
// scope, then run. The ordering is the whole point:
// an async handler mutates real state (dired claims
// a buffer, registers a handle, captures `prev`, and
// paints) long before it reaches any call that could
// refuse. Validating at display time is four
// mutations too late and leaves a hidden buffer
// behind, so every destination precondition is
// checked before the callback is invoked at all.
//
// Typed as `Value` rather than `AnyUserData` so this
// message is REACHABLE: with the narrower type mlua
// rejects a table during argument conversion, and a
// caller who fabricated one got "error converting Lua
// table to userdata" — true, but it names neither the
// rule nor how to get a real destination.
let dest = match &dest {
mlua::Value::UserData(userdata) => {
userdata.borrow::<super::DirectoryDestinationLua>().ok()
}
_ => None,
};
let dest = dest
.ok_or_else(|| {
mlua::Error::runtime(
"pmacs.window.commit_to: expected a destination captured by \
the editor (it cannot be constructed from Lua)",
)
})?
.0;
// 1. The requesting frontend still has a layout.
let refusal = {
let core = cc.borrow();
if !core.views.contains_key(&dest.frontend) {
Some("requesting frontend is gone".to_string())
} else if !core
.views
.get(&dest.frontend)
.is_some_and(|view| view.layout.iter_ids().contains(&dest.window))
{
// 2. The destination window is still live in it.
Some(format!("window {} is gone", dest.window.raw()))
} else if core
.windows
.get(&dest.window)
.is_some_and(|w| w.buffer_id != dest.buffer)
{
// 3. Stale intent (Q#JR14c): the user
// replaced the buffer while the work was
// in flight. Their action is newer
// information than the request, so the
// request loses.
Some(format!(
"window {} now shows another buffer",
dest.window.raw()
))
} else if !core.window_accepts_buffer(dest.window, None) {
// 4. Replaceability (Q#JR14f). `None`
// because the replacement does not exist
// yet — passing the captured buffer would
// approve a window dedicated to *it*, and
// the handler's different buffer would be
// refused later, after mutating.
Some(format!("window {} is dedicated", dest.window.raw()))
} else {
None
}
};
if let Some(reason) = refusal {
let mut out = mlua::MultiValue::new();
out.push_back(mlua::Value::String(lua.create_string(reason.as_bytes())?));
out.push_front(mlua::Value::Boolean(false));
return Ok(out);
}
let scope = lua
.app_data_ref::<crate::editor::ScopedFrontend>()
.ok_or_else(|| {
mlua::Error::runtime(
"pmacs.window.commit_to: no frontend scope installed",
)
})?
.clone();
let commit = lua
.app_data_ref::<crate::editor::CommitScopeActive>()
.ok_or_else(|| {
mlua::Error::runtime(
"pmacs.window.commit_to: no commit scope installed",
)
})?
.clone();
// Both the override and the core's ambient
// `active_frontend` are restored when this guard
// drops -- on the normal return AND on a raising
// callback, which is why the result is captured
// rather than `?`-propagated through the drop.
let result = {
let _guard = scope.enter(&cc, &commit, dest.frontend);
body.call::<mlua::MultiValue>(())
};
let mut out = result?;
out.push_front(mlua::Value::Boolean(true));
Ok(out)
},
)?,
)?;
}
{
let cc = core.clone();
win.set(
@ -397,10 +528,33 @@ pub(crate) fn install(lua: &Lua, core: &SharedCore, win: &Table) -> mlua::Result
.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
//
// Journey Stage 1a (Q#JR13): a DIRECTORY raises here
// and does NOT enter the directory resolver chain.
// `display_file` is "put this file in a window", not
// a CLI router — and `find-file`'s accept arm
// (`builtin/commands/default.lua`) wraps this call in
// a `pcall` whose comment guarantees that "only a
// real failure (a directory, a permission error)
// reaches here", pinned by
// `find_file_accepting_a_directory_reports_instead_of_raising`.
// Routing it into dired would silently change what
// `C-x C-f` on a directory does. Opening dired from
// find-file is a named deferral, not a side effect of
// the CLI work.
let (buffer_id, fire) = match cc
.borrow_mut()
.resolve_target_buffer(&path_buf)
.map_err(mlua::Error::runtime)?;
.map_err(mlua::Error::runtime)?
{
crate::editor_core::ResolvedTarget::Buffer { id, fire } => (id, fire),
crate::editor_core::ResolvedTarget::Directory { path } => {
return Err(mlua::Error::runtime(format!(
"pmacs.window.display_file: {} is a directory",
path.display()
)));
}
};
// 4. Enter Q#BP4's transaction, so any hook observes
// the DOCUMENT TARGET as active.
let mut request = DisplayRequest::new(buffer_id);

View File

@ -470,6 +470,13 @@ pub struct ProcessSupervisor {
/// TERM→KILL window used when arming the ledger. Constant
/// [`GROUP_TERM_GRACE`] in production; overridable in tests.
group_term_grace: Duration,
/// Q#PD4 test seam: forces the next `kill(2)` attempt in
/// [`Self::signal`] to fail with this errno, consumed once.
/// Always `None` in production — there is no way to set it outside
/// `cfg(test)`. It replaces the *kill result only*, so the leader
/// observation still runs against the real child handle; a stubbed
/// observation would bypass the code path under test.
forced_kill_errno: Option<nix::errno::Errno>,
}
/// One armed group in the reap ledger.
@ -684,7 +691,50 @@ impl ChildHandle {
}
}
fn signal_target(proc: &ManagedProcess, pid: u32) -> Result<Pid, String> {
/// Which branch of [`signal_target`] chose the target (Q#PD1).
///
/// Recorded on failure because the branches differ in what a failing
/// `kill` can possibly mean: only [`Self::LeaderPid`] aims at the
/// spawned child itself. The other two aim at a *group*, which for a
/// PTY is read from the terminal and can belong to something the
/// supervisor never spawned.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TargetSource {
/// The tty's current foreground process group, read at signal
/// time. Diverges from the leader exactly when job control has
/// moved the terminal.
ForegroundGroup,
/// A `group = true` pipe child leading its own process group.
SpawnGroup,
/// The child's own pid.
LeaderPid,
}
impl TargetSource {
fn as_str(self) -> &'static str {
match self {
Self::ForegroundGroup => "tcgetpgrp",
Self::SpawnGroup => "group",
Self::LeaderPid => "leader-pid",
}
}
/// Whether the target is a process group rather than one process.
fn is_group(self) -> bool {
matches!(self, Self::ForegroundGroup | Self::SpawnGroup)
}
}
/// The entity a signal was actually aimed at, plus the branch that
/// chose it. Carried so a failure can report the target as a fact
/// separate from the leader's state (Q#PD1).
#[derive(Debug, Clone, Copy)]
struct SignalTarget {
pid: Pid,
source: TargetSource,
}
fn signal_target(proc: &ManagedProcess, pid: u32) -> Result<SignalTarget, String> {
if let Some(runtime) = proc.runtime.as_ref()
&& let ChildHandle::Pty {
_master: master, ..
@ -692,7 +742,10 @@ fn signal_target(proc: &ManagedProcess, pid: u32) -> Result<Pid, String> {
&& let Some(pgrp) = master.process_group_leader()
&& pgrp > 0
{
return Ok(Pid::from_raw(-pgrp));
return Ok(SignalTarget {
pid: Pid::from_raw(-pgrp),
source: TargetSource::ForegroundGroup,
});
}
// `group = true` pipe children lead a fresh process group
// (`process_group(0)` at spawn ⇒ pgid == pid), so fatal signals
@ -700,11 +753,80 @@ fn signal_target(proc: &ManagedProcess, pid: u32) -> Result<Pid, String> {
// (Q#CM3).
if proc.spec.group {
let pgid = i32::try_from(pid).map_err(|e| e.to_string())?;
return Ok(Pid::from_raw(-pgid));
return Ok(SignalTarget {
pid: Pid::from_raw(-pgid),
source: TargetSource::SpawnGroup,
});
}
Ok(Pid::from_raw(
i32::try_from(pid).map_err(|e| e.to_string())?,
))
Ok(SignalTarget {
pid: Pid::from_raw(i32::try_from(pid).map_err(|e| e.to_string())?),
source: TargetSource::LeaderPid,
})
}
/// The spawned leader's state at the moment a `kill` failed (Q#PD1).
///
/// Deliberately reported *beside* the target rather than folded into a
/// verdict: for a PTY the two are different entities whenever job
/// control has moved the terminal, and three successive designs for
/// this code were unsound precisely because they collapsed them.
enum LeaderObservation {
Exited(TermStatus),
Live,
Unobservable(String),
NoRuntime,
}
impl LeaderObservation {
fn render(&self) -> String {
match self {
Self::Exited(TermStatus::Exited(code)) => format!("exited(code {code})"),
Self::Exited(TermStatus::Signaled(sig)) => format!("exited(signal {sig})"),
Self::Live => "live".to_owned(),
Self::Unobservable(e) => format!("unobservable({e})"),
Self::NoRuntime => "no-runtime".to_owned(),
}
}
}
/// Observe the spawned leader. Note this *reaps* an exited child and
/// caches its status; that is why Q#PD3 claims "no disposition change"
/// rather than "strictly additive", and why an event-count test pins
/// that `poll_one` still emits exactly one exit event afterwards.
fn observe_leader(proc: &mut ManagedProcess) -> LeaderObservation {
let Some(runtime) = proc.runtime.as_mut() else {
return LeaderObservation::NoRuntime;
};
match runtime.child.try_wait() {
Ok(Some(status)) => LeaderObservation::Exited(status),
Ok(None) => LeaderObservation::Live,
Err(e) => LeaderObservation::Unobservable(e),
}
}
/// Render a failing `kill` as the five facts of Q#PD1. The disposition
/// is unchanged (Q#PD2) — this only replaces a message that said
/// nothing but the errno.
fn signal_failure_report(
target: SignalTarget,
leader_pid: u32,
errno: nix::errno::Errno,
leader: &LeaderObservation,
) -> String {
let expected = if target.source.is_group() {
match i32::try_from(leader_pid) {
Ok(p) => format!(", expected_group=-{p}"),
Err(_) => String::new(),
}
} else {
String::new()
};
format!(
"kill: {errno} (target={} via {}, leader_pid={leader_pid}{expected}, leader={})",
target.pid.as_raw(),
target.source.as_str(),
leader.render(),
)
}
/// Termination status of one generation. Internal --- the supervisor
@ -807,9 +929,20 @@ impl ProcessSupervisor {
shut_down: false,
reap_ledger: HashMap::new(),
group_term_grace: GROUP_TERM_GRACE,
forced_kill_errno: None,
}
}
/// Q#PD4 test seam: make the next `kill(2)` attempt in
/// [`Self::signal`] report `errno` instead of calling the kernel.
/// Consumed by that one attempt. Everything downstream — target
/// selection, the leader observation against the real child, and
/// the error construction — runs unmodified.
#[cfg(test)]
fn force_next_kill_errno(&mut self, errno: nix::errno::Errno) {
self.forced_kill_errno = Some(errno);
}
/// Override the SIGTERM-to-SIGKILL grace window. Test helper.
pub fn set_grace_period(&mut self, d: Duration) {
self.grace_period = d;
@ -928,7 +1061,21 @@ impl ProcessSupervisor {
return Err(format!("process {id} is not running"));
};
let target = signal_target(proc, pid)?;
nix::sys::signal::kill(target, Some(signal)).map_err(|e| format!("kill: {e}"))?;
// Q#PD4: the seam injects the KILL attempt's result only —
// never the observation below — so target selection, the real
// `ChildHandle::try_wait` against the real child, and the error
// construction all run for real. Consumed once.
let kill_result = match self.forced_kill_errno.take() {
Some(errno) => Err(errno),
None => nix::sys::signal::kill(target.pid, Some(signal)),
};
if let Err(errno) = kill_result {
// Q#PD1/Q#PD2: the failure describes itself; the
// disposition is unchanged — this still returns `Err`,
// with no state transition and no ledger arming.
let leader = observe_leader(proc);
return Err(signal_failure_report(target, pid, errno, &leader));
}
if matches!(signal, Signal::SIGTERM | Signal::SIGKILL | Signal::SIGHUP) {
proc.state = ProcessState::Exiting {
pid,
@ -2131,6 +2278,290 @@ mod tests {
);
}
/// Spawn a PTY child that leads its own session and stays alive
/// until terminated, returning its id and OS pid.
///
/// `/bin/sleep` directly rather than through a shell: a shell may
/// place the command in a different foreground process group, and
/// these tests assert the exact target the tty reports.
fn spawn_live_pty(sup: &mut ProcessSupervisor, name: &str) -> (ProcessId, u32) {
let mut spec = ProcessSpec::new(name, "/bin/sleep");
spec.args = vec!["30".into()];
spec.mode = ProcessMode::Pty {
rows: 24,
cols: 80,
mode: TerminalMode::Canonical,
};
let id = sup.spawn(spec).expect("spawn");
(id, spawn_started_pid(sup, id))
}
/// The OS pid straight from the supervisor's own record, WITHOUT
/// ticking.
///
/// `drain_until` ticks, and a tick can observe a fast child's exit
/// and transition the record out of `Running` — after which
/// `signal` returns "is not running" and never reaches the
/// diagnostic at all. Any test whose child exits promptly must read
/// the pid this way. (Found by the parallel workspace sweep: the
/// drain-based helper raced only under load.)
fn record_pid(sup: &ProcessSupervisor, id: ProcessId) -> u32 {
match sup.processes.get(&id).expect("record").state {
ProcessState::Running { pid, .. } | ProcessState::Exiting { pid, .. } => pid,
ProcessState::Starting => panic!("spawn has not reported a pid yet"),
ProcessState::Terminated(_) => {
panic!("the record already left Running; the pid is unavailable")
}
}
}
/// Drain until `Started` and return the OS pid it carries. Safe
/// only for children that outlive the drain; see [`record_pid`].
fn spawn_started_pid(sup: &mut ProcessSupervisor, id: ProcessId) -> u32 {
let evs = drain_until(sup, id, Duration::from_secs(5), |evs| {
evs.iter()
.any(|e| matches!(e.kind, ProcessEventKind::Started { .. }))
});
evs.iter()
.find_map(|e| match e.kind {
ProcessEventKind::Started { pid } => Some(pid),
_ => None,
})
.expect("Started carries a pid")
}
/// Drive the production diagnostic until it observes the leader as
/// exited, bounded by `timeout`.
///
/// A fixed sleep is NOT proof of exit — on a loaded runner the child
/// can still be live, which would turn these tests into false
/// failures. This synchronises on the very observation under test.
/// Each failing attempt leaves the record untouched, because the
/// failure path returns before any bookkeeping (Q#PD2), so looping
/// is side-effect free.
fn terminate_until_leader_exited(
sup: &mut ProcessSupervisor,
id: ProcessId,
timeout: Duration,
) -> String {
let deadline = Instant::now() + timeout;
loop {
sup.force_next_kill_errno(nix::errno::Errno::EPERM);
let err = sup.terminate(id).expect_err("injected EPERM must fail");
if err.contains("leader=exited(") {
return err;
}
assert!(
!err.contains("is not running"),
"the record left Running before the diagnostic could run, so \
this test never exercised it: {err}"
);
assert!(
Instant::now() < deadline,
"leader never observed as exited within {timeout:?}: {err}"
);
std::thread::sleep(Duration::from_millis(10));
}
}
/// Q#PD1 acceptance 1 — a group-directed failure names the target,
/// the branch that chose it, the expected group, the errno, and the
/// leader's own state, as five separate facts.
///
/// Asserted as an exact message against the pid the kernel actually
/// assigned, so a hardcoded target could not satisfy it. The leader
/// field is the one that matters: for a PTY the signal goes to the
/// terminal's foreground group, a different entity from the spawned
/// child whenever job control has moved the terminal. Three rejected
/// designs for this code collapsed the two; the report keeps them
/// apart, and here they are asserted to agree only because nothing
/// has moved the terminal.
#[test]
fn a_group_directed_kill_failure_reports_target_and_leader_separately() {
let mut sup = ProcessSupervisor::new();
let (id, pid) = spawn_live_pty(&mut sup, "diag-group");
sup.force_next_kill_errno(nix::errno::Errno::EPERM);
let err = sup.terminate(id).expect_err("injected EPERM must fail");
let expected = format!(
"kill: {} (target=-{pid} via tcgetpgrp, leader_pid={pid}, expected_group=-{pid}, leader=live)",
nix::errno::Errno::EPERM
);
assert_eq!(
err, expected,
"the report names the exact target the tty reported, the exact \
leader pid, and observes the leader as live"
);
let _ = sup.signal(id, Signal::SIGKILL);
}
/// Q#PD1 acceptance 2 — a leader-directed failure records the
/// fallback branch and a positive target, and omits the group field
/// that would be meaningless for it. Exact message again.
#[test]
fn a_leader_directed_kill_failure_reports_the_fallback_branch() {
let mut sup = ProcessSupervisor::new();
let mut spec = ProcessSpec::new("diag-leader", "/bin/sleep");
spec.args = vec!["30".into()];
let id = sup.spawn(spec).expect("spawn");
let pid = spawn_started_pid(&mut sup, id);
sup.force_next_kill_errno(nix::errno::Errno::ESRCH);
let err = sup.terminate(id).expect_err("injected ESRCH must fail");
let expected = format!(
"kill: {} (target={pid} via leader-pid, leader_pid={pid}, leader=live)",
nix::errno::Errno::ESRCH
);
assert_eq!(
err, expected,
"a non-group pipe child targets its own pid, and the group \
field is omitted where it has no meaning"
);
let _ = sup.signal(id, Signal::SIGKILL);
}
/// Q#PD1 acceptance 3 — every leader state renders distinctly. The
/// `Unobservable` and `NoRuntime` arms cannot be produced by a real
/// child on demand, so they are pinned directly; `live` and `exited`
/// are pinned through the real path by the tests around this one.
#[test]
fn every_leader_observation_renders_distinctly() {
assert_eq!(
LeaderObservation::Exited(TermStatus::Exited(0)).render(),
"exited(code 0)"
);
assert_eq!(
LeaderObservation::Exited(TermStatus::Signaled("SIGTERM".into())).render(),
"exited(signal SIGTERM)"
);
assert_eq!(LeaderObservation::Live.render(), "live");
assert_eq!(
LeaderObservation::Unobservable("try_wait: boom".into()).render(),
"unobservable(try_wait: boom)"
);
assert_eq!(LeaderObservation::NoRuntime.render(), "no-runtime");
}
/// Q#PD1 acceptance 3, exited arm through the REAL path — the leader
/// has genuinely exited and the report carries its exact code, not
/// merely "some exit".
#[test]
fn a_failure_after_the_child_exits_reports_the_leader_as_exited() {
let mut sup = ProcessSupervisor::new();
let mut spec = ProcessSpec::new("diag-exited", "/bin/sh");
spec.args = vec!["-c".into(), "exit 3".into()];
let id = sup.spawn(spec).expect("spawn");
// NOT `spawn_started_pid`: draining ticks, and this child exits
// immediately.
let pid = record_pid(&sup, id);
let err = terminate_until_leader_exited(&mut sup, id, Duration::from_secs(10));
let expected = format!(
"kill: {} (target={pid} via leader-pid, leader_pid={pid}, leader=exited(code 3))",
nix::errno::Errno::EPERM
);
assert_eq!(
err, expected,
"the exact exit code is observed from the real child, not \
inferred from the errno"
);
}
/// Q#PD2 acceptance 4 — **the disposition is unchanged.** An
/// injected failure still fails, and neither the state transition
/// nor the reap-ledger arming runs. This is the assertion that
/// separates a diagnostic from the tolerance rules three review
/// rounds rejected; flipping any arm to `Ok` fails it.
#[test]
fn an_injected_failure_changes_no_state_and_arms_no_ledger() {
let mut sup = ProcessSupervisor::new();
let mut spec = ProcessSpec::new("diag-disposition", "/bin/sh");
spec.args = vec!["-c".into(), "sleep 30".into()];
spec.group = true;
let id = sup.spawn(spec).expect("spawn");
let pid = spawn_started_pid(&mut sup, id);
assert!(
sup.reap_ledger.is_empty(),
"precondition: nothing armed before the attempt"
);
sup.force_next_kill_errno(nix::errno::Errno::EPERM);
let err = sup.terminate(id).expect_err("injected EPERM must fail");
let expected = format!(
"kill: {} (target=-{pid} via group, leader_pid={pid}, expected_group=-{pid}, leader=live)",
nix::errno::Errno::EPERM
);
assert_eq!(err, expected, "a group=true pipe child reports via group");
assert!(
matches!(
sup.processes.get(&id).expect("record").state,
ProcessState::Running { .. }
),
"a failed kill must not transition the record to Exiting"
);
assert!(
sup.reap_ledger.is_empty(),
"a failed kill must not arm the reap ledger"
);
let _ = sup.signal(id, Signal::SIGKILL);
}
/// Q#PD3/Q#PD4 acceptance 5 — the diagnostic consults the REAL
/// `ChildHandle::try_wait` on the REAL child, which reaps it and
/// caches the status. `poll_one` must still emit exactly one exit
/// event, carrying the exact code.
///
/// A stubbed observation would bypass the double-`try_wait` path
/// entirely and pin nothing, so the injection replaces the kill
/// result only.
#[test]
fn observing_the_leader_does_not_consume_the_exit_event() {
let mut sup = ProcessSupervisor::new();
let mut spec = ProcessSpec::new("diag-one-event", "/bin/sh");
spec.args = vec!["-c".into(), "exit 7".into()];
spec.mode = ProcessMode::Pty {
rows: 24,
cols: 80,
mode: TerminalMode::Canonical,
};
let id = sup.spawn(spec).expect("spawn");
// NOT `spawn_started_pid`: draining ticks, and a tick can reap
// this immediately-exiting child before the diagnostic runs.
let _ = record_pid(&sup, id);
// Drives `observe_leader`, which try_waits the real PTY child
// for the first time and reaps it.
let err = terminate_until_leader_exited(&mut sup, id, Duration::from_secs(10));
assert!(
err.contains("leader=exited(code 7)"),
"the real handle was consulted and carries the exact code: {err}"
);
// The supervisor's own try_wait must still see that status.
let evs = drain_until(&mut sup, id, Duration::from_secs(5), has_exited);
let terminal: Vec<i32> = evs
.iter()
.filter_map(|e| match e.kind {
ProcessEventKind::Exited { code, .. } => Some(code),
ProcessEventKind::Signaled { .. } => Some(-1),
_ => None,
})
.collect();
assert_eq!(
terminal,
vec![7],
"exactly one terminal event survives the diagnostic's try_wait, \
carrying the child's real exit code"
);
}
#[test]
fn signal_terminates_a_running_child() {
let mut sup = ProcessSupervisor::new();

View File

@ -1683,7 +1683,7 @@ mod tests {
// --- M5.5a handshake & postcard round-trips ---
#[test]
fn protocol_version_is_twenty_for_gpu_initial_targets() {
fn protocol_version_is_twenty_one_for_the_bottom_panel_band() {
// Pin the value: T M10.5 bumped 1→2 (v1.0 wire: CrdtOp /
// PresenceUpdate). T M11.1 bumped 2→3 (v1.1 wire: the
// SemanticFrame family + FrontendEvent::Viewport). T M11.6
@ -1722,7 +1722,13 @@ mod tests {
// variant, see the placement pins).
// GPU initial targets bump 19→20 with a semantic-only
// SessionBootstrapRequest and appended InitialTargetResult.
assert_eq!(PROTOCOL_VERSION, 20);
// Bottom panel Stage 2 bumps 20→21 (`InstanceMessage::PanelFrame`,
// daemon-gated, plus `FrontendEvent::{FrontendCellGeometry,
// PanelResizeRows, PanelPointer}`, frontend-gated — the second
// bump that gates in BOTH directions; all four appended after
// their enum's final v20 variant, see the placement pins in
// `bottom_panel_stage2b_protocol_acceptance`).
assert_eq!(PROTOCOL_VERSION, 21);
}
#[test]
@ -1798,17 +1804,18 @@ mod tests {
// minibuffer), v13 (`LineNumbers`), v14 (`LineNumberMode`), v15
// (`CompletionPopup`), v16 (`ThemeFacts`), v17 (`FontFacts`),
// v18 (`StatuslineSegments`), v19 (the vterm terminal family),
// and v20 (semantic initial-target bootstrap) all interoperate.
for accepted in 6..=20 {
// v20 (semantic initial-target bootstrap), and v21 (the bottom
// panel band) all interoperate.
for accepted in 6..=21 {
assert!(
is_supported_protocol_version(accepted),
"v{accepted} must be accepted"
);
}
for rejected in [0, 1, 2, 3, 4, 5, 21, u32::MAX] {
for rejected in [0, 1, 2, 3, 4, 5, 22, u32::MAX] {
assert!(
!is_supported_protocol_version(rejected),
"v{rejected} must be rejected by a v20 binary"
"v{rejected} must be rejected by a v21 binary"
);
}
}

View File

@ -625,6 +625,22 @@ impl SemanticRenderState {
// post-evaluation face inventory must then precede the authoritative
// segment replacement in this same frame. Unsupported peers skip the
// evaluator entirely and therefore pay no Lua callback/dynamic-face cost.
// Bottom-panel A2A-2, round 3: the document identity used to
// FILTER the results must be the PRE-CALLBACK one. Both outcome
// arms carry phase-1 contexts, and a provider that closes the
// primary document split changes `primary_document_window`
// mid-evaluation — reading it after the fact would compare
// phase-1 contexts against a replacement identity, match
// nothing, and silently suppress the authoritative clear.
let statusline_document_window = self
.peer_knows_statusline_segments
.then(|| {
state
.core
.borrow()
.primary_document_window(self.frontend_id)
})
.flatten();
let statusline_evaluation = self.peer_knows_statusline_segments.then(|| {
evaluate_statusline(
state.lua_host.lua(),
@ -810,7 +826,7 @@ impl SemanticRenderState {
out.extend(self.font_facts_msg(state));
// Q#SL6/Q#SL8: face inventory must precede segment text.
if let Some(evaluation) = statusline_evaluation {
self.emit_statusline_segments(evaluation, &mut out);
self.emit_statusline_segments(evaluation, statusline_document_window, &mut out);
}
out
}
@ -855,6 +871,16 @@ impl SemanticRenderState {
// Evaluate callbacks before `ThemeFacts` for the same reason the
// document path does: a callback may register a face, and the
// face inventory must precede the segment text that names it.
// Same pre-callback capture as the document path (round 3).
let statusline_document_window = self
.peer_knows_statusline_segments
.then(|| {
state
.core
.borrow()
.primary_document_window(self.frontend_id)
})
.flatten();
let statusline_evaluation = self.peer_knows_statusline_segments.then(|| {
evaluate_statusline(
state.lua_host.lua(),
@ -881,7 +907,12 @@ impl SemanticRenderState {
// a verdict we hold.
if self.last_terminal_frame.as_ref() == Some(&frame) {
self.terminal_error_latched = false;
out.extend(self.terminal_chrome(state, buffer_id, statusline_evaluation));
out.extend(self.terminal_chrome(
state,
buffer_id,
statusline_evaluation,
statusline_document_window,
));
return Some(out);
}
match frame.validate() {
@ -905,7 +936,12 @@ impl SemanticRenderState {
}
}
out.extend(self.terminal_chrome(state, buffer_id, statusline_evaluation));
out.extend(self.terminal_chrome(
state,
buffer_id,
statusline_evaluation,
statusline_document_window,
));
Some(out)
}
@ -920,6 +956,7 @@ impl SemanticRenderState {
state: &EditorState,
buffer_id: BufferId,
statusline_evaluation: Option<StatuslineEvaluation>,
statusline_document_window: Option<crate::window::WindowId>,
) -> Vec<InstanceMessage> {
let mut out = Vec::new();
out.extend(self.status_facts_msg(state, buffer_id));
@ -929,7 +966,7 @@ impl SemanticRenderState {
out.extend(self.font_facts_msg(state));
// Q#SL6/Q#SL8: face inventory must precede segment text.
if let Some(evaluation) = statusline_evaluation {
self.emit_statusline_segments(evaluation, &mut out);
self.emit_statusline_segments(evaluation, statusline_document_window, &mut out);
}
out
}
@ -941,6 +978,7 @@ impl SemanticRenderState {
fn emit_statusline_segments(
&mut self,
evaluation: StatuslineEvaluation,
document_window: Option<crate::window::WindowId>,
out: &mut Vec<InstanceMessage>,
) {
let to_wire = |segments: Vec<crate::statusline::EvaluatedStatuslineSegment>| {
@ -955,10 +993,16 @@ impl SemanticRenderState {
let frontend_id = self.frontend_id;
match evaluation.outcome {
StatuslineEvaluationOutcome::Ready(windows) => {
if let Some(window) = windows
.into_iter()
.find(|window| window.context.frontend_id == frontend_id)
{
// Bottom-panel A2A-2: the fan-out now yields the primary
// document AND the visible side window, so the wire
// segments must be selected by WINDOW IDENTITY. Taking
// "the first context for my frontend" would silently
// depend on capture order and could ship the panel's
// mode-line text as the document status band.
if let Some(window) = windows.into_iter().find(|window| {
window.context.frontend_id == frontend_id
&& Some(window.context.window_id) == document_window
}) {
self.emit_statusline_payload(
window.context.buffer_id,
to_wire(window.left),
@ -970,10 +1014,18 @@ impl SemanticRenderState {
StatuslineEvaluationOutcome::Invalidated {
authoritative_empty,
} => {
for context in authoritative_empty
.into_iter()
.filter(|context| context.frontend_id == frontend_id)
{
// Bottom-panel A2A-2: the clear must be filtered by
// DOCUMENT WINDOW exactly like the Ready arm. The
// semantic peer has ONE statusline slot, so publishing
// the panel context's clear here replaces the document's
// payload with the panel's — the same misrouting the
// Ready arm was fixed for, on the clear path.
//
// A panel's own clear belongs to the future panel
// painter (`PanelFrame`, Stage 2B), not to this wire.
for context in authoritative_empty.into_iter().filter(|context| {
context.frontend_id == frontend_id && Some(context.window_id) == document_window
}) {
self.emit_statusline_payload(context.buffer_id, Vec::new(), Vec::new(), out);
}
}
@ -1345,9 +1397,13 @@ impl SemanticRenderState {
state: &EditorState,
buffer_id: BufferId,
) -> Option<InstanceMessage> {
// Bottom-panel §1.3 #4 — Projection. `LineNumbers` describes the
// replica's DOCUMENT surface; a focused panel must not replace
// the document's gutter mode with the panel window's.
let mode = {
let core = state.core.borrow();
core.active_window_for(self.frontend_id)
core.primary_document_window(self.frontend_id)
.and_then(|win_id| core.windows.get(&win_id))
.map_or(crate::window::LineNumberMode::Off, |w| w.line_numbers)
};
if self.last_line_numbers == Some(mode) {
@ -1703,7 +1759,12 @@ impl SemanticRenderState {
// Emitting CurrentLine here forced a whole-buffer line table on
// every frame even though pmacs-gpu ignores its own current-line
// wash.
if let Some(win) = core.active_window_for(self.frontend_id)
// Bottom-panel §1.3 #5 — Projection. Selection decorations
// belong to the document surface the viewport describes; a
// selection made inside a focused panel must not paint into it.
if let Some(win) = core
.primary_document_window(self.frontend_id)
.and_then(|win_id| core.windows.get(&win_id))
&& win.buffer_id == vp.buffer_id
&& let Some((lo, hi)) = win.region()
&& let Some(range) = clip_to_viewport(lo, hi, vp)
@ -2916,6 +2977,7 @@ mod tests {
),
new_failures: Vec::new(),
},
None,
&mut stale,
);
assert!(stale.is_empty(), "phase-1 stale evaluation emits nothing");
@ -2924,11 +2986,15 @@ mod tests {
"stale evaluation retains the prior baseline until snapshot reset"
);
// Bottom-panel A2A-2: the clear is filtered by DOCUMENT window
// identity, so the context under test must BE the document
// window — passing `None` here would assert nothing.
let document_window = crate::window::WindowId::next();
let invalidated = || StatuslineEvaluation {
outcome: StatuslineEvaluationOutcome::Invalidated {
authoritative_empty: vec![crate::statusline::StatuslineContext {
frontend_id: FrontendId::LOCAL,
window_id: crate::window::WindowId::next(),
window_id: document_window,
buffer_id,
active: true,
}],
@ -2936,13 +3002,13 @@ mod tests {
new_failures: Vec::new(),
};
let mut replacement = Vec::new();
semantic.emit_statusline_segments(invalidated(), &mut replacement);
semantic.emit_statusline_segments(invalidated(), Some(document_window), &mut replacement);
assert_eq!(
statusline_of(&replacement),
Some((buffer_id, Vec::new(), Vec::new()))
);
let mut unchanged = Vec::new();
semantic.emit_statusline_segments(invalidated(), &mut unchanged);
semantic.emit_statusline_segments(invalidated(), Some(document_window), &mut unchanged);
assert!(
unchanged.is_empty(),
"the empty invalidation became baseline"

View File

@ -215,10 +215,25 @@ pub enum StatuslineEvaluationTarget {
/// Frontend whose entire visible layout is evaluated.
frontend_id: FrontendId,
},
/// Only the frontend's active window, iff it still displays the declared
/// semantic viewport buffer.
/// The frontend's **primary document window**, iff it still displays
/// the declared semantic viewport buffer, **plus its visible side
/// window** when one exists (bottom-panel Q#BP8 / A2A-2).
///
/// Two contexts, not one: the document result feeds the semantic
/// `StatuslineSegments` wire, while the side result paints in the
/// panel's own mode line. Unprojected document splits run no
/// callbacks, and a derived-hidden side (Q#BP2b) is omitted because
/// it has no mode line to paint this frame.
///
/// The document context is captured **first**; consumers must still
/// select by window identity rather than position, since only one of
/// the two may reach the single semantic statusline slot.
///
/// `active` on each context reports **actual focus**, so a document
/// provider truthfully observes `active = false` while a panel owns
/// focus (Q#BP14, parent acceptance 42).
Semantic {
/// Frontend whose focused daemon window is evaluated.
/// Frontend whose document (and visible side) window is evaluated.
frontend_id: FrontendId,
/// Buffer declared by the semantic viewport.
declared_buffer: BufferId,
@ -639,9 +654,20 @@ fn capture_target_contexts(
.views
.get(&frontend_id)
.ok_or(StatuslineNoMessageReason::ContextUnavailable)?;
// Bottom-panel §1.3 #12 — Projection. This LOOKUP resolves
// the primary document window: with a panel focused,
// `view.active` would name the panel and the declared-buffer
// check would clear the document's statusline.
//
// `active` is NOT rerouted with it (Q#BP14/parent 42): it
// reports ACTUAL focus, so a document provider truthfully
// observes `active = false` while the panel owns focus.
let window_id = core
.primary_document_window(frontend_id)
.ok_or(StatuslineNoMessageReason::ContextUnavailable)?;
let window = core
.windows
.get(&view.active)
.get(&window_id)
.ok_or(StatuslineNoMessageReason::ContextUnavailable)?;
if buffers.get(window.buffer_id).is_err() {
return Err(StatuslineNoMessageReason::BufferUnavailable);
@ -649,12 +675,46 @@ fn capture_target_contexts(
if window.buffer_id != declared_buffer {
return Err(StatuslineNoMessageReason::DeclaredBufferMismatch);
}
Ok(vec![StatuslineContext {
let mut contexts = vec![StatuslineContext {
frontend_id,
window_id: window.id,
buffer_id: window.buffer_id,
active: true,
}])
active: window.id == view.active,
}];
// Bottom-panel Q#BP8 / A2A-2 — the semantic fan-out is the
// primary document PLUS the frontend's visible side window,
// and nothing else: unprojected document splits run no
// callbacks. The document result feeds the semantic
// `StatuslineSegments`; the side result paints in the panel's
// own mode line.
//
// A derived-hidden side window is omitted (Q#BP2b): it has no
// mode line to paint this frame, so evaluating providers for
// it would invoke callbacks for a surface nobody can see.
if !view.panel_hidden {
for side_id in view.layout.iter_ids() {
if side_id == window.id {
continue;
}
let Some(side) = core.windows.get(&side_id) else {
return Err(StatuslineNoMessageReason::ContextUnavailable);
};
if !side.is_side() {
continue;
}
if buffers.get(side.buffer_id).is_err() {
return Err(StatuslineNoMessageReason::BufferUnavailable);
}
contexts.push(StatuslineContext {
frontend_id,
window_id: side.id,
buffer_id: side.buffer_id,
// Same rule as the document context: ACTUAL focus.
active: side.id == view.active,
});
}
}
Ok(contexts)
}
}
}

View File

@ -34,6 +34,10 @@ pub use pmacs_protocol::terminal::{
/// Configuration-time, not a wire bound: history never crosses the
/// protocol, so this stays core-owned.
pub const DEFAULT_TERMINAL_SCROLLBACK_ROWS: usize = 10_000;
/// Default `terminal.escape-key`, and the fallback an unparseable value
/// falls back to (Q#TC4a).
pub const DEFAULT_TERMINAL_ESCAPE_KEY: &str = "C-c";
/// Maximum retained main-screen history cells. Core-owned for the same
/// reason as [`DEFAULT_TERMINAL_SCROLLBACK_ROWS`].
pub const MAX_TERMINAL_HISTORY_CELLS: usize = 4_000_000;

View File

@ -12,6 +12,7 @@ use crate::ansi::AnsiParserProfile;
use crate::buffer::{Buffer, BufferId};
use crate::cell::{Cell, CellCoord, CellSize};
use crate::editor_core::EditorCore;
use crate::key::{Chord, parse_chord};
use crate::process::{
ProcessEventKind, ProcessId, ProcessMode, ProcessSpec, ProcessState, ProcessSupervisor,
RestartPolicy, StdinMode, TerminalMode,
@ -218,12 +219,40 @@ pub(super) struct TerminalSession {
pub(super) screen: TerminalScreen,
pub(super) process: TerminalProcessState,
pub(super) annotated: bool,
/// Resolved `terminal.escape-key` for this terminal (Q#TC4c).
///
/// The cache lives HERE, not in an editor-side map, because a
/// session is created in [`TerminalManager::open`] and dropped on
/// kill/prune — so its lifetime is exactly the cache's, with no
/// purge hook to forget. An editor-side map would leak an entry per
/// terminal; a single last-entry cache would reparse (and re-report
/// an invalid value) every time focus alternates between two
/// terminals.
pub(super) escape: Option<EscapeCache>,
}
/// One terminal's parsed escape chord, valid for one config epoch.
pub(super) struct EscapeCache {
/// The `ConfigRegistry::value_epoch` this was parsed at. The key is
/// `(this session, epoch)`: the epoch alone is not enough, because
/// it does not advance when focus moves between terminals with
/// different buffer-local values.
pub(super) epoch: u64,
/// The effective chord — the parsed spelling, or the `C-c` fallback.
pub(super) chord: Chord,
/// The invalid spelling already reported for this terminal, if any.
/// Reporting is once per terminal per effective invalid value: an
/// unchanged bad value stays quiet, a *different* bad value reports
/// again because it is a new mistake.
pub(super) reported_invalid: Option<String>,
}
/// Owns the one-buffer/one-process/one-screen terminal registry.
#[derive(Default)]
pub struct TerminalManager {
pub(super) sessions: HashMap<BufferId, TerminalSession>,
/// Total escape-key parses performed (Q#TC4c observability).
escape_parses: u64,
process_to_buffer: HashMap<ProcessId, BufferId>,
/// Removed buffers whose children are still being reaped. Their events
/// remain manager-owned so Lua/LSP/MCP consumers cannot steal a batch.
@ -331,6 +360,7 @@ impl TerminalManager {
screen,
process: TerminalProcessState::Running,
annotated: false,
escape: None,
},
);
debug_assert!(previous.is_none(), "fresh BufferId collided");
@ -538,6 +568,89 @@ impl TerminalManager {
.map_err(TerminalError::Process)
}
/// Resolve this terminal's effective escape chord, parsing at most
/// once per `(terminal, config epoch)` (Q#TC4c).
///
/// `spelling` is the caller-resolved `terminal.escape-key` value and
/// `epoch` the registry's `value_epoch()` it was read at. Returns the
/// effective chord plus, at most once per terminal per effective
/// invalid value, a message the caller should surface.
///
/// An unparseable spelling falls back to `C-c` rather than leaving the
/// terminal with no escape at all (Q#TC4a): without one, every key goes
/// to the child and the user cannot reach the binding that would fix
/// the setting that broke it.
pub fn escape_chord(
&mut self,
buffer_id: BufferId,
epoch: u64,
spelling: &str,
) -> (Chord, Option<String>) {
let fallback = default_escape_chord();
if let Some(session) = self.sessions.get(&buffer_id)
&& let Some(cache) = session.escape.as_ref()
&& cache.epoch == epoch
{
return (cache.chord, None);
}
self.escape_parses = self.escape_parses.saturating_add(1);
let Some(session) = self.sessions.get_mut(&buffer_id) else {
return (fallback, None);
};
let previously_reported = session
.escape
.as_ref()
.and_then(|cache| cache.reported_invalid.clone());
let (chord, reported_invalid, report) = match parse_chord(spelling) {
Ok(chord) => (chord, None, None),
Err(error) => {
let already = previously_reported.as_deref() == Some(spelling);
let message = (!already).then(|| {
format!(
"terminal.escape-key {spelling:?} is not a valid chord ({error}); using C-c"
)
});
(fallback, Some(spelling.to_owned()), message)
}
};
session.escape = Some(EscapeCache {
epoch,
chord,
reported_invalid,
});
(chord, report)
}
/// How many escape-key spellings this manager has parsed.
///
/// An observability seam for Q#TC4c's cache contract, which is
/// otherwise unpinnable for a VALID setting: a correct per-session
/// cache and a single last-entry cache produce identical behavior
/// there and differ only in how often they parse. Counting reports
/// covers the invalid case; this covers the valid one.
#[must_use]
pub fn escape_parses(&self) -> u64 {
self.escape_parses
}
/// How many terminals currently hold a cached escape chord.
///
/// The LIFETIME half of Q#TC4c's cache contract, which `escape_parses`
/// cannot cover: parse counting says a valid setting is read once, but
/// says nothing about whether the cache is ever released. Because the
/// cache lives on [`TerminalSession`], this count falls with the
/// session set by construction — which is exactly the property worth
/// pinning, since the rejected alternative (an editor-side
/// `HashMap<BufferId, EscapeCache>`) has no purge hook and would hold
/// this at its high-water mark while sessions drained.
#[must_use]
pub fn escape_caches(&self) -> usize {
self.sessions
.values()
.filter(|session| session.escape.is_some())
.count()
}
/// Resize a terminal screen and its PTY after validating shared limits.
pub fn resize(
&mut self,
@ -730,3 +843,12 @@ fn sanitize_metadata(value: &str) -> String {
}
clean
}
/// The built-in terminal escape chord, and the fallback for an
/// unparseable `terminal.escape-key` (Q#TC4a).
pub(super) fn default_escape_chord() -> Chord {
Chord::new(
crossterm::event::KeyCode::Char('c'),
crossterm::event::KeyModifiers::CONTROL,
)
}

View File

@ -329,6 +329,29 @@ impl TerminalManager {
copy_selection_bytes(&rows, selection)
}
/// Serialize a session's ENTIRE retained range — scrollback plus the
/// visible screen — through the same path [`copy_selection`] uses.
///
/// Q#TC7. This deliberately builds a whole-range *selection* and hands
/// it to the existing serializer rather than walking the rows itself.
/// Soft-wrap joining, wide-glyph continuation, cluster bytes, and
/// per-row trailing-blank trimming are Vterm Stage 2 criterion 21's
/// pinned behavior; a second walk would re-derive all four and the two
/// would drift. That inheritance is what acceptance 13 asserts, by
/// comparing this against a full-range `copy_selection` rather than
/// against a literal.
///
/// Returns `None` for a non-terminal buffer and for a session whose
/// retained rows are all empty — there is no cell to anchor to.
/// Unlike `copy_selection` this needs no registered view, so copy mode
/// does not depend on the terminal being currently displayed.
#[must_use]
pub fn copy_retained(&self, buffer_id: BufferId) -> Option<Vec<u8>> {
let session = self.sessions.get(&buffer_id)?;
let projection = session.screen.projection_ref();
retained_bytes(&retained_rows(projection))
}
/// Start an editor-owned primary selection at a viewport coordinate.
pub fn begin_selection(
&mut self,
@ -540,6 +563,40 @@ fn retained_rows(projection: BorrowedScreenProjection<'_>) -> RetainedRows<'_> {
RetainedRows { projection }
}
/// Serialize every retained cell, through the selection-copy serializer.
///
/// Split out from [`TerminalManager::copy_retained`] so the fidelity
/// claims — soft-wrap joining, per-row trailing-blank trimming, wide-glyph
/// continuation, cluster bytes — are testable against the same projection
/// fixtures that pin `copy_selection_bytes` itself. Those four are exactly
/// what a second, independently written walk would get wrong.
fn retained_bytes(rows: &RetainedRows<'_>) -> Option<Vec<u8>> {
copy_selection_bytes(rows, full_retained_selection(rows)?)
}
/// The selection spanning every retained cell.
///
/// Rows with no cells are skipped at both ends rather than clamped: an
/// anchor into a zero-width row cannot resolve (`resolve_anchor` requires
/// `cell_offset` to fall inside `cell_offset .. cell_offset + len`), so
/// including one would make the whole range unresolvable and silently
/// yield nothing. Interior empty rows are untouched, because trailing- and
/// interior-blank handling belongs to the serializer.
fn full_retained_selection(rows: &RetainedRows<'_>) -> Option<TerminalSelection> {
let mut occupied = rows.iter().filter(|row| !row.cells.is_empty());
let first = occupied.next()?;
// `RetainedRows::iter` is a chain of slice iterators exposed as
// `impl Iterator`, so it is not double-ended; scan forward.
let last = occupied.last().unwrap_or(first);
Some(TerminalSelection {
anchor: row_lead(first),
head: LogicalCellAnchor {
logical_line_id: last.logical_line_id,
cell_offset: last.cell_offset.saturating_add(last.cells.len() as u32 - 1),
},
})
}
fn row_lead(row: &TerminalRow) -> LogicalCellAnchor {
LogicalCellAnchor {
logical_line_id: row.logical_line_id,
@ -1001,6 +1058,92 @@ mod tests {
assert_eq!(bytes, b"abcd\ne");
}
/// Stage 2 criteria 13 and 14. Every property here is one a second,
/// independently written whole-range walk would get wrong: a naive
/// walk emits a newline per physical row (breaking the soft wrap),
/// keeps trailing default blanks, and has to rediscover that history
/// precedes the visible screen. Asserting exact bytes is what makes
/// "it reuses the serializer" falsifiable.
#[test]
fn retained_copy_spans_history_joins_soft_wraps_and_trims_blanks() {
let source = projection(
vec![row(1, 0, "ab ", true), row(1, 3, "cd ", false)],
vec![row(2, 0, "e ", false), row(3, 0, " ", false)],
);
let retained = retained_rows(source.as_borrowed());
let bytes = retained_bytes(&retained).expect("whole range resolves");
// `ab`+`cd` joined across the soft wrap; `e` on its own hard row;
// the all-blank final row trimmed to nothing but still separated.
assert_eq!(bytes, b"abcd\ne\n");
}
/// The whole-range selection must not depend on a view existing, and
/// must agree with an explicit full-span selection through the public
/// serializer — the anti-drift half of criterion 13.
#[test]
fn retained_copy_agrees_with_an_explicit_full_span_selection() {
let source = projection(
vec![row(1, 0, "aaa", false)],
vec![row(2, 0, "bbb", false), row(3, 0, "ccc", false)],
);
let retained = retained_rows(source.as_borrowed());
let explicit = copy_selection_bytes(
&retained,
TerminalSelection {
anchor: LogicalCellAnchor {
logical_line_id: 1,
cell_offset: 0,
},
head: LogicalCellAnchor {
logical_line_id: 3,
cell_offset: 2,
},
},
)
.expect("explicit selection resolves");
assert_eq!(retained_bytes(&retained).expect("whole range"), explicit);
assert_eq!(explicit, b"aaa\nbbb\nccc");
}
/// A wide glyph must be copied once across the whole range too, not
/// once per cell it occupies.
#[test]
fn retained_copy_emits_a_wide_glyph_once() {
let wide = TerminalRow {
cells: vec![
Cell {
glyph: Glyph::Char('界'),
style: Style::default(),
attachment: None,
},
Cell {
glyph: Glyph::Continuation,
style: Style::default(),
attachment: None,
},
Cell::default(),
],
logical_line_id: 9,
cell_offset: 0,
soft_wrapped: false,
};
let source = projection(Vec::new(), vec![wide]);
let retained = retained_rows(source.as_borrowed());
assert_eq!(
retained_bytes(&retained).expect("whole range"),
"".as_bytes()
);
}
/// A session with nothing retained yields `None` rather than an empty
/// string, so the caller can tell "no terminal" from "empty terminal".
#[test]
fn retained_copy_of_zero_width_rows_is_none() {
let source = projection(Vec::new(), vec![row(1, 0, "", false)]);
let retained = retained_rows(source.as_borrowed());
assert!(retained_bytes(&retained).is_none());
}
#[test]
fn wide_continuation_canonicalizes_to_lead_and_copies_once() {
let wide = TerminalRow {

View File

@ -558,8 +558,19 @@ pub struct FrontendView {
/// store. Reckoning in visible lines unconditionally would make that
/// GPU session's cursor skip lines it is still showing, so every
/// command/event-time visible-line reckoning is gated on the
/// **acting** frontend's flag. Render-time clamps need no gate: a
/// semantic session never enters `paint_frame`.
/// **acting** frontend's flag.
///
/// **Render-time clamps used to need no gate, on the premise that a
/// semantic session never enters `paint_frame`. The bottom-panel
/// band breaks that premise** (Q#BP17): the daemon projects a
/// semantic frontend's side window through the same per-window
/// painter. So the extracted painters
/// (`prepare_window_cursor_visible`, `paint_window_content`) take the
/// visible-line map as a **parameter**, and the panel path passes
/// `None` when the *owning* frontend's `fold_projection` is false.
/// That path must not call `EditorCore::fold_map_for_window`, which
/// gates on the **active** frontend — correct for command-time
/// reckoning, wrong for painting another frontend's panel.
///
/// Set at attach from the negotiated selected-render bit (grid ⇒
/// `true`, semantic ⇒ `false`), cleared with the view at detach, and

View File

@ -0,0 +1,898 @@
// bottom_panel_stage2a_acceptance.rs --- bottom-panel Stage 2A
// (docs/bottom-panel-stage2-framing.md, criteria A2A-1 / A2A-2 / A2A-3).
//! Classified §1.3 census routing + the per-window painter extraction.
//! No wire change.
//!
//! **The negative half is the load-bearing half.** A suite that only
//! proved "the document surface is used" would pass with the focus,
//! focus-chrome, and focus/session consumers *wrongly* rerouted to the
//! document — which is the defect the framing spent three review rounds
//! eliminating, and which would break remote-op validation,
//! `DispatchIdle`, presence, focused search/menu/completion routing, and
//! terminal bell ownership. So every Projection assertion here is paired
//! with a focus-class assertion taken in the *same* state.
use pmacs::cell::{CellGrid, CellSize};
use pmacs::editor::EditorState;
use pmacs::protocol::FrontendId;
use pmacs::window::{Side, WindowId};
const ROWS: u32 = 24;
const COLS: u32 = 60;
fn editor() -> EditorState {
let s = EditorState::new();
exec(&s, "pmacs.lsp.config = {}");
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 side_window_of(core: &pmacs::editor_core::EditorCore, fid: FrontendId) -> Option<WindowId> {
core.views[&fid].layout.iter_ids().into_iter().find(|id| {
core.windows
.get(id)
.is_some_and(|w| w.params.side.is_some())
})
}
fn side_window(s: &EditorState) -> Option<WindowId> {
let core = s.core.borrow();
core.views[&FrontendId::LOCAL]
.layout
.iter_ids()
.into_iter()
.find(|id| {
core.windows
.get(id)
.is_some_and(|w| w.params.side.is_some())
})
}
/// Open a bottom panel and leave it FOCUSED — the state in which every
/// classification difference becomes observable.
fn focused_panel(s: &EditorState) -> (WindowId, WindowId) {
let document = s.core.borrow().views[&FrontendId::LOCAL].active;
exec(
s,
"PANEL_BUF = pmacs.buffer.create(\"*panel*\")
PANEL_WIN = pmacs.window.display(PANEL_BUF, \
{ side = \"bottom\", height = 4 })",
);
let panel = side_window(s).expect("panel exists");
s.core.borrow_mut().focus_window(FrontendId::LOCAL, panel);
assert_eq!(
s.core.borrow().views[&FrontendId::LOCAL].active,
panel,
"fixture precondition: the panel must own focus"
);
(document, panel)
}
fn render(s: &EditorState) {
let size = CellSize::new(ROWS, COLS);
let mut cells = vec![pmacs::cell::Cell::default(); (ROWS * COLS) as usize];
let mut grid = CellGrid {
cells: &mut cells,
stride: size.cols,
size,
};
let _ = pmacs::editor::paint_frame(
s,
FrontendId::LOCAL,
&std::collections::HashMap::new(),
&mut grid,
size,
);
}
// ---------------------------------------------------------------------------
// A2A-1 — the Projection class resolves the document surface
// ---------------------------------------------------------------------------
#[test]
fn projection_resolves_the_document_window_while_a_panel_is_focused() {
let s = editor();
let (document, panel) = focused_panel(&s);
let core = s.core.borrow();
assert_eq!(
core.primary_document_window(FrontendId::LOCAL),
Some(document),
"Projection consumers must resolve the document window, not the focused panel"
);
assert_ne!(document, panel);
}
#[test]
fn projection_buffer_is_the_document_buffer_not_the_panel_buffer() {
let s = editor();
let (document, _panel) = focused_panel(&s);
let core = s.core.borrow();
let document_buffer = core.windows[&document].buffer_id;
assert_eq!(
core.primary_document_buffer(FrontendId::LOCAL),
Some(document_buffer),
"the replica's document mirror must not follow panel focus"
);
assert_ne!(
core.primary_document_buffer(FrontendId::LOCAL),
Some(core.windows[&core.views[&FrontendId::LOCAL].active].buffer_id),
"non-vacuity: the focused window's buffer differs, so this test can fail"
);
}
// ---------------------------------------------------------------------------
// A2A-1 — the NEGATIVE half: focus classes still resolve focus
// ---------------------------------------------------------------------------
#[test]
fn focus_class_dispatch_idle_still_tracks_the_focused_window() {
let s = editor();
let (_document, _panel) = focused_panel(&s);
// §1.3 #14 — Focus. Q#BP14a: optimistic input is gated per WINDOW.
// A panel that owns focus must suppress `DispatchIdle` even though
// the *document* projection is unaffected.
assert!(
!s.dispatch_idle_for(FrontendId::LOCAL),
"a focused side window must gate optimistic input off (#14)"
);
}
#[test]
fn focus_class_gate_lifts_when_focus_returns_to_the_document() {
let s = editor();
let (document, _panel) = focused_panel(&s);
s.core
.borrow_mut()
.focus_window(FrontendId::LOCAL, document);
assert!(
s.dispatch_idle_for(FrontendId::LOCAL),
"non-vacuity: the gate must lift with focus, or the test above proves nothing"
);
}
#[test]
fn focus_and_projection_disagree_in_the_same_state() {
// The single most important assertion in this suite: in ONE state,
// the two classes must resolve DIFFERENT windows. If a future change
// routes the focus class through `primary_document_window`, this
// fails even though every Projection test above still passes.
let s = editor();
let (document, panel) = focused_panel(&s);
let core = s.core.borrow();
let focused = core.views[&FrontendId::LOCAL].active;
let projected = core
.primary_document_window(FrontendId::LOCAL)
.expect("a document window exists");
assert_eq!(focused, panel, "focus authority must name the panel");
assert_eq!(
projected, document,
"projection authority must name the document"
);
assert_ne!(
focused, projected,
"the two authorities must be genuinely distinct in this state"
);
}
// ---------------------------------------------------------------------------
// A2A-2 — the statusline split: lookup reroutes, `active` does not
// ---------------------------------------------------------------------------
#[test]
fn statusline_document_context_reports_active_false_under_a_focused_panel() {
use pmacs::statusline::{
StatuslineEvaluationOutcome, StatuslineEvaluationTarget, evaluate_statusline,
};
let s = editor();
let (document, _panel) = focused_panel(&s);
let declared = s.core.borrow().windows[&document].buffer_id;
let evaluation = evaluate_statusline(
s.lua_host.lua(),
&s.core,
&s.statusline_registry,
StatuslineEvaluationTarget::Semantic {
frontend_id: FrontendId::LOCAL,
declared_buffer: declared,
},
);
match evaluation.outcome {
StatuslineEvaluationOutcome::Ready(windows) => {
// A2A-2: the semantic fan-out captures the primary document
// AND the visible side window — two contexts, not one.
assert_eq!(
windows.len(),
2,
"the semantic-layout target must capture document + visible side window"
);
let side = windows
.iter()
.find(|w| w.context.window_id != document)
.expect("a side-window context");
assert!(
side.context.active,
"the focused panel's own context reports active = true"
);
let context = windows
.first()
.map(|segments| segments.context)
.expect("one document context");
// The LOOKUP rerouted: it resolved the document window even
// though the panel is focused (§1.3 #12).
assert_eq!(
context.window_id, document,
"the semantic target must resolve the primary document window"
);
// `active` did NOT reroute (parent acceptance 42): a document
// provider observes the truth, that it is not focused.
assert!(
!context.active,
"a document provider must observe active = false while the panel owns focus"
);
}
other => panic!("expected a ready evaluation, got {other:?}"),
}
}
#[test]
fn statusline_document_context_is_active_when_the_document_is_focused() {
use pmacs::statusline::{
StatuslineEvaluationOutcome, StatuslineEvaluationTarget, evaluate_statusline,
};
// Non-vacuity for the assertion above: with focus on the document,
// the same context must report `active = true`.
let s = editor();
let (document, _panel) = focused_panel(&s);
s.core
.borrow_mut()
.focus_window(FrontendId::LOCAL, document);
let declared = s.core.borrow().windows[&document].buffer_id;
let evaluation = evaluate_statusline(
s.lua_host.lua(),
&s.core,
&s.statusline_registry,
StatuslineEvaluationTarget::Semantic {
frontend_id: FrontendId::LOCAL,
declared_buffer: declared,
},
);
match evaluation.outcome {
StatuslineEvaluationOutcome::Ready(windows) => {
let context = windows.first().map(|s| s.context).expect("one context");
assert!(context.active, "a focused document context must be active");
}
other => panic!("expected a ready evaluation, got {other:?}"),
}
}
// ---------------------------------------------------------------------------
// A2A-3 — the painter extraction preserves grid behavior
// ---------------------------------------------------------------------------
#[test]
fn extraction_preserves_cells_cursor_and_focused_view_top() {
// The extraction must preserve four things, not just cells: a clamp
// that silently moved to the WRONG window would leave the painted
// cells identical on a single-window frame.
let s = editor();
exec(
&s,
"local b = pmacs.buffer.create(\"*doc*\")
b:insert(0, string.rep(\"line\\n\", 200))
pmacs.window.display(b, {})",
);
// The gutter only paints when line numbers are on, so turn them on
// rather than dropping the assertion.
{
let mut core = s.core.borrow_mut();
let active = core.views[&FrontendId::LOCAL].active;
core.windows.get_mut(&active).unwrap().line_numbers =
pmacs::window::LineNumberMode::Absolute;
}
let size = CellSize::new(ROWS, COLS);
let mut cells_a = vec![pmacs::cell::Cell::default(); (ROWS * COLS) as usize];
let mut grid_a = CellGrid {
cells: &mut cells_a,
stride: size.cols,
size,
};
let cursor_a = pmacs::editor::paint_frame(
&s,
FrontendId::LOCAL,
&std::collections::HashMap::new(),
&mut grid_a,
size,
);
let active = s.core.borrow().views[&FrontendId::LOCAL].active;
let view_top_a = s.core.borrow().windows[&active].view_top;
// A second identical paint is a fixed point: same cells, same
// returned cursor, same `view_top`.
let mut cells_b = vec![pmacs::cell::Cell::default(); (ROWS * COLS) as usize];
let mut grid_b = CellGrid {
cells: &mut cells_b,
stride: size.cols,
size,
};
let cursor_b = pmacs::editor::paint_frame(
&s,
FrontendId::LOCAL,
&std::collections::HashMap::new(),
&mut grid_b,
size,
);
let view_top_b = s.core.borrow().windows[&active].view_top;
assert_eq!(cells_a, cells_b, "painted cells must be stable");
assert_eq!(cursor_a, cursor_b, "the returned cursor must be stable");
assert_eq!(view_top_a, view_top_b, "focused view_top must be stable");
// Review round 1, finding 5: a fixed-point check alone is VACUOUS —
// deleting `text_view.render` leaves it green. Assert the extracted
// painter actually produced each of its four outputs.
let rows = |cells: &[pmacs::cell::Cell]| -> Vec<String> {
(0..ROWS as usize)
.map(|r| {
(0..COLS as usize)
.map(|c| match &cells[r * COLS as usize + c].glyph {
pmacs::cell::Glyph::Char(ch) => *ch,
_ => ' ',
})
.collect::<String>()
})
.collect()
};
let painted = rows(&cells_a);
// TEXT: the buffer's content reached the grid.
assert!(
painted.iter().any(|row| row.contains("line")),
"the extracted painter must paint buffer TEXT; got {painted:?}"
);
// GUTTER: line numbers were painted beside it.
assert!(
painted.iter().any(|row| row.trim_start().starts_with('1')),
"the extracted painter must paint the line-number GUTTER"
);
// MODE LINE: the window's mode line names its buffer.
assert!(
painted.iter().any(|row| row.contains("*doc*")),
"the extracted painter must paint the window MODE LINE"
);
// CURSOR: a real caret position came back, not None.
assert!(
cursor_a.is_some(),
"the extraction must still return a caret position"
);
}
#[test]
fn extraction_leaves_a_passive_window_view_top_untouched() {
// The auto-scroll clamp runs for the FOCUSED window only. A passive
// window's scroll state must survive a frame it did not own.
let s = editor();
exec(
&s,
"local b = pmacs.buffer.create(\"*doc*\")
b:insert(0, string.rep(\"line\\n\", 200))
pmacs.window.display(b, {})
pmacs.window.split_horizontal()",
);
render(&s);
let (passive, before) = {
let core = s.core.borrow();
let view = &core.views[&FrontendId::LOCAL];
let passive = view
.layout
.iter_ids()
.into_iter()
.find(|id| *id != view.active)
.expect("a second window exists");
(passive, core.windows[&passive].view_top)
};
// Scroll the passive window somewhere the clamp would "fix" if it
// ever ran against the wrong window.
s.core
.borrow_mut()
.windows
.get_mut(&passive)
.unwrap()
.view_top = 120;
render(&s);
assert_eq!(
s.core.borrow().windows[&passive].view_top,
120,
"a passive window's view_top must not be clamped by another window's frame"
);
assert_ne!(before, 120, "non-vacuity: the value actually changed");
}
// ---------------------------------------------------------------------------
// Fixture integrity
// ---------------------------------------------------------------------------
#[test]
fn the_panel_fixture_really_builds_a_side_window() {
// Every test above is worthless if `focused_panel` silently produced
// an ordinary split, so pin the fixture's own precondition.
let s = editor();
let (_document, panel) = focused_panel(&s);
let core = s.core.borrow();
assert_eq!(
core.windows[&panel].params.side,
Some(Side::Bottom),
"the fixture must produce a real bottom side window"
);
}
// ---------------------------------------------------------------------------
// A2A-1 at the CONSUMER seam — review round 1, finding 3.
//
// The tests above assert the *authority* (`primary_document_window`).
// That is not sufficient: restoring a producer to `active_window_for`
// leaves every one of them green. These drive the real producers through
// `SemanticRenderState::render_frame` with a panel focused, so a reverted
// routing fails here.
// ---------------------------------------------------------------------------
/// A semantic frontend that CAN hold a panel. Stage 1 ships
/// `panel_capable = false` for semantic sessions and 2B flips it for a
/// v21-negotiated peer; until then the projection is only reachable with
/// a test-only capable view, which is exactly what the framing's §7.2
/// says 2B must replace with the real capability flip.
fn semantic_frontend_with_focused_panel(
s: &EditorState,
) -> (FrontendId, WindowId, WindowId, pmacs::buffer::BufferId) {
use pmacs::window::{FrontendView, Layout, LayoutNode, Orientation, Window, WindowParams};
let fid = FrontendId(77);
let (doc_win, panel_win, doc_buf) = {
let mut core = s.core.borrow_mut();
let doc_buf = core.active_window().buffer_id;
let panel_buf = core.registry.borrow_mut().create("*panel*");
let doc_win = WindowId::next();
let panel_win = WindowId::next();
let doc_view = {
let reg = core.registry.borrow();
pmacs::text_view::TextView::new(reg.get(doc_buf).expect("document buffer"))
};
let panel_view = {
let reg = core.registry.borrow();
pmacs::text_view::TextView::new(reg.get(panel_buf).expect("panel buffer"))
};
core.windows
.insert(doc_win, Window::new(doc_win, doc_buf, doc_view));
let mut panel = Window::new(panel_win, panel_buf, panel_view);
let mut params = WindowParams::default();
params.side = Some(Side::Bottom);
params.fixed_rows = Some(4);
panel.params = params;
core.windows.insert(panel_win, panel);
core.register_frontend_view(
fid,
FrontendView {
layout: Layout {
root: LayoutNode::Split {
orientation: Orientation::Horizontal,
children: vec![LayoutNode::Leaf(doc_win), LayoutNode::Leaf(panel_win)],
weights: vec![1, 1],
},
},
// The panel owns focus; the document is the projection.
active: panel_win,
fold_projection: false,
panel_capable: true,
frame_geometry: None,
panel_hidden: false,
},
);
(doc_win, panel_win, doc_buf)
};
s.sync_frame_geometry(fid, CellSize::new(ROWS, COLS));
(fid, doc_win, panel_win, doc_buf)
}
#[test]
fn consumer_line_numbers_follow_the_document_not_the_focused_panel() {
use pmacs::protocol::{ByteRange, InstanceMessage};
use pmacs::semantic_render::SemanticRenderState;
use pmacs::window::LineNumberMode;
let s = editor();
let (fid, doc_win, panel_win, doc_buf) = semantic_frontend_with_focused_panel(&s);
// Make the two windows DISAGREE, so the emitted mode identifies
// which window the producer read (§1.3 #4).
{
let mut core = s.core.borrow_mut();
core.windows.get_mut(&doc_win).unwrap().line_numbers = LineNumberMode::Absolute;
core.windows.get_mut(&panel_win).unwrap().line_numbers = LineNumberMode::Off;
}
let mut sem = SemanticRenderState::new(fid);
sem.set_viewport(doc_buf, ByteRange { start: 0, end: 0 }, 0);
let msgs = sem.render_frame(&s);
let mode = msgs.iter().find_map(|m| match m {
InstanceMessage::LineNumbers { mode, .. } => Some(*mode),
_ => None,
});
assert_eq!(
mode,
Some(pmacs::protocol::LineNumberMode::Absolute),
"LineNumbers must describe the DOCUMENT window's mode, not the focused panel's"
);
}
#[test]
fn consumer_statusline_segments_carry_the_document_payload_not_the_panel() {
use pmacs::protocol::{ByteRange, InstanceMessage};
use pmacs::semantic_render::SemanticRenderState;
// §1.3 #12 / A2A-2 at the WIRE. Round 2 finding: the previous
// version discarded `render_frame`'s output and only reasserted
// `primary_document_window`, so restoring the producer's
// "first context for my frontend" selector left it green.
//
// The peer must negotiate v18 or no `StatuslineSegments` is emitted
// at all and the assertion would be vacuous a second way.
let s = editor();
let (fid, _doc_win, _panel_win, doc_buf) = semantic_frontend_with_focused_panel(&s);
let panel_buf = {
let core = s.core.borrow();
let panel = side_window_of(&core, fid).expect("panel");
core.windows[&panel].buffer_id
};
// One provider so a payload exists to misroute.
exec(
&s,
"pmacs.statusline.register({ name = \"probe\", side = \"left\",
face = \"ui.modeline\", fn = function(ctx) return \"X\" end })",
);
let mut sem = SemanticRenderState::for_peer(fid, 18);
sem.set_viewport(doc_buf, ByteRange { start: 0, end: 0 }, 0);
let msgs = sem.render_frame(&s);
let targets: Vec<_> = msgs
.iter()
.filter_map(|m| match m {
InstanceMessage::StatuslineSegments { buffer_id, .. } => Some(*buffer_id),
_ => None,
})
.collect();
assert!(
!targets.is_empty(),
"non-vacuity: a v18 peer with a registered provider must emit StatuslineSegments"
);
assert!(
targets.iter().all(|b| *b == doc_buf),
"every StatuslineSegments must target the DOCUMENT buffer; got {targets:?} (document {doc_buf:?}, panel {panel_buf:?})"
);
assert!(
!targets.contains(&panel_buf),
"the panel's context must never reach the document statusline wire"
);
}
#[test]
fn consumer_terminal_declaration_resolves_the_document_not_the_focused_panel() {
use pmacs::terminal::TerminalSpec;
// §1.3 #6/#10/#11 through the real guard. Round 2 finding: the
// previous version compared two NON-terminal buffers, so both the
// old and new routings returned `false` and it could not
// discriminate. Make the DOCUMENT window hold a real terminal: the
// document routing then answers `true` while the old `view.active`
// routing (which names the focused panel) answers `false`.
let mut s = editor();
let (fid, doc_win, _panel_win, _doc_buf) = semantic_frontend_with_focused_panel(&s);
let mut spec = TerminalSpec::new("/bin/sh");
spec.rows = 10;
spec.cols = 40;
let term_buf = s.open_terminal(spec).expect("a real terminal session");
// Install the terminal in the DOCUMENT window; the panel keeps its
// own non-terminal buffer and keeps focus.
{
let mut core = s.core.borrow_mut();
core.install_buffer_in_window(doc_win, term_buf)
.expect("install the terminal in the document window");
}
let panel_buf = {
let core = s.core.borrow();
let panel = side_window_of(&core, fid).expect("panel");
core.windows[&panel].buffer_id
};
assert!(
s.semantic_terminal_declaration_is_active(fid, term_buf),
"the DOCUMENT window's terminal must be declarable while the panel owns focus"
);
assert!(
!s.semantic_terminal_declaration_is_active(fid, panel_buf),
"the focused panel's own buffer must never claim the document declaration"
);
}
#[test]
fn invalidated_statusline_clears_only_the_document_not_the_panel() {
use pmacs::protocol::{ByteRange, InstanceMessage};
use pmacs::semantic_render::SemanticRenderState;
// Round 2 finding 1. The `Invalidated` arm emits an
// authoritative-empty payload for EVERY context of the frontend.
// Once A2A-2's fan-out yields document + panel, that publishes two
// clears on a wire with ONE statusline slot, so the panel's payload
// replaces the document's. This is the live, observable half of the
// routing bug — the `Ready` arm happens to be safe today only
// because the document context is captured first.
let s = editor();
let (fid, _doc_win, _panel_win, doc_buf) = semantic_frontend_with_focused_panel(&s);
let panel_buf = {
let core = s.core.borrow();
let panel = side_window_of(&core, fid).expect("panel");
core.windows[&panel].buffer_id
};
assert_ne!(doc_buf, panel_buf, "fixture: the two buffers must differ");
// A provider that unregisters itself mid-evaluation is the canonical
// registry-mutation invalidation.
exec(
&s,
r"_G.SL_SELF = pmacs.statusline.register {
name='self-remove', side='left', priority=100,
fn=function() pmacs.statusline.unregister(SL_SELF); return 'STALE' end,
}",
);
let mut sem = SemanticRenderState::for_peer(fid, 18);
sem.set_viewport(doc_buf, ByteRange { start: 0, end: 0 }, 0);
let msgs = sem.render_frame(&s);
let targets: Vec<_> = msgs
.iter()
.filter_map(|m| match m {
InstanceMessage::StatuslineSegments { buffer_id, .. } => Some(*buffer_id),
_ => None,
})
.collect();
assert!(
!targets.contains(&panel_buf),
"an invalidated evaluation must not clear the PANEL's context on the \
document statusline wire; got {targets:?} (document {doc_buf:?}, \
panel {panel_buf:?})"
);
}
#[test]
fn the_semantic_fan_out_captures_the_document_first() {
use pmacs::statusline::{
StatuslineEvaluationOutcome, StatuslineEvaluationTarget, evaluate_statusline,
};
// The `Ready` arm selects by window identity, so capture order is not
// load-bearing for correctness — but it IS load-bearing for the
// falsifiability of that selector, so pin it explicitly rather than
// leaving a silent dependency. If a future change reorders the
// fan-out, this fails and whoever reads it learns why it mattered.
let s = editor();
let (fid, doc_win, _panel_win, doc_buf) = semantic_frontend_with_focused_panel(&s);
let evaluation = evaluate_statusline(
s.lua_host.lua(),
&s.core,
&s.statusline_registry,
StatuslineEvaluationTarget::Semantic {
frontend_id: fid,
declared_buffer: doc_buf,
},
);
match evaluation.outcome {
StatuslineEvaluationOutcome::Ready(windows) => {
assert_eq!(windows.len(), 2, "document + visible side window");
assert_eq!(
windows[0].context.window_id, doc_win,
"the DOCUMENT context must be captured first"
);
}
other => panic!("expected Ready, got {other:?}"),
}
}
#[test]
fn consumer_decorations_follow_the_document_selection_not_the_panel() {
use pmacs::protocol::{ByteRange, InstanceMessage};
use pmacs::semantic_render::SemanticRenderState;
// §1.3 #5 — Projection. A selection made inside a FOCUSED PANEL must
// not paint selection decorations into the document's viewport.
//
// To DISCRIMINATE, the panel must display the SAME buffer the
// viewport declares and hold a NON-EMPTY selection while the
// document holds none. With different buffers (the first attempt)
// both routings emit nothing and the test proves nothing.
let s = editor();
let (fid, doc_win, panel_win, doc_buf) = semantic_frontend_with_focused_panel(&s);
exec(&s, "PROBE = pmacs.buffer.list()[1]");
{
let mut core = s.core.borrow_mut();
// Put real text in the document buffer so a span exists.
{
let reg = core.registry.borrow();
let _ = reg.get(doc_buf).expect("doc");
}
// The panel shows the document's buffer and selects a range.
core.install_buffer_in_window(panel_win, doc_buf)
.expect("panel shows the document buffer");
let panel = core.windows.get_mut(&panel_win).expect("panel");
panel.selection = Some(pmacs::window::Selection { anchor: 0 });
panel.cursor = 4;
// The document window selects nothing.
let doc = core.windows.get_mut(&doc_win).expect("doc");
doc.selection = None;
doc.cursor = 0;
}
let mut sem = SemanticRenderState::for_peer(fid, 18);
sem.set_viewport(doc_buf, ByteRange { start: 0, end: 8 }, 0);
let msgs = sem.render_frame(&s);
let selection_decorations: usize = msgs
.iter()
.filter_map(|m| match m {
InstanceMessage::Decorations { segments, .. } => Some(
segments
.iter()
.map(|seg| seg.decorations.len())
.sum::<usize>(),
),
_ => None,
})
.sum();
assert_eq!(
selection_decorations, 0,
"a selection living in the focused PANEL must not decorate the document viewport"
);
}
#[test]
fn a_provider_closing_the_document_split_still_clears_the_statusline() {
use pmacs::protocol::{ByteRange, InstanceMessage};
use pmacs::semantic_render::SemanticRenderState;
// Round 3 finding 1. `authoritative_empty` carries PHASE-1 contexts,
// so the identity used to filter them must be the PRE-CALLBACK one.
// A provider that closes the primary document split changes
// `primary_document_window` mid-evaluation; reading it afterwards
// compares phase-1 contexts against a replacement identity, matches
// nothing, and silently suppresses the authoritative clear — leaving
// stale statusline text on screen forever.
//
// Driven on LOCAL, because the Lua window API acts on the ACTIVE
// FRONTEND: a synthetic semantic view would be untouched by
// `pmacs.window.close()` and the identity would never change, which
// is exactly how the first version of this test came back vacuous.
// TWO document windows plus the panel: closing the only document
// window is structurally refused (Q#BP6 forbids a lone side window
// as a resting state), so the first attempt could not change the
// identity at all. Distinct buffers make the target selectable from
// a Lua provider, which has no focus-by-id.
let s = editor();
exec(
&s,
"DOC_A = pmacs.buffer.create(\"*doc-a*\")
DOC_B = pmacs.buffer.create(\"*doc-b*\")
pmacs.window.display(DOC_A, {})
pmacs.window.split_horizontal()
pmacs.window.focus_next()
pmacs.window.display(DOC_B, {})",
);
let (_origin, panel) = focused_panel(&s);
let (document, doc_buf) = {
let core = s.core.borrow();
let win = core
.primary_document_window(FrontendId::LOCAL)
.expect("a primary document window");
(win, core.windows[&win].buffer_id)
};
assert_ne!(document, panel);
let mut sem = SemanticRenderState::for_peer(FrontendId::LOCAL, 18);
sem.set_viewport(doc_buf, ByteRange { start: 0, end: 0 }, 0);
// Seed a baseline payload so a CLEAR is observable as a change.
exec(
&s,
r"_G.SL_SEED = pmacs.statusline.register {
name='seed', side='left', priority=10,
fn=function() return 'OLD' end,
}",
);
let seeded = sem.render_frame(&s);
assert!(
seeded
.iter()
.any(|m| matches!(m, InstanceMessage::StatuslineSegments { .. })),
"non-vacuity: a baseline payload must exist before we test its clear"
);
// A provider that unregisters itself (making the evaluation
// Invalidated) AND closes the captured document window. `close()`
// closes the ACTIVE window and Lua has no focus-by-id, so step
// around the ring until the captured buffer is current.
s.lua_host
.lua()
.globals()
.set("TARGET_BUF", pmacs::lua_bindings::BufferIdLua(doc_buf))
.expect("expose the target buffer");
exec(
&s,
r"_G.SL_CLOSER = pmacs.statusline.register {
name='closer', side='left', priority=100,
fn=function()
pmacs.statusline.unregister(SL_CLOSER)
for _ = 1, 8 do
if pmacs.window.buffer() == TARGET_BUF then break end
pmacs.window.focus_next()
end
pmacs.window.close()
return 'STALE'
end,
}",
);
let msgs = sem.render_frame(&s);
// The fixture must actually have changed the identity, or this test
// discriminates nothing.
assert_ne!(
s.core.borrow().primary_document_window(FrontendId::LOCAL),
Some(document),
"fixture: the callback must really have changed the document identity"
);
let cleared = msgs.iter().any(|m| match m {
InstanceMessage::StatuslineSegments {
buffer_id,
left,
right,
..
} => *buffer_id == doc_buf && left.is_empty() && right.is_empty(),
_ => false,
});
assert!(
cleared,
"an invalidated evaluation must still publish the authoritative EMPTY clear for \
the phase-1 document identity, even when a callback closed that window; got {msgs:?}"
);
}

View File

@ -0,0 +1,595 @@
//! Bottom-panel Stage 2B — the v21 protocol slice.
//!
//! Covers parent acceptance 37 (round-trip plus the two byte pins) and
//! the shared/terminal-only validator split of Q#BP15. The daemon
//! projection, the epoch state machine, and the GPU band are later
//! slices of this stage and are not exercised here.
mod common;
use std::time::Duration;
use pmacs_protocol::cell::{Cell, CellCoord, CellSize, Color, Glyph, Style, UnderlineStyle};
use pmacs_protocol::message::{
AttachRequest, FrontendEvent, Hello, InstanceMessage, Modifiers, MouseButton, MouseKind,
};
use pmacs_protocol::panel::{
MAX_PANEL_VISIBLE_CELLS, PanelFrame, PanelFrameError, PanelFramePayload,
};
use pmacs_protocol::terminal::{
MAX_TERMINAL_COLS, TerminalFrame, TerminalFrameError, TerminalProcessState,
};
use pmacs_protocol::transport::{MAX_FRAME_BYTES, read_message, write_message};
use pmacs_protocol::wire_grid::{MAX_WIRE_GRID_GLYPH_BYTES, MAX_WIRE_GRID_GRAPHEME_BYTES};
use pmacs_protocol::{
ADVERTISED_PROTOCOL_VERSION, BufferId, FrontendId, PROTOCOL_VERSION,
SUPPORTED_PROTOCOL_VERSIONS,
};
use common::daemon::{TestDaemon, build_default_caps};
fn cell(ch: char) -> Cell {
Cell {
glyph: Glyph::Char(ch),
style: Style::default(),
attachment: None,
}
}
/// The style whose postcard encoding is as long as a legal `Style` gets.
fn maximal_style() -> Style {
Style {
fg: Color::Rgb(0xff, 0xee, 0xdd),
bg: Color::Rgb(0x11, 0x22, 0x33),
bold: true,
italic: true,
underline: UnderlineStyle::Dashed,
reverse: true,
underline_color: Color::Rgb(0x44, 0x55, 0x66),
}
}
fn maximal_cell(glyph: Glyph) -> Cell {
Cell {
glyph,
style: maximal_style(),
attachment: None,
}
}
/// A single-column cluster of exactly `len` UTF-8 bytes.
fn cluster_of_len(len: usize) -> Vec<u8> {
assert!((1..=MAX_WIRE_GRID_GRAPHEME_BYTES).contains(&len));
let mut text = String::with_capacity(len);
if len % 2 == 1 {
text.push(' ');
} else {
text.push('\u{e9}');
}
while text.len() < len {
text.push('\u{301}');
}
assert_eq!(text.len(), len);
text.into_bytes()
}
fn panel_frame(rows: u32, cols: u32) -> PanelFrame {
PanelFrame {
buffer_id: BufferId::from_raw(9),
panel_epoch: 3,
geometry_epoch: 5,
size: CellSize::new(rows, cols),
cells: vec![cell(' '); (rows * cols) as usize],
cursor: Some(CellCoord::new(0, 0)),
focused: true,
}
}
fn terminal_frame(rows: u32, cols: u32) -> TerminalFrame {
TerminalFrame {
buffer_id: BufferId::from_raw(9),
size: CellSize::new(rows, cols),
cells: vec![cell(' '); (rows * cols) as usize],
cursor: Some(CellCoord::new(0, 0)),
title: None,
screen_generation: 1,
selection: Vec::new(),
scroll_offset: 0,
at_bottom: true,
pid: 1,
process: TerminalProcessState::Running,
}
}
// ---------------------------------------------------------------------------
// 37 — version and round-trip
// ---------------------------------------------------------------------------
#[test]
fn the_panel_stage_takes_protocol_v21() {
assert_eq!(PROTOCOL_VERSION, 21);
assert!(SUPPORTED_PROTOCOL_VERSIONS.contains(&21));
// The wire family is reserved before it is activated: the production
// server-first Hello must remain acceptable to already-shipped v20
// clients throughout the dark protocol and daemon slices.
assert_eq!(ADVERTISED_PROTOCOL_VERSION, 20);
assert!(SUPPORTED_PROTOCOL_VERSIONS.contains(&20));
}
#[test]
fn a_new_daemon_keeps_an_existing_v20_client_attachable() {
let daemon = TestDaemon::spawn();
let mut stream = daemon.connect();
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.expect("set handshake timeout");
// This is the rejection point in an already-shipped client: it reads the
// daemon's unsolicited Hello before it is able to identify its own
// supported range or send AttachRequest.
let hello: Hello = read_message(&mut stream).expect("read daemon Hello");
let v20_client_supported_versions = 6..=20;
assert_eq!(hello.protocol_version, ADVERTISED_PROTOCOL_VERSION);
assert!(
v20_client_supported_versions.contains(&hello.protocol_version),
"an existing v20 client would reject the server-first Hello"
);
write_message(
&mut stream,
&AttachRequest {
protocol_version: hello.protocol_version,
frontend_capabilities: build_default_caps(),
initial_size: CellSize::new(24, 80),
},
)
.expect("write v20 AttachRequest");
assert!(
matches!(
read_message::<InstanceMessage>(&mut stream).expect("read initial grid"),
InstanceMessage::CellDelta {
full_grid: true,
..
}
),
"the daemon must establish the v20 session, not merely send an acceptable Hello"
);
}
#[test]
fn a_present_panel_frame_round_trips_with_both_epochs() {
let frame = panel_frame(2, 3);
let msg = InstanceMessage::PanelFrame(PanelFramePayload::Present(frame.clone()));
let bytes = postcard::to_allocvec(&msg).expect("encode");
let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode");
let InstanceMessage::PanelFrame(PanelFramePayload::Present(got)) = decoded else {
panic!("expected a Present panel frame, got {decoded:?}");
};
// Both epochs must survive: they are the identities every later
// panel event validates against, so a frame that round-trips its
// cells but drops an epoch would silently accept stale input.
assert_eq!(got.panel_epoch, frame.panel_epoch);
assert_eq!(got.geometry_epoch, frame.geometry_epoch);
assert_eq!(got.buffer_id, frame.buffer_id);
assert_eq!(got.size, frame.size);
assert_eq!(got.cells, frame.cells);
assert_eq!(got.cursor, frame.cursor);
assert_eq!(got.focused, frame.focused);
}
#[test]
fn an_absent_panel_payload_round_trips_as_its_own_state() {
let msg = InstanceMessage::PanelFrame(PanelFramePayload::Absent);
let bytes = postcard::to_allocvec(&msg).expect("encode");
let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode");
assert!(matches!(
decoded,
InstanceMessage::PanelFrame(PanelFramePayload::Absent)
));
// Absent must be distinguishable from a Present frame carrying no
// cells: it is authoritative, and conflating the two would make
// "hide the band" indistinguishable from "paint an empty band".
let empty_present = InstanceMessage::PanelFrame(PanelFramePayload::Present(panel_frame(1, 1)));
assert_ne!(
postcard::to_allocvec(&empty_present).expect("encode"),
bytes
);
}
#[test]
fn the_three_panel_events_round_trip() {
let fid = FrontendId(4);
let events = vec![
FrontendEvent::FrontendCellGeometry {
frontend_id: fid,
geometry_epoch: 1,
total: CellSize::new(40, 120),
},
FrontendEvent::PanelResizeRows {
frontend_id: fid,
geometry_epoch: 2,
panel_epoch: 7,
rows: 12,
},
FrontendEvent::PanelPointer {
frontend_id: fid,
geometry_epoch: 2,
panel_epoch: 7,
buffer_id: BufferId::from_raw(21),
coord: CellCoord::new(3, 9),
kind: MouseKind::Down(MouseButton::Left),
mods: Modifiers::default(),
},
];
for event in events {
let bytes = postcard::to_allocvec(&event).expect("encode");
let decoded: FrontendEvent = postcard::from_bytes(&bytes).expect("decode");
assert_eq!(decoded, event);
assert_eq!(decoded.frontend_id(), fid);
}
}
#[test]
fn panel_pointer_carries_buffer_id_distinctly_from_panel_epoch() {
// The two fields close different holes and neither subsumes the
// other: `buffer_id` catches an A->B buffer replacement, while
// `panel_epoch` catches close/hide/reopen of the SAME buffer, which
// a buffer id alone cannot see. So each must independently reach the
// wire — a field silently dropped from the encoding would let one of
// those two stale gestures through.
let base = |buffer: u64, panel_epoch: u64| FrontendEvent::PanelPointer {
frontend_id: FrontendId(4),
geometry_epoch: 2,
panel_epoch,
buffer_id: BufferId::from_raw(buffer),
coord: CellCoord::new(1, 1),
kind: MouseKind::Down(MouseButton::Left),
mods: Modifiers::default(),
};
let encode = |e: &FrontendEvent| postcard::to_allocvec(e).expect("encode");
// Same panel epoch, different buffer: must differ on the wire.
assert_ne!(encode(&base(1, 7)), encode(&base(2, 7)));
// Same buffer, different panel epoch: must also differ.
assert_ne!(encode(&base(1, 7)), encode(&base(1, 8)));
// And both survive decode rather than being defaulted.
let event = base(31, 7);
let decoded: FrontendEvent = postcard::from_bytes(&encode(&event)).expect("decode");
let FrontendEvent::PanelPointer {
buffer_id,
panel_epoch,
..
} = decoded
else {
panic!("expected a PanelPointer, got {decoded:?}");
};
assert_eq!(buffer_id, BufferId::from_raw(31));
assert_eq!(panel_epoch, 7);
}
// ---------------------------------------------------------------------------
// 37 — byte pins on the previous final variant of each extended enum
// ---------------------------------------------------------------------------
#[test]
fn appending_panel_frame_does_not_move_the_previous_final_instance_discriminant() {
// `InitialTargetResult` was the final v20 variant. Its encoding must
// be byte-identical after `PanelFrame` is appended; if the new
// variant were inserted anywhere earlier, this leading discriminant
// byte would shift and every v20 peer would misread the wire.
let msg = InstanceMessage::InitialTargetResult(
pmacs_protocol::message::InitialTargetResult::Opened {
buffer_id: BufferId::from_raw(1),
},
);
let bytes = postcard::to_allocvec(&msg).expect("encode");
assert_eq!(
bytes[0], 27,
"InitialTargetResult must stay discriminant 27; got {bytes:?}"
);
// And the appended variant must be the next one, not a reused slot.
let panel = InstanceMessage::PanelFrame(PanelFramePayload::Absent);
let panel_bytes = postcard::to_allocvec(&panel).expect("encode");
assert_eq!(panel_bytes[0], 28);
}
#[test]
fn appending_panel_events_does_not_move_the_previous_final_event_discriminant() {
// `TerminalPointer` was the final v19/v20 variant of `FrontendEvent`.
let event = FrontendEvent::TerminalPointer {
frontend_id: FrontendId(2),
buffer_id: BufferId::from_raw(3),
coord: CellCoord::new(1, 1),
kind: MouseKind::Down(MouseButton::Left),
mods: Modifiers::default(),
};
let bytes = postcard::to_allocvec(&event).expect("encode");
assert_eq!(
bytes[0], 12,
"TerminalPointer must stay discriminant 12; got {bytes:?}"
);
// The three appended events take the next three slots, in order.
let fid = FrontendId(2);
for (expected, event) in [
(
13u8,
FrontendEvent::FrontendCellGeometry {
frontend_id: fid,
geometry_epoch: 1,
total: CellSize::new(1, 1),
},
),
(
14,
FrontendEvent::PanelResizeRows {
frontend_id: fid,
geometry_epoch: 1,
panel_epoch: 1,
rows: 1,
},
),
(
15,
FrontendEvent::PanelPointer {
frontend_id: fid,
geometry_epoch: 1,
panel_epoch: 1,
buffer_id: BufferId::from_raw(1),
coord: CellCoord::new(0, 0),
kind: MouseKind::Down(MouseButton::Left),
mods: Modifiers::default(),
},
),
] {
let bytes = postcard::to_allocvec(&event).expect("encode");
assert_eq!(bytes[0], expected, "wrong discriminant for {event:?}");
}
}
// ---------------------------------------------------------------------------
// 39 — the shared/terminal-only validator split
// ---------------------------------------------------------------------------
#[test]
fn a_panel_wider_than_512_columns_is_legal_while_a_terminal_is_not() {
let wide = u32::from(MAX_TERMINAL_COLS) + 1;
// The panel does not inherit the PTY per-axis cap: a 4K surface at a
// small font is legitimately this wide, and the area bound is what
// keeps the encoding inside the transport budget.
let panel = panel_frame(1, wide);
assert_eq!(panel.validate(), Ok(()));
// The terminal keeps it, and reports the axis that failed.
let terminal = terminal_frame(1, wide);
assert!(matches!(
terminal.validate(),
Err(TerminalFrameError::Size { cols, max_cols, .. })
if cols == wide && max_cols == u32::from(MAX_TERMINAL_COLS)
));
}
#[test]
fn a_panel_still_answers_to_the_shared_area_bound() {
// Removing the per-axis cap must not remove the area bound: that is
// the check that actually bounds the encoded size.
let huge = panel_frame(1, 1);
let mut huge = huge;
huge.size = CellSize::new(1024, 1024);
huge.cells = vec![cell(' '); 1];
assert!(matches!(huge.validate(), Err(PanelFrameError::Area { .. })));
}
#[test]
fn a_panel_cell_carrying_an_attachment_is_rejected() {
// The attachment rejection is SHARED, not terminal-only, even though
// the terminal-side message says "which terminals never use":
// panels render no attachments either, so a shared rejection fails
// closed for both.
let mut frame = panel_frame(1, 2);
frame.cells[1].attachment = Some(pmacs_protocol::cell::Attachment::ImageCell {
image_id: 1,
sub_x: 0,
sub_y: 0,
});
assert!(matches!(
frame.validate(),
Err(PanelFrameError::Attachment { index: 1 })
));
}
#[test]
fn panel_glyph_topology_matches_the_terminal_rules() {
// A wide lead with no continuation column on its row is rejected the
// same way for both messages — the topology rule is shared.
let mut frame = panel_frame(1, 1);
frame.cells[0] = cell('\u{4e00}');
assert!(matches!(
frame.validate(),
Err(PanelFrameError::Glyph { .. })
));
let mut terminal = terminal_frame(1, 1);
terminal.cells[0] = cell('\u{4e00}');
assert!(matches!(
terminal.validate(),
Err(TerminalFrameError::Glyph { .. })
));
}
#[test]
fn a_panel_cursor_outside_its_grid_is_rejected() {
let mut frame = panel_frame(2, 2);
frame.cursor = Some(CellCoord::new(2, 0));
assert!(matches!(
frame.validate(),
Err(PanelFrameError::Cursor {
row: 2,
rows: 2,
..
})
));
}
#[test]
fn a_zero_epoch_panel_frame_is_rejected_on_the_wire() {
// Epoch 0 is reserved for "never declared" (Q#BP2S1), so a frame
// carrying it could otherwise match a receiver that has declared
// nothing yet.
let mut frame = panel_frame(1, 1);
frame.panel_epoch = 0;
assert!(matches!(
frame.validate(),
Err(PanelFrameError::ZeroEpoch { field: "panel" })
));
let mut frame = panel_frame(1, 1);
frame.geometry_epoch = 0;
assert!(matches!(
frame.validate(),
Err(PanelFrameError::ZeroEpoch { field: "geometry" })
));
}
#[test]
fn terminal_frames_are_unchanged_by_the_factoring() {
// The shared validator must not have altered terminal acceptance:
// a valid frame still validates, and each terminal-only rule still
// reports its own variant.
assert_eq!(terminal_frame(3, 4).validate(), Ok(()));
let mut bad_bottom = terminal_frame(1, 1);
bad_bottom.at_bottom = false;
bad_bottom.scroll_offset = 0;
assert!(matches!(
bad_bottom.validate(),
Err(TerminalFrameError::BottomState { .. })
));
let mut bad_meta = terminal_frame(1, 1);
bad_meta.title = Some("\u{7}".into());
assert!(matches!(
bad_meta.validate(),
Err(TerminalFrameError::Metadata { field: "title", .. })
));
let mut bad_count = terminal_frame(2, 2);
bad_count.cells.pop();
assert!(matches!(
bad_count.validate(),
Err(TerminalFrameError::CellCount {
expected: 4,
actual: 3
})
));
}
// ---------------------------------------------------------------------------
// 39 — the transport-safety ratchet
// ---------------------------------------------------------------------------
/// The largest legal panel frame, plus the same frame one glyph byte over.
///
/// Deliberately shaped `1 x MAX_PANEL_VISIBLE_CELLS`: a panel carries no
/// per-axis cap, so this is a legal panel geometry a terminal frame
/// cannot express, and it is therefore the worst case the terminal's own
/// ratchet never measured.
fn panel_budget_boundary_frames() -> (PanelFrame, PanelFrame) {
/// Shortest cluster length postcard encodes with a two-byte length
/// prefix, which is what makes a cluster cell maximally expensive.
const WIDE_PREFIX_LEN: usize = 128;
let area = MAX_PANEL_VISIBLE_CELLS;
// Every cell owes at least one glyph byte; the rest of the budget is
// spent on as many two-byte-prefix clusters as it affords.
let spare = MAX_WIRE_GRID_GLYPH_BYTES - area;
let wide_cells = spare / (WIDE_PREFIX_LEN - 1);
let remainder = spare % (WIDE_PREFIX_LEN - 1);
assert!(wide_cells + usize::from(remainder > 0) <= area);
let wide = cluster_of_len(WIDE_PREFIX_LEN).into_boxed_slice();
let single = cluster_of_len(1).into_boxed_slice();
let mut cells = Vec::with_capacity(area);
for index in 0..area {
let glyph = if index < wide_cells {
Glyph::Cluster(wide.clone())
} else if index == wide_cells && remainder > 0 {
Glyph::Cluster(cluster_of_len(remainder + 1).into_boxed_slice())
} else {
Glyph::Cluster(single.clone())
};
cells.push(maximal_cell(glyph));
}
let cols = u32::try_from(area).expect("area fits u32");
let exact = PanelFrame {
buffer_id: BufferId::from_raw(u64::MAX),
panel_epoch: u64::MAX,
geometry_epoch: u64::MAX,
size: CellSize::new(1, cols),
cells,
cursor: Some(CellCoord::new(0, cols - 1)),
focused: true,
};
let mut over = exact.clone();
// One more byte of glyph, nothing else changed.
let last = over.cells.len() - 1;
over.cells[last] = maximal_cell(Glyph::Cluster(cluster_of_len(2).into_boxed_slice()));
(exact, over)
}
#[test]
fn maximum_legal_panel_frame_encodes_below_the_transport_cap() {
let (exact, over) = panel_budget_boundary_frames();
assert_eq!(exact.validate(), Ok(()));
// The fixture must actually sit ON the boundary, or the ratchet
// below measures something smaller than the worst case and would
// stay green while a real maximum frame overran the transport.
let mut glyph_bytes = 0usize;
for cell in &exact.cells {
glyph_bytes += match &cell.glyph {
Glyph::Char(ch) => ch.len_utf8(),
Glyph::Cluster(bytes) => bytes.len(),
Glyph::Continuation => 0,
};
}
assert_eq!(
glyph_bytes, MAX_WIRE_GRID_GLYPH_BYTES,
"the measured fixture must spend the whole aggregate budget"
);
let over_glyph_bytes = over
.cells
.iter()
.map(|cell| match &cell.glyph {
Glyph::Char(ch) => ch.len_utf8(),
Glyph::Cluster(bytes) => bytes.len(),
Glyph::Continuation => 0,
})
.sum::<usize>();
assert_eq!(
over_glyph_bytes,
MAX_WIRE_GRID_GLYPH_BYTES + 1,
"the rejecting twin must be exactly one byte over the aggregate budget"
);
// One byte over is rejected, which is what makes `exact` maximal.
assert!(matches!(
over.validate(),
Err(PanelFrameError::GlyphBudget { .. })
));
let msg = InstanceMessage::PanelFrame(PanelFramePayload::Present(exact));
let bytes = postcard::to_allocvec(&msg).expect("encode");
assert!(
bytes.len() < MAX_FRAME_BYTES,
"largest legal panel frame encodes to {} bytes, at or above the \
{MAX_FRAME_BYTES}-byte transport cap; the aggregate glyph bound no \
longer keeps panel traffic inside the existing transport limit",
bytes.len()
);
}

View File

@ -26,7 +26,7 @@ use tempfile::TempDir;
#[cfg(feature = "crdt")]
use pmacs::cell::CellSize;
#[cfg(feature = "crdt")]
use pmacs::protocol::{AttachRequest, PROTOCOL_VERSION};
use pmacs::protocol::AttachRequest;
use pmacs::protocol::{FrontendCapabilities, Hello};
use pmacs::transport::read_message;
#[cfg(feature = "crdt")]
@ -294,7 +294,7 @@ pub fn attach_multi(daemon: &TestDaemon) -> (Hello, UnixStream) {
.unwrap();
let hello: Hello = read_message(&mut stream).expect("read Hello");
let req = AttachRequest {
protocol_version: PROTOCOL_VERSION,
protocol_version: hello.protocol_version,
frontend_capabilities: multi_frontend_caps(),
initial_size: CellSize::new(24, 80),
};

View File

@ -93,9 +93,9 @@ mod crdt {
use pmacs::cell::CellSize;
use pmacs::crdt::CrdtState;
use pmacs::protocol::{
AttachRequest, FrontendCapabilities, FrontendEvent, FrontendId, Hello, InitialTarget,
InitialTargetResult, InstanceCapabilities, InstanceIdentity, InstanceMessage,
PROTOCOL_VERSION, SessionBootstrapRequest,
ADVERTISED_PROTOCOL_VERSION, AttachRequest, FrontendCapabilities, FrontendEvent,
FrontendId, Hello, InitialTarget, InitialTargetResult, InstanceCapabilities,
InstanceIdentity, InstanceMessage, PROTOCOL_VERSION, SessionBootstrapRequest,
};
use pmacs::transport::{read_message, write_message};
@ -121,6 +121,19 @@ mod crdt {
.collect()
}
fn decode_hex(encoded: &str) -> String {
assert_eq!(encoded.len() % 2, 0, "hex payload must have even length");
let bytes = encoded
.as_bytes()
.chunks_exact(2)
.map(|pair| {
let pair = std::str::from_utf8(pair).expect("hex pair is UTF-8");
u8::from_str_radix(pair, 16).expect("decode hex pair")
})
.collect::<Vec<_>>();
String::from_utf8(bytes).expect("snapshot text is UTF-8")
}
fn wait_for_fact(
report: &Path,
key: &str,
@ -231,11 +244,11 @@ mod crdt {
.set_read_timeout(Some(Duration::from_secs(5)))
.expect("set target frontend timeout");
let hello: Hello = read_message(&mut stream).expect("target frontend Hello");
assert_eq!(hello.protocol_version, PROTOCOL_VERSION);
assert_eq!(hello.protocol_version, ADVERTISED_PROTOCOL_VERSION);
write_message(
&mut stream,
&AttachRequest {
protocol_version: PROTOCOL_VERSION,
protocol_version: hello.protocol_version,
frontend_capabilities: FrontendCapabilities {
multi_frontend: true,
crdt_replica: true,
@ -356,6 +369,16 @@ mod crdt {
}
impl ManagedProbe {
fn from_child(mut child: Child, report: &Path) -> Self {
let stdin = child.stdin.take().expect("probe stdin");
Self {
child,
stdin: Some(stdin),
report: report.to_owned(),
daemon_pid: None,
}
}
fn spawn(socket: &Path, report: &Path, daemon_executable: &Path, home: &Path) -> Self {
Self::spawn_with_env(socket, report, daemon_executable, home, &[])
}
@ -418,18 +441,11 @@ mod crdt {
for (key, value) in envs {
command.env(key, value);
}
let mut child = command.spawn().expect("spawn managed probe");
let stdin = child.stdin.take().expect("probe stdin");
Self {
child,
stdin: Some(stdin),
report: report.to_owned(),
daemon_pid: None,
}
Self::from_child(command.spawn().expect("spawn managed probe"), report)
}
fn wait_ready(&mut self) -> HashMap<String, String> {
let facts = wait_for_fact(&self.report, "phase", "ready", Duration::from_secs(10));
fn wait_for(&mut self, key: &str, expected: &str) -> HashMap<String, String> {
let facts = wait_for_fact(&self.report, key, expected, Duration::from_secs(10));
if facts
.get("spawned_daemon")
.is_some_and(|value| value == "true")
@ -439,6 +455,10 @@ mod crdt {
facts
}
fn wait_ready(&mut self) -> HashMap<String, String> {
self.wait_for("phase", "ready")
}
fn close(mut self) -> std::process::ExitStatus {
self.stdin.take();
wait_for_fact(&self.report, "phase", "complete", Duration::from_secs(5));
@ -563,7 +583,7 @@ mod crdt {
facts
.get("server_protocol_version")
.and_then(|value| value.parse::<u32>().ok()),
Some(PROTOCOL_VERSION)
Some(ADVERTISED_PROTOCOL_VERSION)
);
assert_eq!(
facts.get("spawned_daemon").map(String::as_str),
@ -662,6 +682,75 @@ mod crdt {
assert!(probe.close().success());
}
#[test]
fn public_gpu_directory_target_reaches_dired_and_leaves_the_daemon_usable() {
let temp = secure_tempdir();
let socket = temp.path().join("directory-target.sock");
let report = temp.path().join("directory-target-report");
let wrapper = temp.path().join("headless-gpu");
let listed_name = "listed-before-bootstrap.txt";
fs::write(temp.path().join(listed_name), "listed\n").expect("write listed file");
write_script(
&wrapper,
"test \"$1\" = \"--managed-attach\"\n\
socket=$2\n\
daemon=$3\n\
shift 3\n\
exec \"$PMACS_REAL_GPU\" --headless-managed-probe \
\"$socket\" \"$PMACS_TEST_REPORT\" \"$daemon\" \"$@\"",
);
let mut command = Command::new(pmacs_binary());
command
.args(["--gpu", "--socket"])
.arg(&socket)
.arg(".")
.current_dir(temp.path())
.env(TEST_GPU_OVERRIDE, &wrapper)
.env("PMACS_REAL_GPU", gpu_binary())
.env("PMACS_TEST_REPORT", &report)
.env("HOME", temp.path())
.env("XDG_CONFIG_HOME", temp.path())
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null());
let mut directory =
ManagedProbe::from_child(command.spawn().expect("spawn public GPU command"), &report);
// The public root broker and real managed GPU connector must stay
// alive through Journey N2's asynchronous dired commit. Snapshot
// one is the deliberately pre-existing bootstrap document; snapshot
// two is the post-quiescence directory surface.
// Capture the spawned daemon's PID from the ready report first so
// ManagedProbe::drop can terminate it if snapshot two never arrives.
let ready = directory.wait_ready();
assert_eq!(
ready.get("spawned_daemon").map(String::as_str),
Some("true")
);
let facts = directory.wait_for("buffer_snapshots", "2");
let listing = decode_hex(&facts["last_snapshot_hex"]);
let canonical = fs::canonicalize(temp.path()).expect("canonical directory");
let mut lines = listing.lines();
let expected_header = format!("{}:", canonical.display());
assert_eq!(
lines.next(),
Some(expected_header.as_str()),
"the replacement snapshot must be dired's directory surface:\n{listing}"
);
assert!(
lines.any(|line| line.trim_end().ends_with(listed_name)),
"dired must list the file that existed before bootstrap:\n{listing}"
);
fs::write(temp.path().join("still-alive.txt"), "alive\n").expect("write survivor");
let survivor = attach_target(&socket, temp.path(), Path::new("still-alive.txt"));
assert_eq!(survivor.replica.materialize_string(), "alive\n");
drop(survivor);
assert!(directory.close().success());
}
#[test]
fn malformed_or_unloadable_targets_fail_closed_without_poisoning_the_daemon() {
let temp = secure_tempdir();
@ -673,7 +762,6 @@ mod crdt {
(cwd.clone(), Vec::new()),
(cwd.clone(), b"bad\0name".to_vec()),
(cwd.clone(), vec![b'x'; 32 * 1024 + 1]),
(cwd.clone(), b".".to_vec()),
];
for (index, (bad_cwd, bad_path)) in invalid.into_iter().enumerate() {
let (frontend_id, mut stream, messages) = open_raw_target(&socket, bad_cwd, bad_path);

1299
tests/journey_acceptance.rs Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -305,36 +305,50 @@ fn acc11b_an_unknown_fence_name_still_injects_nothing() {
// ---------------------------------------------------------------------------
#[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).
fn acc12_opening_lean_spawns_no_process_without_a_server_config() {
// **Superseded in half by Stage 3b.** This criterion originally also
// asserted `pmacs.lsp.config.lean4 == nil`, guarding against a Stage-3
// front-run. Stage 3b *is* Stage 3: `builtin/runtime/lean.lua` now ships
// that config deliberately, and its shape is pinned by
// `tests/lean4_server_acceptance.rs`. Asserting the absence here would
// now pin the opposite of the intended behavior, so it is gone rather
// than weakened.
//
// What survives is the half that was always about *restraint*, and it
// matters more now than it did in Stage 1 — it is what holds Q#LN7's
// "not at init" promise. `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. Both the `lake serve` spawn and the
// `lake --version` probe are gated on a real Lean attachment.
// 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.
// Constructing an editor touches no process, even though the Lean
// config now exists and names `lake`.
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"
let at_init: i64 = eval(&pristine, "return #pmacs.process.list()");
assert_eq!(
at_init, 0,
"constructing an editor must not probe or spawn for Lean"
);
// Non-vacuity for the assertion above: the config really is present and
// really does name a command, so "nothing spawned" is restraint rather
// than an empty table having nothing to act on.
let names_lake: bool = eval(
&pristine,
"return pmacs.lsp.config.lean4 ~= nil and pmacs.lsp.config.lean4.command == \"lake\"",
);
// 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"
names_lake,
"Stage 3b ships a lean4 config naming `lake`, so the no-spawn \
assertion 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.
// And opening a Lean buffer with no server configured spawns nothing —
// the `editor()` helper wipes `pmacs.lsp.config`, so this catches a
// probe that fires off the mode rather than off an attachment.
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");
assert_eq!(
procs, 0,
"with no server configured, opening a Lean buffer spawns nothing"
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,724 @@
//! Arc 8 Stage 3a acceptance — LSP notification/response dispatch seams
//! and `pmacs.fs.canonicalize`.
//!
//! `docs/lean4-mode-framing.md` Q#LN9 and Q#LN20, acceptance 2934 plus
//! 34a/34b.
//!
//! This suite deliberately contains **no Lean content**.
//! `handle_server_requests` (`builtin/runtime/lsp.lua`) is the single
//! LSP event drain for every language in pmacs, so the change is
//! exercised through an already-shipped language driven against
//! `pmacs_fake_lsp`. A suite that reached the drain only through Lean
//! would understate the blast radius — the same reasoning that shaped
//! Stage 2's suite.
//!
//! Every fixture calls `pmacs.project.set_search_boundary` at its own
//! tempdir root, so a stray marker above the temp directory cannot make
//! a "markerless" case silently detected.
use std::path::{Path, PathBuf};
use std::time::Duration;
use pmacs::editor::EditorState;
fn exec(state: &EditorState, source: &str) {
state.lua_host.lua().load(source.to_owned()).exec().unwrap();
}
fn eval<T: mlua::FromLuaMulti>(state: &EditorState, source: &str) -> T {
state.lua_host.lua().load(source.to_owned()).eval().unwrap()
}
fn fake_lsp_path() -> String {
env!("CARGO_BIN_EXE_pmacs_fake_lsp").to_owned()
}
/// A fresh editor with the shipped language configs cleared, so the only
/// server any test can spawn is the fake one it configures itself.
fn editor() -> EditorState {
let state = EditorState::new();
exec(&state, "pmacs.lsp.config = {}");
state
}
fn lua_str(path: &Path) -> String {
path.display()
.to_string()
.replace('\\', "\\\\")
.replace('"', "\\\"")
}
struct Fixture {
_dir: tempfile::TempDir,
root: PathBuf,
}
impl Fixture {
fn new() -> Self {
let dir = tempfile::tempdir().unwrap();
let root = std::fs::canonicalize(dir.path()).unwrap();
Self { _dir: dir, root }
}
fn write(&self, rel: &str, contents: &str) -> PathBuf {
let path = self.root.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, contents).unwrap();
path
}
fn dir(&self, rel: &str) -> PathBuf {
self.root.join(rel)
}
fn bind(&self, state: &EditorState) {
exec(
state,
&format!(
"pmacs.project.set_search_boundary(\"{}\")",
lua_str(&self.root)
),
);
}
}
fn configure(state: &EditorState, language: &str) {
exec(
state,
&format!(
"pmacs.lsp.config.{language} = {{ command = \"{}\" }}",
fake_lsp_path()
),
);
}
fn open(state: &EditorState, path: &Path) {
exec(
state,
&format!("pmacs.buffer.find_or_open(\"{}\")", lua_str(path)),
);
}
/// `tick_async` is what drives the drain: `handle_server_requests` is
/// wrapped onto `pmacs._async.tick`, so a settle loop without it moves
/// the LSP state machine while never delivering a single event to Lua.
fn settle(state: &mut EditorState) {
for _ in 0..8 {
state.tick_processes();
state.tick_lsp();
state.tick_async();
std::thread::sleep(Duration::from_millis(2));
}
}
/// A rust project with one file, an attached fake server, and the
/// probes below installed. Returns the opened file's path.
fn attached_rust(state: &mut EditorState, fx: &Fixture) -> PathBuf {
fx.write("proj/Cargo.toml", "[package]\nname = \"p\"\n");
let file = fx.write("proj/src/main.rs", "fn main() {}\nlet x = 1;\n");
fx.bind(state);
configure(state, "rust");
open(state, &file);
settle(state);
file
}
/// The sid of the single live server, as a Lua expression fragment.
const THE_SID: &str = "pmacs.lsp.list()[1].id";
// ---------------------------------------------------------------------------
// Acceptance 29 — a notification reaches a registered subscriber.
// ---------------------------------------------------------------------------
#[test]
fn acc29_notification_reaches_a_registered_subscriber() {
let fx = Fixture::new();
let mut state = editor();
// Registered BEFORE the open, so the didOpen-triggered `pmacs/echo`
// is in the first drain.
exec(
&state,
r#"
_G.seen = {}
pmacs.lsp.on_notification("pmacs/echo", function(sid, params)
_G.seen[#_G.seen + 1] = tostring(params and params.uri)
end)
"#,
);
attached_rust(&mut state, &fx);
let n: i64 = eval(&state, "return #_G.seen");
assert!(
n >= 1,
"expected at least one pmacs/echo notification, got {n}"
);
let first: String = eval(&state, "return _G.seen[1]");
assert!(
first.starts_with("file://") && first.ends_with("main.rs"),
"subscriber got the document uri; saw {first:?}"
);
}
#[test]
fn acc29_subscriber_for_an_unsent_method_does_not_fire() {
let fx = Fixture::new();
let mut state = editor();
exec(
&state,
r#"
_G.hits = 0
pmacs.lsp.on_notification("pmacs/never", function() _G.hits = _G.hits + 1 end)
"#,
);
attached_rust(&mut state, &fx);
// Non-vacuity for acc29: the seam is method-keyed, not a firehose.
// Without this, a subscriber invoked for every notification would
// pass the test above while being wrong.
let hits: i64 = eval(&state, "return _G.hits");
assert_eq!(hits, 0, "a subscriber must only fire for its own method");
}
// ---------------------------------------------------------------------------
// Acceptance 30 + 33 — dispatch integrity: with subscribers registered,
// a `workspace/applyEdit` request in the same drain is still handled.
//
// The fake server writes the applyEdit request and the executeCommand
// response back to back, so both land in one `events_take` batch. That
// co-occurrence is the point: a seam that consumed the batch, or that
// returned early, would starve the `request` arms that share it.
// ---------------------------------------------------------------------------
fn drive_apply_edit(state: &mut EditorState, file: &Path) {
exec(
state,
&format!(
r#"
local sid = {THE_SID}
local uri = "file://{}"
_G.rid = pmacs.lsp.send_request(sid, "workspace/executeCommand", {{
command = "pmacs.fake.applyEdit",
arguments = {{ uri }},
}})
_G.response_hits = 0
pmacs.lsp.on_response(sid, _G.rid, function(result, err)
_G.response_hits = _G.response_hits + 1
end)
"#,
lua_str(file)
),
);
settle(state);
}
fn buffer_text(state: &EditorState) -> String {
eval(
state,
"local b = pmacs.window.buffer() return b:slice(0, b:len())",
)
}
#[test]
fn acc30_apply_edit_still_handled_with_a_notification_subscriber() {
let fx = Fixture::new();
let mut state = editor();
exec(
&state,
r#"
_G.notes = 0
pmacs.lsp.on_notification("pmacs/echo", function() _G.notes = _G.notes + 1 end)
"#,
);
let file = attached_rust(&mut state, &fx);
assert!(
eval::<i64>(&state, "return _G.notes") >= 1,
"precondition: the notification subscriber is actually firing"
);
drive_apply_edit(&mut state, &file);
assert!(
buffer_text(&state).contains("ED2"),
"workspace/applyEdit must still be applied with a subscriber \
registered; buffer was {:?}",
buffer_text(&state)
);
}
#[test]
fn acc33_apply_edit_still_handled_with_a_response_subscriber() {
let fx = Fixture::new();
let mut state = editor();
let file = attached_rust(&mut state, &fx);
drive_apply_edit(&mut state, &file);
// Both halves in one drain: the response was delivered to its
// one-shot AND the server-originated request was serviced.
assert_eq!(
eval::<i64>(&state, "return _G.response_hits"),
1,
"the executeCommand response reaches its one-shot"
);
assert!(
buffer_text(&state).contains("ED2"),
"workspace/applyEdit must still be applied with a response \
subscriber registered; buffer was {:?}",
buffer_text(&state)
);
}
// ---------------------------------------------------------------------------
// Acceptance 31 — a raising subscriber does not stop later events in the
// same drain (and does not stop the `request` arms either).
// ---------------------------------------------------------------------------
#[test]
fn acc31_raising_notification_subscriber_does_not_stop_the_drain() {
let fx = Fixture::new();
let mut state = editor();
exec(
&state,
r#"
_G.second_hits = 0
pmacs.lsp.on_notification("pmacs/echo", function()
error("subscriber blew up")
end)
pmacs.lsp.on_notification("pmacs/echo", function()
_G.second_hits = _G.second_hits + 1
end)
"#,
);
let file = attached_rust(&mut state, &fx);
assert!(
eval::<i64>(&state, "return _G.second_hits") >= 1,
"a raising subscriber must not starve the ones after it"
);
// And the shared `request` arms still run in a later drain.
drive_apply_edit(&mut state, &file);
assert!(
buffer_text(&state).contains("ED2"),
"a raising subscriber must not stop workspace/applyEdit"
);
}
#[test]
fn acc33_raising_response_handler_does_not_stop_the_drain() {
let fx = Fixture::new();
let mut state = editor();
let file = attached_rust(&mut state, &fx);
exec(
&state,
&format!(
r#"
local sid = {THE_SID}
_G.notes_after = 0
pmacs.lsp.on_notification("pmacs/echo", function()
_G.notes_after = _G.notes_after + 1
end)
local rid = pmacs.lsp.send_request(sid, "workspace/executeCommand", {{
command = "pmacs.fake.applyEdit",
arguments = {{ "file://{}" }},
}})
pmacs.lsp.on_response(sid, rid, function() error("handler blew up") end)
"#,
lua_str(&file)
),
);
settle(&mut state);
assert!(
buffer_text(&state).contains("ED2"),
"a raising response handler must not stop workspace/applyEdit in \
the same drain"
);
}
// ---------------------------------------------------------------------------
// Acceptance 32 — the one-shot is removed exactly once, whether or not
// the handler raises.
//
// Named for what it pins rather than for the framing's wording. Q#LN9
// specifies removal *before* invocation, and the implementation does
// that — but bite-testing showed the before/after ordering is not
// observable on its own: `pcall` catches the raise either way, so
// removal after the call is behaviorally identical unless a handler
// re-enters the drain, which nothing does. What IS observable, and what
// this pins, is that removal is **unconditional**: the bite that moves
// it inside `if ok then` fails here 2 != 1, because the surviving
// registration gets invoked a second time by the purge.
// ---------------------------------------------------------------------------
#[test]
fn acc32_response_one_shot_is_removed_even_when_the_handler_raises() {
let fx = Fixture::new();
let mut state = editor();
attached_rust(&mut state, &fx);
exec(
&state,
&format!(
r#"
local sid = {THE_SID}
_G.calls = 0
local rid = pmacs.lsp.send_request(sid, "test/ping", {{ v = 1 }})
pmacs.lsp.on_response(sid, rid, function(result, err)
_G.calls = _G.calls + 1
error("handler raises after being removed")
end)
"#
),
);
settle(&mut state);
assert_eq!(
eval::<i64>(&state, "return _G.calls"),
1,
"the one-shot fires exactly once for its reply"
);
exec(&state, &format!("pmacs.lsp.stop({THE_SID})"));
settle(&mut state);
assert_eq!(
eval::<i64>(&state, "return _G.calls"),
1,
"a delivered one-shot must not be re-invoked by the purge — \
removal is unconditional, not gated on a clean return"
);
}
#[test]
fn acc32_response_carries_the_servers_result() {
let fx = Fixture::new();
let mut state = editor();
attached_rust(&mut state, &fx);
exec(
&state,
&format!(
r#"
local sid = {THE_SID}
_G.echoed = nil
_G.saw_err = "unset"
local rid = pmacs.lsp.send_request(sid, "test/ping", {{ v = 42 }})
pmacs.lsp.on_response(sid, rid, function(result, err)
_G.echoed = result and result.echo and result.echo.v
_G.saw_err = tostring(err)
end)
"#
),
);
settle(&mut state);
// Non-vacuity: without this the seam could "fire" with nil payloads
// and every count-based assertion above would still pass.
assert_eq!(
eval::<i64>(&state, "return _G.echoed or -1"),
42,
"the handler receives the server's result payload"
);
assert_eq!(
eval::<String>(&state, "return _G.saw_err"),
"nil",
"a successful reply passes nil for err"
);
}
// ---------------------------------------------------------------------------
// Acceptance 34 — the pending purge, driven off `pmacs.lsp.list()` and
// NOT off a death event seen in the drain.
//
// The second test is the load-bearing one. `handle_server_requests`
// builds its sid list from `attachments`, so a server that is in no
// attachment is never drained — and its `stopped` event is therefore
// never seen. A purge wired to that event leaks exactly there.
// ---------------------------------------------------------------------------
#[test]
fn acc34_purge_settles_a_pending_one_shot_when_the_server_dies() {
let fx = Fixture::new();
let mut state = editor();
attached_rust(&mut state, &fx);
exec(
&state,
&format!(
r#"
local sid = {THE_SID}
_G.err_msg = "never called"
-- A method the fake server answers only after a delay would
-- be ideal; instead the server is stopped in the same breath,
-- so the reply can never arrive.
local rid = pmacs.lsp.send_request(sid, "test/slow", {{}})
pmacs.lsp.on_response(sid, rid, function(result, err)
_G.err_msg = tostring(err and err.message)
end)
pmacs.lsp.stop(sid)
"#
),
);
settle(&mut state);
let msg: String = eval(&state, "return _G.err_msg");
assert!(
msg.contains("server gone") || msg == "nil",
"a pending one-shot must be settled, not left waiting; saw {msg:?}"
);
assert_ne!(
msg, "never called",
"the one-shot was never settled — it leaked"
);
}
#[test]
fn acc34_purge_reaches_a_server_that_is_in_no_attachment() {
let fx = Fixture::new();
let mut state = editor();
fx.bind(&state);
// Spawned directly, never attached to a buffer. `attachments` is
// empty, so `handle_server_requests` never visits this sid and its
// `stopped` event is never drained.
exec(
&state,
&format!(
r#"
_G.settled = "never called"
local sid = pmacs.lsp.spawn({{
label = "orphan",
language_id = "rust",
command = "{}",
args = {{}},
}})
_G.orphan = sid
"#,
fake_lsp_path()
),
);
settle(&mut state);
exec(
&state,
r#"
local rid = pmacs.lsp.send_request(_G.orphan, "test/slow", {})
pmacs.lsp.on_response(_G.orphan, rid, function(result, err)
_G.settled = tostring(err and err.message)
end)
pmacs.lsp.stop(_G.orphan)
"#,
);
settle(&mut state);
let settled: String = eval(&state, "return _G.settled");
assert_ne!(
settled, "never called",
"the purge must not depend on the drain reaching this server — \
it is in no attachment, so the drain never does"
);
assert!(
settled.contains("server gone"),
"settled with the purge's error; saw {settled:?}"
);
}
// ---------------------------------------------------------------------------
// Acceptance 34a — `pmacs.fs.canonicalize` (Q#LN20).
// ---------------------------------------------------------------------------
#[test]
#[cfg(unix)]
fn acc34a_canonicalize_resolves_symlinks_and_dot_segments() {
let fx = Fixture::new();
fx.write("pkg/sub/a.txt", "x\n");
// Built here rather than assumed: the whole point is the symlink.
std::os::unix::fs::symlink(fx.dir("pkg"), fx.dir("linkpkg")).unwrap();
let state = editor();
let noncanon = format!("{}/sub/./../sub/a.txt", fx.dir("linkpkg").display());
let got: String = eval(
&state,
&format!("return tostring(pmacs.fs.canonicalize(\"{noncanon}\"))"),
);
let want = fx.root.join("pkg/sub/a.txt").display().to_string();
assert_eq!(got, want, "symlink and dot segments both resolved");
// Falsification for 34b: the uncanonicalized spelling really is
// different, so the affinity test below is not vacuous.
assert_ne!(noncanon, want);
}
#[test]
fn acc34a_canonicalize_returns_nil_for_a_missing_path() {
let fx = Fixture::new();
let state = editor();
let missing = fx.dir("nope/not-here").display().to_string();
let got: String = eval(
&state,
&format!("return tostring(pmacs.fs.canonicalize(\"{missing}\"))"),
);
assert_eq!(
got, "nil",
"a nonexistent path declines rather than raising"
);
}
// ---------------------------------------------------------------------------
// Acceptance 34b — affinity survives a symlinked open.
//
// Asserted at the affinity layer, not just at the binding: the
// regression Q#LN20 exists to prevent is *two servers for one project*,
// and only this shape observes it.
// ---------------------------------------------------------------------------
fn server_count(state: &EditorState) -> i64 {
eval(state, "return #pmacs.lsp.list()")
}
#[test]
#[cfg(unix)]
fn acc34b_canonicalizing_resolver_reuses_one_server_across_a_symlink() {
let fx = Fixture::new();
fx.write("proj/Cargo.toml", "[package]\nname = \"p\"\n");
let real = fx.write("proj/src/main.rs", "fn main() {}\n");
std::os::unix::fs::symlink(fx.dir("proj"), fx.dir("linkproj")).unwrap();
let linked = fx.dir("linkproj").join("src/main.rs");
let mut state = editor();
fx.bind(&state);
exec(
&state,
&format!(
r#"
pmacs.lsp.config.rust = {{
command = "{}",
root = function(path)
local dir = path:match("^(.*)/[^/]*$")
if not dir then return nil end
-- Walk up to the directory holding Cargo.toml, then
-- canonicalize the Q#LN8 shape Stage 3b will use.
while dir and #dir > 0 do
local f = io.open(dir .. "/Cargo.toml", "r")
if f then
f:close()
return pmacs.fs.canonicalize(dir)
end
dir = dir:match("^(.*)/[^/]*$")
end
return nil
end,
}}
"#,
fake_lsp_path()
),
);
open(&state, &real);
settle(&mut state);
assert_eq!(server_count(&state), 1, "the real path spawns one server");
open(&state, &linked);
settle(&mut state);
assert_eq!(
server_count(&state),
1,
"the symlinked path must reuse the same server — two here is the \
exact regression Q#LN20 exists to prevent"
);
}
#[test]
#[cfg(unix)]
fn acc34b_falsified_by_a_resolver_that_skips_canonicalization() {
let fx = Fixture::new();
fx.write("proj/Cargo.toml", "[package]\nname = \"p\"\n");
let real = fx.write("proj/src/main.rs", "fn main() {}\n");
std::os::unix::fs::symlink(fx.dir("proj"), fx.dir("linkproj")).unwrap();
let linked = fx.dir("linkproj").join("src/main.rs");
let mut state = editor();
fx.bind(&state);
// Same resolver, minus the canonicalize call. This is the bite: if
// it also produced one server, the test above would be vacuous and
// `pmacs.fs.canonicalize` would be doing nothing.
exec(
&state,
&format!(
r#"
pmacs.lsp.config.rust = {{
command = "{}",
root = function(path)
local dir = path:match("^(.*)/[^/]*$")
while dir and #dir > 0 do
local f = io.open(dir .. "/Cargo.toml", "r")
if f then f:close() return dir end
dir = dir:match("^(.*)/[^/]*$")
end
return nil
end,
}}
"#,
fake_lsp_path()
),
);
open(&state, &real);
settle(&mut state);
open(&state, &linked);
settle(&mut state);
assert_eq!(
server_count(&state),
2,
"without canonicalization the two spellings key differently and \
spawn two servers this is what 34b's positive case rules out"
);
}
// ---------------------------------------------------------------------------
// Acceptance 34a, non-UTF-8 arm — an unrepresentable resolution declines
// rather than returning a lossy string.
//
// Review finding on PR #167: `display().to_string()` substitutes U+FFFD,
// which would hand back a path that does not exist on disk. That is
// strictly worse than nil here, because the value becomes a
// server-affinity key via `file_uri_for` and would silently fail to
// round-trip. Bites against the `display()` form, which returns a
// non-nil string for this fixture.
//
// **Linux-gated, and `cfg(unix)` was not enough** — CI caught that.
// APFS enforces valid UTF-8 in filenames, so on macOS the `write` below
// fails with EILSEQ ("Illegal byte sequence") before the code under test
// is ever reached: the fixture cannot be built there. That is a
// filesystem refusing to represent the case, not a behavioral
// difference — the subject itself, `to_str()` returning None, is
// platform-independent Rust. Gated explicitly rather than skipped at
// runtime, so a future failure here is a real failure and not a silent
// no-op.
// ---------------------------------------------------------------------------
#[test]
#[cfg(target_os = "linux")]
fn acc34a_canonicalize_declines_a_non_utf8_resolution() {
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt as _;
let fx = Fixture::new();
// 0xFF is not valid UTF-8 in any position.
let raw = OsStr::from_bytes(b"bad-\xffname");
let target = fx.root.join(raw);
std::fs::write(&target, "x\n").unwrap();
// Reached through an ASCII symlink, so the *input* is representable
// and only the resolved output is not — which is the case
// `to_str()` has to catch and a UTF-8-only input check would miss.
let link = fx.dir("ascii-link");
std::os::unix::fs::symlink(&target, &link).unwrap();
let state = editor();
let got: String = eval(
&state,
&format!(
"return tostring(pmacs.fs.canonicalize(\"{}\"))",
lua_str(&link)
),
);
assert_eq!(
got, "nil",
"a resolution that lands on non-UTF-8 bytes must decline, not \
return a U+FFFD-substituted path that exists nowhere"
);
}

View File

@ -37,8 +37,8 @@ use pmacs::cell::Color;
#[cfg(feature = "crdt")]
use pmacs::overlay_color::color_for_slot;
use pmacs::protocol::{
AttachRequest, FrontendCapabilities, FrontendEvent, GoodbyeReason, Hello, InstanceMessage, Key,
KeyEvent, Modifiers, PROTOCOL_VERSION,
ADVERTISED_PROTOCOL_VERSION, AttachRequest, FrontendCapabilities, FrontendEvent, GoodbyeReason,
Hello, InstanceMessage, Key, KeyEvent, Modifiers, PROTOCOL_VERSION,
};
use pmacs::transport::{read_message, write_message};
@ -52,9 +52,9 @@ use common::daemon::{
/// Read the daemon's `Hello`, send our `AttachRequest`, return the Hello.
fn do_handshake(stream: &mut UnixStream) -> Hello {
let hello: Hello = read_message(stream).expect("read Hello");
assert_eq!(hello.protocol_version, PROTOCOL_VERSION);
assert_eq!(hello.protocol_version, ADVERTISED_PROTOCOL_VERSION);
let req = AttachRequest {
protocol_version: PROTOCOL_VERSION,
protocol_version: hello.protocol_version,
frontend_capabilities: build_default_caps(),
initial_size: CellSize::new(24, 80),
};
@ -418,7 +418,7 @@ fn version_mismatch_clean_disconnect() {
// Read Hello.
let hello: Hello = read_message(&mut stream).expect("Hello");
assert_eq!(hello.protocol_version, PROTOCOL_VERSION);
assert_eq!(hello.protocol_version, ADVERTISED_PROTOCOL_VERSION);
// Send AttachRequest with wrong protocol version.
let req = AttachRequest {
@ -431,7 +431,7 @@ fn version_mismatch_clean_disconnect() {
// Expect Goodbye(VersionMismatch).
match read_message::<InstanceMessage>(&mut stream) {
Ok(InstanceMessage::Goodbye(GoodbyeReason::VersionMismatch { server, client })) => {
assert_eq!(server, PROTOCOL_VERSION);
assert_eq!(server, ADVERTISED_PROTOCOL_VERSION);
assert_eq!(client, 999);
}
other => panic!("expected VersionMismatch Goodbye, got {other:?}"),
@ -1145,9 +1145,9 @@ fn m10_10_production_attach_negotiates_crdt_replica() {
// Production handshake — NOT the test `attach_multi()` path.
let hello: Hello = read_message(&mut stream).expect("read Hello");
assert_eq!(hello.protocol_version, PROTOCOL_VERSION);
assert_eq!(hello.protocol_version, ADVERTISED_PROTOCOL_VERSION);
let req = AttachRequest {
protocol_version: PROTOCOL_VERSION,
protocol_version: hello.protocol_version,
frontend_capabilities: pmacs::attach::build_capabilities(),
initial_size: CellSize::new(24, 80),
};
@ -1183,9 +1183,9 @@ fn m10_10_production_attach_non_crdt_build_does_not_negotiate_crdt_replica() {
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
let _hello: Hello = read_message(&mut stream).expect("read Hello");
let hello: Hello = read_message(&mut stream).expect("read Hello");
let req = AttachRequest {
protocol_version: PROTOCOL_VERSION,
protocol_version: hello.protocol_version,
frontend_capabilities: pmacs::attach::build_capabilities(),
initial_size: CellSize::new(24, 80),
};
@ -2171,7 +2171,7 @@ fn m10_10_f14_production_path_keystroke_flows_to_broadcast() {
.unwrap();
let hello_a: Hello = read_message(&mut stream_a).expect("A Hello");
let req_a = AttachRequest {
protocol_version: PROTOCOL_VERSION,
protocol_version: hello_a.protocol_version,
frontend_capabilities: pmacs::attach::build_capabilities(),
initial_size: CellSize::new(24, 80),
};

View File

@ -77,7 +77,7 @@ use nix::unistd::Pid;
use tempfile::TempDir;
use pmacs::attach::PMACS_TEST_SSH_BIN;
use pmacs::protocol::{Hello, PROTOCOL_VERSION};
use pmacs::protocol::{ADVERTISED_PROTOCOL_VERSION, Hello};
use pmacs::transport::read_message;
// ---------------------------------------------------------------------------
@ -385,7 +385,7 @@ fn daemon_attach_bridges_hello_from_existing_daemon() {
// verbatim. (No AttachRequest sent — the daemon will hold the
// attach slot until the bridge stdin closes below.)
let hello: Hello = read_message(&mut bridge_stdout).expect("read Hello via bridge");
assert_eq!(hello.protocol_version, PROTOCOL_VERSION);
assert_eq!(hello.protocol_version, ADVERTISED_PROTOCOL_VERSION);
// Tear down: drop bridge stdin → bridge's stdin→socket copy sees
// EOF, shuts down the socket write half, the daemon notices and
@ -424,7 +424,7 @@ fn daemon_attach_auto_starts_missing_daemon() {
// bound the socket and the bridge connected.
let hello: Hello =
read_message(&mut bridge_stdout).expect("read Hello via auto-started daemon");
assert_eq!(hello.protocol_version, PROTOCOL_VERSION);
assert_eq!(hello.protocol_version, ADVERTISED_PROTOCOL_VERSION);
// The lockfile must exist now: `acquire_lock` writes it on
// daemon startup. (Existence of the lockfile is what proves

View File

@ -72,8 +72,8 @@ use tempfile::TempDir;
use pmacs::cell::CellSize;
use pmacs::protocol::{
AttachRequest, FrontendCapabilities, FrontendEvent, Hello, InstanceMessage, Key, KeyEvent,
Modifiers, PROTOCOL_VERSION,
ADVERTISED_PROTOCOL_VERSION, AttachRequest, FrontendCapabilities, FrontendEvent, Hello,
InstanceMessage, Key, KeyEvent, Modifiers,
};
use pmacs::transport::{TransportError, read_message, write_message};
@ -158,9 +158,9 @@ fn build_default_caps() -> FrontendCapabilities {
fn do_handshake(stream: &mut UnixStream) -> Hello {
let hello: Hello = read_message(stream).expect("read Hello");
assert_eq!(hello.protocol_version, PROTOCOL_VERSION);
assert_eq!(hello.protocol_version, ADVERTISED_PROTOCOL_VERSION);
let req = AttachRequest {
protocol_version: PROTOCOL_VERSION,
protocol_version: hello.protocol_version,
frontend_capabilities: build_default_caps(),
initial_size: CellSize::new(24, 80),
};

View File

@ -11,8 +11,8 @@ use std::time::{Duration, Instant};
use pmacs::cell::{Cell, CellSize, Glyph};
use pmacs::protocol::{
AttachRequest, FrontendEvent, FrontendId, Hello, InstanceMessage, Key, KeyEvent, Modifiers,
PROTOCOL_VERSION,
ADVERTISED_PROTOCOL_VERSION, AttachRequest, FrontendEvent, FrontendId, Hello, InstanceMessage,
Key, KeyEvent, Modifiers,
};
use pmacs::transport::{read_message, write_message};
@ -75,11 +75,11 @@ fn attach(daemon: &TestDaemon) -> (Client, Grid) {
.set_read_timeout(Some(Duration::from_secs(5)))
.expect("set daemon handshake timeout");
let hello: Hello = read_message(&mut stream).expect("read daemon Hello");
assert_eq!(hello.protocol_version, PROTOCOL_VERSION);
assert_eq!(hello.protocol_version, ADVERTISED_PROTOCOL_VERSION);
write_message(
&mut stream,
&AttachRequest {
protocol_version: PROTOCOL_VERSION,
protocol_version: hello.protocol_version,
frontend_capabilities: build_default_caps(),
initial_size: CellSize::new(ROWS, COLS),
},

View File

@ -786,15 +786,16 @@ fn a12_builtin_lsp_provider_tracks_real_attachment_and_unknown_label() {
#[test]
fn a13_17_26_protocol_semantic_init_late_join_and_version_cost() {
// Vterm Stage 3 appended the terminal family as v19; GPU initial targets
// appended the semantic bootstrap family as v20. This acceptance owns the
// STATUSLINE variant's placement and gate, so it tracks the current wire
// version rather than pinning 18: the v18 floor it actually cares about is
// asserted below and in `peer_accepts_statusline_message`.
assert_eq!(PROTOCOL_VERSION, 20);
for version in 6..=20 {
// appended the semantic bootstrap family as v20; bottom-panel Stage 2B-1
// appended the panel family as v21. This acceptance owns the STATUSLINE
// variant's placement and gate, so it tracks the current wire version
// rather than pinning 18: the v18 floor it actually cares about is asserted
// below and in `peer_accepts_statusline_message`.
assert_eq!(PROTOCOL_VERSION, 21);
for version in 6..=21 {
assert!(is_supported_protocol_version(version));
}
assert!(!is_supported_protocol_version(21));
assert!(!is_supported_protocol_version(22));
let sample = InstanceMessage::StatuslineSegments {
buffer_id: BufferId::from_raw(9),
left: vec![StatuslineSegment {

View File

@ -0,0 +1,746 @@
//! Terminal configuration acceptance (Stage 1 of
//! `docs/terminal-config-and-copy-mode-framing.md`, criteria 1-12).
//!
//! Deliberately NOT `#[cfg(feature = "crdt")]`: CI never enables that
//! feature, so a gated suite is written and then never run.
use std::thread;
use std::time::{Duration, Instant};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use mlua::Value;
use pmacs::cell::{CellSize, Glyph};
use pmacs::editor::EditorState;
use pmacs::protocol::FrontendId;
use pmacs::terminal::TerminalViewKey;
use pmacs::window::WindowId;
fn exec(state: &EditorState, src: &str) {
state
.lua_host
.lua()
.load(src)
.exec()
.unwrap_or_else(|e| panic!("lua failed: {src}\n{e}"));
}
fn eval_err(state: &EditorState, src: &str) -> String {
let result: mlua::Result<Value> = state.lua_host.lua().load(src).eval();
match result {
Ok(_) => panic!("expected an error from: {src}"),
Err(e) => e.to_string(),
}
}
/// The viewport every test projects through. Deliberately SHORTER than
/// the 24-row screen a terminal opens with, so "scroll to the oldest
/// retained row" has somewhere to go even when nothing is retained —
/// which is what makes the two scrollback arms differ by content rather
/// than by whether scrolling was possible at all.
fn viewport() -> CellSize {
CellSize::new(10, 40)
}
fn cells_to_text(cells: &[pmacs::cell::Cell]) -> String {
let mut text = String::new();
for cell in cells {
match &cell.glyph {
Glyph::Char(c) => text.push(*c),
Glyph::Cluster(b) => text.push_str(&String::from_utf8_lossy(b)),
Glyph::Continuation => {}
}
}
text
}
fn screen_text(state: &EditorState, buffer: pmacs::buffer::BufferId) -> String {
let manager = state.terminal_manager.borrow();
let Some(snapshot) = manager.snapshot(buffer) else {
return String::new();
};
cells_to_text(&snapshot.cells)
}
/// Text a view actually shows, which is where retained history is
/// visible at all — the live `screen_text` above always reads the tail.
fn view_text(state: &EditorState, key: TerminalViewKey) -> String {
let mut manager = state.terminal_manager.borrow_mut();
manager
.snapshot_for_view(key, viewport())
.map(|snapshot| cells_to_text(&snapshot.cells))
.unwrap_or_default()
}
/// Scroll a view to its OLDEST retained row and read it back.
fn oldest_view_text(state: &EditorState, key: TerminalViewKey) -> String {
state
.terminal_manager
.borrow_mut()
.scroll_view(key, viewport(), i32::MAX);
view_text(state, key)
}
fn tick_until(state: &mut EditorState, needle: &str, buffer: pmacs::buffer::BufferId) -> bool {
let deadline = Instant::now() + Duration::from_secs(5);
loop {
state.tick_processes();
if screen_text(state, buffer).contains(needle) {
return true;
}
if Instant::now() >= deadline {
return false;
}
thread::sleep(Duration::from_millis(20));
}
}
/// Give LOCAL a window on `buffer` and register/claim its terminal view,
/// which is what makes `dispatch_key`'s terminal arm reachable.
fn focus_terminal(state: &EditorState, buffer: pmacs::buffer::BufferId) -> WindowId {
state.core.borrow_mut().switch_active_buffer(buffer).ok();
let window = state.core.borrow().active_window_id();
let key = TerminalViewKey::new(FrontendId::LOCAL, window, buffer);
let mut manager = state.terminal_manager.borrow_mut();
manager.register_view(key);
manager.claim_controller(key);
let _ = manager.snapshot_for_view(key, viewport());
window
}
fn terminal_buffers(state: &EditorState) -> Vec<pmacs::buffer::BufferId> {
let manager = state.terminal_manager.borrow();
state
.core
.borrow()
.registry
.borrow()
.ids()
.iter()
.copied()
.filter(|id| manager.is_terminal(*id))
.collect()
}
/// Open a terminal from Lua and return the identity buffer it created.
///
/// The id is derived by diffing the manager's terminal set rather than
/// returned through Lua: `BufferIdLua` exposes no id accessor, and
/// diffing also asserts in passing that exactly one terminal appeared.
fn open_cat_terminal(state: &EditorState, lua_spec: &str) -> pmacs::buffer::BufferId {
let before = terminal_buffers(state);
exec(
state,
&format!("TERM_BUF = pmacs.terminal.open {{ {lua_spec} }}"),
);
let after = terminal_buffers(state);
let mut fresh: Vec<_> = after
.into_iter()
.filter(|id| !before.contains(id))
.collect();
assert_eq!(fresh.len(), 1, "exactly one terminal must have opened");
fresh.remove(0)
}
/// `cat -v` is the echo instrument, deliberately: the terminal screen
/// rejects C0/C1 controls before they enter cells (Vterm Stage 1
/// criterion 2), so a raw echoed `Ctrl-X` would be invisible and a test
/// probing for it could never pass. `-v` renders it as the printable
/// two-character `^X`, which is what makes "the configured chord reached
/// the child" observable at all.
const CAT_PROFILE: &str = r#"
pmacs.terminal.profiles.echo = {
command = "/bin/sh",
args = { "-c", "printf 'READY\r\n'; exec cat -v" },
}
"#;
/// Did the last key ARM the terminal escape?
///
/// Observed behaviorally rather than through an accessor: while the
/// escape is armed the next key goes to ordinary dispatch, so it never
/// reaches the child. `cat` echoes anything that does reach it, which
/// makes "the probe character did not appear" the exact observable for
/// "that chord was consumed as the escape".
fn escape_was_armed(state: &mut EditorState, buffer: pmacs::buffer::BufferId, probe: char) -> bool {
// Count occurrences rather than testing for presence: the screen
// already holds the child's own output, and a single-character probe
// like 'R' collides with the "READY" banner. Only an INCREASE proves
// this keystroke reached the child.
let before = screen_text(state, buffer).matches(probe).count();
state.dispatch_key(
FrontendId::LOCAL,
KeyEvent::new(KeyCode::Char(probe), KeyModifiers::NONE),
);
let deadline = Instant::now() + Duration::from_secs(2);
loop {
state.tick_processes();
if screen_text(state, buffer).matches(probe).count() > before {
return false;
}
if Instant::now() >= deadline {
return true;
}
thread::sleep(Duration::from_millis(20));
}
}
/// Acceptance 1: a profile spec is strict, and rejects before anything spawns.
#[test]
fn acc1_profile_specs_are_strict_and_reject_before_spawning() {
let state = EditorState::new();
let before = state.core.borrow().registry.borrow().ids().len();
exec(
&state,
r#"pmacs.terminal.profiles.bad = { command = "/bin/sh", nonsense = true }"#,
);
let err = eval_err(&state, r#"return pmacs.terminal.open { profile = "bad" }"#);
assert!(
err.contains("unknown field") && err.contains("nonsense"),
"the error must name the offending field: {err}"
);
exec(&state, "pmacs.terminal.profiles.wrong = { command = 42 }");
let err = eval_err(
&state,
r#"return pmacs.terminal.open { profile = "wrong" }"#,
);
assert!(err.contains("must be a string"), "typed field error: {err}");
assert_eq!(
state.core.borrow().registry.borrow().ids().len(),
before,
"a rejected profile must create no buffer"
);
assert_eq!(state.terminal_manager.borrow().len(), 0);
}
/// Acceptance 2: an unknown profile names the known ones and creates nothing.
#[test]
fn acc2_unknown_profile_lists_known_names_and_creates_nothing() {
let state = EditorState::new();
exec(&state, CAT_PROFILE);
exec(
&state,
r#"pmacs.terminal.profiles.other = { command = "/bin/sh" }"#,
);
let before = state.core.borrow().registry.borrow().ids().len();
// Via the default setting.
exec(
&state,
r#"pmacs.config.set("terminal.default-profile", "ghost")"#,
);
let err = eval_err(&state, "return pmacs.terminal.open {}");
assert!(err.contains("ghost"), "names the missing profile: {err}");
assert!(
err.contains("echo") && err.contains("other"),
"must LIST the known profiles: {err}"
);
// An explicit bad profile fails even though the default is now valid —
// a typo must not silently fall back (Q#TC3a).
exec(
&state,
r#"pmacs.config.set("terminal.default-profile", "echo")"#,
);
let err = eval_err(&state, r#"return pmacs.terminal.open { profile = "typo" }"#);
assert!(err.contains("typo"), "explicit bad profile errors: {err}");
assert_eq!(
state.core.borrow().registry.borrow().ids().len(),
before,
"no buffer, session, or process is created"
);
assert_eq!(state.terminal_manager.borrow().len(), 0);
}
/// Acceptance 2 (malformed table): `pmacs.terminal.profiles` is a raw
/// user table, so a diagnostic that walks its keys must be total over
/// them. A table holding both a string and a numeric key made
/// `table.sort` raise "attempt to compare number with string" — on the
/// unknown-profile path, replacing the exact error being asked for.
#[test]
fn acc2_malformed_profile_keys_do_not_mask_the_unknown_profile_error() {
let state = EditorState::new();
exec(&state, CAT_PROFILE);
exec(
&state,
r#"pmacs.terminal.profiles[1] = { command = "/bin/sh" }"#,
);
let err = eval_err(
&state,
r#"return pmacs.terminal.open { profile = "ghost" }"#,
);
assert!(
err.contains("ghost") && err.contains("echo"),
"the unknown-profile error must survive a malformed table: {err}"
);
assert!(
!err.contains("attempt to compare"),
"listing known profiles must not raise: {err}"
);
// Rendering the REQUESTED name is partial too: `%q` raises on a
// table, and the name arrives straight from the caller.
let err = eval_err(&state, r"return pmacs.terminal.open { profile = {} }");
assert!(
err.contains("is not defined") && err.contains("known profiles"),
"a non-string profile name must render, not raise: {err}"
);
assert_eq!(state.terminal_manager.borrow().len(), 0);
}
/// Acceptance 3: explicit beats profile beats setting beats `$SHELL`, and
/// `env` MERGES rather than replacing.
#[test]
fn acc3_field_resolution_order_and_env_merge() {
let mut state = EditorState::new();
exec(
&state,
r#"
pmacs.terminal.profiles.merged = {
command = "/bin/sh",
args = { "-c", "printf 'PROFILE:%s:%s\r\n' \"$FROM_PROFILE\" \"$SHARED\"; exec cat" },
env = { FROM_PROFILE = "p", SHARED = "profile" },
}
"#,
);
let buffer = open_cat_terminal(
&state,
r#"profile = "merged", env = { SHARED = "explicit" }"#,
);
assert!(
tick_until(&mut state, "PROFILE:p:explicit", buffer),
"profile env survives and explicit env overrides the same key: {:?}",
screen_text(&state, buffer)
);
state.process_supervisor.borrow_mut().shutdown();
}
/// Acceptance 3 (explicit command wins) and 4 (`""` means no profile).
#[test]
fn acc3_acc4_explicit_command_wins_and_empty_default_means_no_profile() {
let mut state = EditorState::new();
exec(&state, CAT_PROFILE);
exec(
&state,
r#"pmacs.config.set("terminal.default-profile", "echo")"#,
);
// Explicit command beats the profile's.
let explicit = open_cat_terminal(
&state,
r#"command = "/bin/sh", args = { "-c", "printf 'EXPLICIT\r\n'; exec cat" }"#,
);
assert!(tick_until(&mut state, "EXPLICIT", explicit));
// `""` is the no-profile sentinel: falls through to $SHELL.
exec(
&state,
r#"pmacs.config.set("terminal.default-profile", "")"#,
);
let bare = open_cat_terminal(&state, "");
let spec_ok = state.terminal_manager.borrow().is_terminal(bare);
assert!(spec_ok, "an empty default must open a $SHELL terminal");
assert!(
!screen_text(&state, bare).contains("READY"),
"the echo profile must NOT have been applied"
);
state.process_supervisor.borrow_mut().shutdown();
}
/// A child that overflows the 24-row screen and then goes quiet, so its
/// early output can only still be found in RETAINED HISTORY. Zero-padded
/// so `LINE001` is not a substring of `LINE100`.
const FILL_PROFILE: &str = r#"
pmacs.terminal.profiles.fill = {
command = "/bin/sh",
args = { "-c",
"i=1; while [ $i -le 200 ]; do printf 'LINE%03d\r\n' $i; i=$((i+1)); done; printf 'DONE\r\n'; exec cat" },
}
"#;
/// Acceptance 5: the scrollback SETTING reaches the screen's retained
/// history, an explicit spec value overrides it, and `0` is legal.
///
/// Asserted end to end, through a real child and a real view, rather
/// than by reading the value back out of the registry: a registry
/// round-trip is a test of the registry, and would stay green with the
/// setting's only consumer (`terminal.lua`'s `resolved.scrollback_rows`
/// fallback) deleted outright.
#[test]
fn acc5_scrollback_setting_reaches_retained_history() {
let mut state = EditorState::new();
exec(&state, FILL_PROFILE);
// Arm 1: `0` is legal, and means the early rows are GONE.
exec(&state, r#"pmacs.config.set("terminal.scrollback-rows", 0)"#);
let none = open_cat_terminal(&state, r#"profile = "fill""#);
assert!(tick_until(&mut state, "DONE", none), "child finished");
let window = focus_terminal(&state, none);
let none_key = TerminalViewKey::new(FrontendId::LOCAL, window, none);
let oldest = oldest_view_text(&state, none_key);
assert!(
!oldest.contains("LINE001"),
"with scrollback 0 the oldest retained row must not be the \
child's first line: {oldest:?}"
);
// Arm 2: a large setting retains it, reachable by scrolling back.
exec(
&state,
r#"pmacs.config.set("terminal.scrollback-rows", 10000)"#,
);
let kept = open_cat_terminal(&state, r#"profile = "fill""#);
assert!(tick_until(&mut state, "DONE", kept), "child finished");
let window = focus_terminal(&state, kept);
let kept_key = TerminalViewKey::new(FrontendId::LOCAL, window, kept);
let oldest = oldest_view_text(&state, kept_key);
assert!(
oldest.contains("LINE001"),
"with scrollback 10000 the first line must survive in history: \
{oldest:?}"
);
// Arm 3: an explicit spec value beats the setting, which is still 10000.
let overridden = open_cat_terminal(&state, r#"profile = "fill", scrollback_rows = 0"#);
assert!(tick_until(&mut state, "DONE", overridden), "child finished");
let window = focus_terminal(&state, overridden);
let overridden_key = TerminalViewKey::new(FrontendId::LOCAL, window, overridden);
let oldest = oldest_view_text(&state, overridden_key);
assert!(
!oldest.contains("LINE001"),
"an explicit scrollback_rows = 0 must beat the setting: {oldest:?}"
);
state.process_supervisor.borrow_mut().shutdown();
}
/// Acceptance 5 (bounds): the registered range rejects out-of-range
/// values, and `0` is inside it rather than a disabled sentinel.
#[test]
fn acc5_scrollback_bounds() {
let state = EditorState::new();
exec(&state, r#"pmacs.config.set("terminal.scrollback-rows", 0)"#);
assert_eq!(
state
.lua_host
.lua()
.load(r#"return pmacs.config.get("terminal.scrollback-rows")"#)
.eval::<i64>()
.unwrap(),
0,
"0 is a legal scrollback value meaning 'retain no history'"
);
let err = eval_err(
&state,
r#"return pmacs.config.set("terminal.scrollback-rows", -1)"#,
);
assert!(
err.contains("-1") || err.contains("min"),
"below range: {err}"
);
let err = eval_err(
&state,
r#"return pmacs.config.set("terminal.scrollback-rows", 4000001)"#,
);
assert!(
err.contains("4000001") || err.contains("max"),
"above range: {err}"
);
}
/// Acceptance 6 and 9: the configured chord escapes, repeating it sends
/// THAT chord to the child, and an ordinary `C-c` still reaches the child.
#[test]
fn acc6_acc9_configured_escape_chord_and_literal_repeat() {
let mut state = EditorState::new();
exec(&state, CAT_PROFILE);
let buffer = open_cat_terminal(&state, r#"profile = "echo""#);
assert!(tick_until(&mut state, "READY", buffer));
focus_terminal(&state, buffer);
exec(&state, r#"pmacs.config.set("terminal.escape-key", "C-x")"#);
// `C-x C-x` must send Ctrl-X (0x18), which `cat` echoes back. Against
// the pre-change hardcoded `&[0x03]` this sends Ctrl-C instead.
state.dispatch_key(
FrontendId::LOCAL,
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
);
state.dispatch_key(
FrontendId::LOCAL,
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
);
assert!(
tick_until(&mut state, "^X", buffer),
"C-x C-x must send literal Ctrl-X: {:?}",
screen_text(&state, buffer)
);
// With the escape moved, an ordinary C-c is just another key.
state.dispatch_key(
FrontendId::LOCAL,
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
);
assert!(
tick_until(&mut state, "^C", buffer),
"plain C-c must reach the child once the escape moved: {:?}",
screen_text(&state, buffer)
);
state.process_supervisor.borrow_mut().shutdown();
}
/// Acceptance 7, 8 and 8a: per-terminal escape resolution, an A→B→A parse
/// count that does not grow, and a cache that dies with its terminal.
#[test]
fn acc7_acc8_acc8a_per_terminal_escape_cache_identity_and_lifecycle() {
let mut state = EditorState::new();
exec(&state, CAT_PROFILE);
let a = open_cat_terminal(&state, r#"profile = "echo""#);
exec(&state, "TERM_A = TERM_BUF");
let b = open_cat_terminal(&state, r#"profile = "echo""#);
exec(&state, "TERM_B = TERM_BUF");
assert!(tick_until(&mut state, "READY", a));
assert!(tick_until(&mut state, "READY", b));
// Different buffer-local escapes, then NO further writes.
exec(
&state,
r#"pmacs.config.set_local(TERM_A, "terminal.escape-key", "C-x")"#,
);
exec(
&state,
r#"pmacs.config.set_local(TERM_B, "terminal.escape-key", "C-b")"#,
);
// Prime both caches. Each priming press ARMS the escape, so it is
// consumed with a probe — otherwise the next chord would be read as
// the escape repeat rather than a fresh escape.
focus_terminal(&state, a);
state.dispatch_key(
FrontendId::LOCAL,
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
);
assert!(escape_was_armed(&mut state, a, 'M'), "A primes on its C-x");
focus_terminal(&state, b);
state.dispatch_key(
FrontendId::LOCAL,
KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL),
);
assert!(escape_was_armed(&mut state, b, 'N'), "B primes on its C-b");
let primed = state.terminal_manager.borrow().escape_parses();
assert_eq!(
state.terminal_manager.borrow().escape_caches(),
2,
"each primed terminal holds its own cache"
);
// Acceptance 7 — BOTH directions. Asserting only that A still works
// after A->B->A is not enough: an epoch-only cache hands whichever
// entry it finds to every terminal, so A keeps working by accident
// while B silently inherits A's chord. The discriminating assertion
// is that EACH terminal honors its OWN chord and NOT the other's.
focus_terminal(&state, b);
state.dispatch_key(
FrontendId::LOCAL,
KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL),
);
assert!(
escape_was_armed(&mut state, b, 'R'),
"terminal B must escape on its own C-b"
);
// ...and A's chord must be ordinary input in B, not an escape.
focus_terminal(&state, b);
state.dispatch_key(
FrontendId::LOCAL,
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
);
assert!(
!escape_was_armed(&mut state, b, 'S'),
"terminal A's C-x must NOT escape terminal B"
);
focus_terminal(&state, a);
state.dispatch_key(
FrontendId::LOCAL,
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
);
assert!(
escape_was_armed(&mut state, a, 'Q'),
"terminal A must still escape on its own C-x after A->B->A"
);
// Acceptance 8: that round trip parsed nothing new. A single
// last-entry cache would have reparsed twice.
assert_eq!(
state.terminal_manager.borrow().escape_parses(),
primed,
"A->B->A with no setting written must not reparse"
);
// Acceptance 8a: the cache dies with its terminal.
//
// Waiting for the SESSION count to fall is not the assertion — a
// session set that drains while an editor-side `HashMap<BufferId,
// EscapeCache>` keeps its entry (the rejected implementation named
// in Q#TC4c, which has no purge hook) satisfies it exactly. The
// discriminating observable is the CACHE count, which such a map
// would hold at its high-water mark of 2.
let sessions_before = state.terminal_manager.borrow().len();
exec(&state, "pmacs.terminal.terminate(TERM_A)");
exec(&state, "pmacs.buffer.kill(TERM_A)");
// Pruning is tick-driven (the manager reaps on the process tick), so
// the session outlives the kill call by design.
let deadline = Instant::now() + Duration::from_secs(5);
while state.terminal_manager.borrow().len() >= sessions_before {
state.tick_processes();
assert!(
Instant::now() < deadline,
"killing the terminal must remove its session"
);
thread::sleep(Duration::from_millis(20));
}
assert_eq!(
state.terminal_manager.borrow().escape_caches(),
1,
"killing terminal A must drop ITS cache, not merely its session"
);
// ...and the surviving cache is B's, so the right one was dropped.
focus_terminal(&state, b);
state.dispatch_key(
FrontendId::LOCAL,
KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL),
);
assert!(
escape_was_armed(&mut state, b, 'T'),
"terminal B must still escape on its own C-b after A was killed"
);
assert_eq!(
state.terminal_manager.borrow().escape_parses(),
primed,
"B's surviving cache must not have been reparsed"
);
state.process_supervisor.borrow_mut().shutdown();
}
/// Acceptance 10 and 10a: an unparseable value falls back, reports through
/// the status line, and reports once per terminal per effective bad value.
#[test]
fn acc10_acc10a_invalid_escape_falls_back_and_reports_once() {
let mut state = EditorState::new();
exec(&state, CAT_PROFILE);
let buffer = open_cat_terminal(&state, r#"profile = "echo""#);
assert!(tick_until(&mut state, "READY", buffer));
focus_terminal(&state, buffer);
exec(
&state,
r#"pmacs.config.set("terminal.escape-key", "not-a-chord")"#,
);
state.core.borrow_mut().status.clear();
// Acceptance 10: falls back to C-c, so the terminal stays escapable.
state.dispatch_key(
FrontendId::LOCAL,
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
);
// Read the report BEFORE probing: `status` is a single slot, and the
// probe key's own rejected self-insert would overwrite it.
let reported = state.core.borrow().status.clone();
assert!(
reported.contains("terminal.escape-key") && reported.contains("not-a-chord"),
"the report must name the setting and the bad value: {reported:?}"
);
assert!(
escape_was_armed(&mut state, buffer, 'Q'),
"an invalid escape-key must fall back to C-c, not leave the \
terminal unescapable"
);
// Acceptance 10a: the same bad value does not report again.
state.core.borrow_mut().status.clear();
state.dispatch_key(
FrontendId::LOCAL,
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
);
assert!(
state.core.borrow().status.is_empty(),
"an unchanged invalid value must not re-report: {:?}",
state.core.borrow().status
);
let _ = escape_was_armed(&mut state, buffer, 'W');
// A DIFFERENT bad value is new information, so it reports again.
exec(
&state,
r#"pmacs.config.set("terminal.escape-key", "also-bad")"#,
);
state.core.borrow_mut().status.clear();
state.dispatch_key(
FrontendId::LOCAL,
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
);
assert!(
state.core.borrow().status.contains("also-bad"),
"a different invalid value must report: {:?}",
state.core.borrow().status
);
state.process_supervisor.borrow_mut().shutdown();
}
/// Acceptance 11: the opening binding exists, resolves to the command, and
/// shadowed nothing (`keymap.bind` is strict, so loading the runtime at all
/// proves the second half).
#[test]
fn acc11_terminal_opening_binding_is_bound_and_shadowed_nothing() {
let state = EditorState::new();
let command: Option<String> = state
.lua_host
.lua()
.load(r#"local d = pmacs.describe.key("C-c t"); return d and d.command"#)
.eval()
.expect("describe.key");
assert_eq!(
command.as_deref(),
Some("terminal"),
"C-c t must open a terminal"
);
}
/// Acceptance 12: with no settings written and no profiles registered, the
/// defaults reproduce the pre-arc behavior.
#[test]
fn acc12_defaults_reproduce_prior_behavior() {
let state = EditorState::new();
let lua = state.lua_host.lua();
assert_eq!(
lua.load(r#"return pmacs.config.get("terminal.default-profile")"#)
.eval::<String>()
.unwrap(),
""
);
assert_eq!(
lua.load(r#"return pmacs.config.get("terminal.scrollback-rows")"#)
.eval::<i64>()
.unwrap(),
10_000
);
assert_eq!(
lua.load(r#"return pmacs.config.get("terminal.escape-key")"#)
.eval::<String>()
.unwrap(),
"C-c"
);
assert!(
lua.load("return next(pmacs.terminal.profiles) == nil")
.eval::<bool>()
.unwrap(),
"no profiles are registered by default"
);
}

View File

@ -0,0 +1,994 @@
//! Terminal copy-mode acceptance (Stage 2 of
//! `docs/terminal-config-and-copy-mode-framing.md`, criteria 13-21).
//!
//! **Deliberately NOT `#[cfg(feature = "crdt")]`.** CI never enables that
//! feature, so a gated suite is written and then never run — 264 tests are
//! dark workspace-wide for exactly that reason. Criterion 16, the
//! round-trip gate Q#TC6a's entire safety argument rests on, needs no CRDT
//! and must be caught by the default configuration.
use std::thread;
use std::time::{Duration, Instant};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use mlua::Value;
use pmacs::cell::{CellSize, Glyph};
use pmacs::editor::EditorState;
use pmacs::protocol::FrontendId;
use pmacs::terminal::TerminalViewKey;
const SNAPSHOT_NAME: &str = "*terminal-copy: terminal:sh*";
fn exec(state: &EditorState, src: &str) {
state
.lua_host
.lua()
.load(src)
.exec()
.unwrap_or_else(|e| panic!("lua failed: {src}\n{e}"));
}
fn eval<T: mlua::FromLuaMulti>(state: &EditorState, src: &str) -> T {
state
.lua_host
.lua()
.load(src)
.eval()
.unwrap_or_else(|e| panic!("lua eval failed: {src}\n{e}"))
}
fn eval_err(state: &EditorState, src: &str) -> String {
let result: mlua::Result<Value> = state.lua_host.lua().load(src).eval();
match result {
Ok(_) => panic!("expected an error from: {src}"),
Err(e) => e.to_string(),
}
}
fn press(state: &mut EditorState, code: KeyCode, mods: KeyModifiers) {
state.dispatch_key(FrontendId::LOCAL, KeyEvent::new(code, mods));
}
/// The live terminal screen's text, used only to wait for the child.
fn screen_text(state: &EditorState, buffer: pmacs::buffer::BufferId) -> String {
let manager = state.terminal_manager.borrow();
let Some(snapshot) = manager.snapshot(buffer) else {
return String::new();
};
let mut text = String::new();
for cell in &snapshot.cells {
match &cell.glyph {
Glyph::Char(c) => text.push(*c),
Glyph::Cluster(b) => text.push_str(&String::from_utf8_lossy(b)),
Glyph::Continuation => {}
}
}
text
}
fn tick_until(state: &mut EditorState, needle: &str, buffer: pmacs::buffer::BufferId) -> bool {
let deadline = Instant::now() + Duration::from_secs(5);
loop {
state.tick_processes();
if screen_text(state, buffer).contains(needle) {
return true;
}
if Instant::now() >= deadline {
return false;
}
thread::sleep(Duration::from_millis(20));
}
}
fn terminal_buffers(state: &EditorState) -> Vec<pmacs::buffer::BufferId> {
let manager = state.terminal_manager.borrow();
state
.core
.borrow()
.registry
.borrow()
.ids()
.iter()
.copied()
.filter(|id| manager.is_terminal(*id))
.collect()
}
/// A child that overflows the 24-row screen and then goes quiet, so its
/// early lines exist ONLY in scrollback — which is what makes criterion
/// 15's "content only in scrollback" claim meaningful.
const FILL_PROFILE: &str = r#"
pmacs.terminal.profiles.fill = {
command = "/bin/sh",
args = { "-c",
"printf 'NEEDLE-IN-SCROLLBACK\r\n'; i=1; while [ $i -le 200 ]; do printf 'LINE%03d\r\n' $i; i=$((i+1)); done; printf 'DONE\r\n'; exec cat" },
}
"#;
/// Open the fill terminal, wait for the child to finish, and return its id.
fn open_fill_terminal(state: &mut EditorState) -> pmacs::buffer::BufferId {
exec(state, FILL_PROFILE);
let before = terminal_buffers(state);
exec(
state,
r#"TERM_BUF = pmacs.terminal.open { profile = "fill" }"#,
);
let fresh: Vec<_> = terminal_buffers(state)
.into_iter()
.filter(|id| !before.contains(id))
.collect();
assert_eq!(fresh.len(), 1, "exactly one terminal must have opened");
let buffer = fresh[0];
assert!(tick_until(state, "DONE", buffer), "the child must finish");
buffer
}
fn viewport() -> CellSize {
CellSize::new(10, 40)
}
/// Give LOCAL a window on the terminal and register/claim its view, which
/// is what makes `dispatch_key`'s terminal transport arm reachable.
/// Returns the view key, so assertions can read the *projected* view
/// rather than the context-free live screen.
fn focus_terminal(state: &EditorState, buffer: pmacs::buffer::BufferId) -> TerminalViewKey {
state.core.borrow_mut().switch_active_buffer(buffer).ok();
let window = state.core.borrow().active_window_id();
let key = TerminalViewKey::new(FrontendId::LOCAL, window, buffer);
let mut manager = state.terminal_manager.borrow_mut();
manager.register_view(key);
manager.claim_controller(key);
let _ = manager.snapshot_for_view(key, viewport());
key
}
/// Make the child produce NEW output, so a refresh has something to find.
///
/// The child is `exec cat`, so typing into the focused terminal echoes
/// back. Without this, "refresh" tests compare a quiet terminal against
/// itself and pass with the render replaced by a no-op — the defect review
/// round 1 found in acceptance 18 and 19.
fn emit_into_child(state: &mut EditorState, terminal: pmacs::buffer::BufferId, marker: &str) {
focus_terminal(state, terminal);
for ch in marker.chars() {
press(state, KeyCode::Char(ch), KeyModifiers::NONE);
}
assert!(
tick_until(state, marker, terminal),
"the child must echo {marker:?} back onto the live screen"
);
}
/// What the registered VIEW currently projects — which, unlike
/// `manager.snapshot(buffer)`, depends on where the view is anchored.
fn view_text(state: &EditorState, key: TerminalViewKey) -> String {
let mut manager = state.terminal_manager.borrow_mut();
let Some(snapshot) = manager.snapshot_for_view(key, viewport()) else {
return String::new();
};
let mut text = String::new();
for cell in &snapshot.cells {
match &cell.glyph {
Glyph::Char(c) => text.push(*c),
Glyph::Cluster(b) => text.push_str(&String::from_utf8_lossy(b)),
Glyph::Continuation => {}
}
}
text
}
fn view_at_bottom(state: &EditorState, key: TerminalViewKey) -> bool {
state
.terminal_manager
.borrow_mut()
.snapshot_for_view(key, viewport())
.is_some_and(|snapshot| snapshot.at_bottom)
}
fn buffer_text_by_name(state: &EditorState, name: &str) -> Option<String> {
eval(
state,
&format!(
r"
for _, id in ipairs(pmacs.buffer.list()) do
local ok, d = pcall(pmacs.describe.buffer, id)
if ok and d and d.name == {name:?} then
return id:slice(0, id:len())
end
end
return nil
"
),
)
}
fn active_buffer_name(state: &EditorState) -> String {
eval(
state,
r"local b = pmacs.window.buffer(); return (pmacs.describe.buffer(b)).name",
)
}
fn buffer_count(state: &EditorState) -> usize {
state.core.borrow().registry.borrow().ids().len()
}
/// Acceptance 13: the snapshot's text is exactly the whole retained range
/// as the existing copy path serializes it.
///
/// Compared against `_copy_retained` rather than a literal, so this cannot
/// pass by both sides drifting the same way; the exact-bytes fidelity
/// claims (criterion 14) are pinned at the unit level in
/// `src/terminal/view.rs`, against the same projection fixtures that pin
/// `copy_selection_bytes` itself.
#[test]
fn acc13_snapshot_is_the_whole_retained_range_through_the_shared_serializer() {
let mut state = EditorState::new();
let terminal = open_fill_terminal(&mut state);
focus_terminal(&state, terminal);
exec(&state, "SNAP = pmacs.terminal.copy_mode(TERM_BUF)");
let snapshot_text = buffer_text_by_name(&state, SNAPSHOT_NAME).expect("snapshot buffer exists");
let serialized: String = eval(
&state,
r"return pmacs.terminal._copy_retained(TERM_BUF) or ''",
);
assert_eq!(
snapshot_text, serialized,
"the snapshot must be byte-identical to the shared serializer's output"
);
assert!(
snapshot_text.contains("NEEDLE-IN-SCROLLBACK") && snapshot_text.contains("LINE200"),
"the range must span scrollback AND the visible screen"
);
state.process_supervisor.borrow_mut().shutdown();
}
/// Acceptance 14 (end-to-end half): the snapshot really is a rope-backed
/// document buffer and not a terminal, which is what makes every
/// buffer-shaped consumer work and what removes the transport arm.
#[test]
fn acc14_the_snapshot_is_an_ordinary_non_terminal_buffer() {
let mut state = EditorState::new();
let terminal = open_fill_terminal(&mut state);
focus_terminal(&state, terminal);
exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)");
let is_terminal: bool = eval(
&state,
r"local b = pmacs.window.buffer(); return pmacs.terminal.is_terminal(b)",
);
assert!(
!is_terminal,
"the snapshot must NOT be a terminal — that is what structurally \
removes the transport arm rather than guarding it"
);
assert_eq!(active_buffer_name(&state), SNAPSHOT_NAME);
state.process_supervisor.borrow_mut().shutdown();
}
/// Acceptance 15: isearch finds content that exists ONLY in scrollback,
/// with no change to `src/search.rs` (B1).
#[test]
fn acc15_isearch_finds_content_only_in_scrollback() {
let mut state = EditorState::new();
let terminal = open_fill_terminal(&mut state);
focus_terminal(&state, terminal);
// The needle is off the visible screen: the live terminal cannot see it.
assert!(
!screen_text(&state, terminal).contains("NEEDLE-IN-SCROLLBACK"),
"precondition: the needle must have scrolled off the live screen"
);
exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)");
state.core.borrow_mut().set_cursor_byte(0);
// Drive real isearch: C-s then the needle.
press(&mut state, KeyCode::Char('s'), KeyModifiers::CONTROL);
for ch in "NEEDLE-IN-SCROLLBACK".chars() {
press(&mut state, KeyCode::Char(ch), KeyModifiers::NONE);
}
let cursor = state.core.borrow().cursor();
press(&mut state, KeyCode::Enter, KeyModifiers::NONE);
let text = buffer_text_by_name(&state, SNAPSHOT_NAME).expect("snapshot");
let expected = text
.find("NEEDLE-IN-SCROLLBACK")
.expect("the needle is in the snapshot") as u64;
assert_eq!(
cursor,
expected,
"isearch must land on the scrollback-only match; text was {:?}",
&text[..text.len().min(80)]
);
state.process_supervisor.borrow_mut().shutdown();
}
/// Acceptance 16 — the load-bearing pin, and the reason this suite is
/// ungated. `set_round_trip_input` is the ONLY thing standing between a
/// replica frontend and unauthorized mutation **of its own mirror**
/// (Q#TC6a), so its regression must be caught in the configuration CI
/// actually compiles.
///
/// Rope-level `read_only` does not substitute for it. Since review round 2
/// the daemon refuses such an op at `ensure_writable()` — but a refusal
/// arrives after the frontend has already applied optimistically and
/// painted the result. What that buys is divergence instead of silent
/// agreement; what stops the mutation is this.
#[test]
fn acc16_dispatch_idle_is_false_while_the_snapshot_is_focused() {
let mut state = EditorState::new();
let terminal = open_fill_terminal(&mut state);
focus_terminal(&state, terminal);
exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)");
assert!(
!state.dispatch_idle(),
"a focused snapshot must round-trip keys, so no replica applies \
optimistically and none emits a CRDT op"
);
// ...and it is the SNAPSHOT that does it, not merely "some terminal
// buffer is around": switching to an ordinary buffer restores idle.
exec(
&state,
r#"pmacs.window.switch_buffer(pmacs.buffer.create("*plain*"))"#,
);
assert!(state.dispatch_idle(), "an ordinary buffer is idle again");
state.process_supervisor.borrow_mut().shutdown();
}
/// Acceptance 16 (the other half): the intercept rejects ordinary edits,
/// and the buffer is genuinely `read_only` at the rope boundary, so the
/// protection does not depend on which key or command was used.
#[test]
fn acc16b_the_snapshot_is_immutable_at_the_rope_not_merely_intercepted() {
let mut state = EditorState::new();
let terminal = open_fill_terminal(&mut state);
focus_terminal(&state, terminal);
exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)");
let before = buffer_text_by_name(&state, SNAPSHOT_NAME).expect("snapshot");
press(&mut state, KeyCode::Char('z'), KeyModifiers::NONE);
let after = buffer_text_by_name(&state, SNAPSHOT_NAME).expect("snapshot");
assert_eq!(before, after, "the read-only intercept rejects self-insert");
let core = state.core.borrow();
let registry = core.registry.borrow();
let ids = registry.ids();
let snapshot = ids
.iter()
.copied()
.find(|id| {
registry
.get(*id)
.is_ok_and(|buf| buf.name() == SNAPSHOT_NAME)
})
.expect("snapshot buffer id");
assert!(
registry
.get(snapshot)
.expect("snapshot buffer")
.is_read_only(),
"an intercept guards the dispatch path only; `Buffer::undo` reaches \
the rope through `ensure_writable` without consulting it, so the \
snapshot must be read-only at the rope"
);
drop(registry);
drop(core);
state.process_supervisor.borrow_mut().shutdown();
}
/// Acceptance 16c (review round 2, P1): **undo cannot empty the snapshot**,
/// through the chord *or* through the command.
///
/// The chord half alone would be a false pass. `M-x buffer.undo` and the
/// menu reach `Buffer::undo` without passing through any buffer-local
/// keymap, so rebinding `C-/` to a no-op — the existing `*compilation*`
/// idiom, which documents that "command/menu undo stays dispatchable" —
/// leaves the buffer emptiable. Only rope-level `read_only` closes both.
#[test]
fn acc16c_undo_cannot_empty_the_snapshot_by_chord_or_by_command() {
let mut state = EditorState::new();
let terminal = open_fill_terminal(&mut state);
focus_terminal(&state, terminal);
exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)");
let rendered = buffer_text_by_name(&state, SNAPSHOT_NAME).expect("snapshot");
assert!(
rendered.contains("LINE200"),
"precondition: the snapshot has content to lose"
);
// The command path — reachable regardless of any buffer-local binding.
let _: Value = state
.lua_host
.lua()
.load(r"return pcall(pmacs.command.invoke_interactive, 'buffer.undo')")
.eval()
.expect("invoke_interactive is callable");
assert_eq!(
buffer_text_by_name(&state, SNAPSHOT_NAME).as_deref(),
Some(rendered.as_str()),
"M-x buffer.undo must not empty the snapshot"
);
// The chord path.
press(&mut state, KeyCode::Char('/'), KeyModifiers::CONTROL);
assert_eq!(
buffer_text_by_name(&state, SNAPSHOT_NAME).as_deref(),
Some(rendered.as_str()),
"C-/ must not empty the snapshot"
);
// Redo is the same door.
let _: Value = state
.lua_host
.lua()
.load(r"return pcall(pmacs.command.invoke_interactive, 'buffer.redo')")
.eval()
.expect("invoke_interactive is callable");
assert_eq!(
buffer_text_by_name(&state, SNAPSHOT_NAME).as_deref(),
Some(rendered.as_str()),
"buffer.redo must not alter the snapshot either"
);
// ...and the owner's own refresh still works, which is the whole
// reason plain `read_only` was not enough on its own.
emit_into_child(&mut state, terminal, "STILLREFRESHES");
exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)");
assert!(
buffer_text_by_name(&state, SNAPSHOT_NAME)
.expect("snapshot")
.contains("STILLREFRESHES"),
"the owner-authorized write path must survive immutability"
);
state.process_supervisor.borrow_mut().shutdown();
}
/// Snapshot buffer id, by name, from the Rust side.
#[cfg(feature = "crdt")]
fn snapshot_buffer_id(state: &EditorState) -> pmacs::buffer::BufferId {
let core = state.core.borrow();
let reg = core.registry.borrow();
reg.ids()
.iter()
.copied()
.find(|id| reg.get(*id).is_ok_and(|b| b.name() == SNAPSHOT_NAME))
.expect("snapshot buffer exists")
}
/// Rendered cells of the active window (the `m4_acceptance` grid helper;
/// cross-crate test code can't import it).
fn render_active_window_to_grid(
state: &mut EditorState,
rows: u32,
cols: u32,
) -> Vec<pmacs::cell::Cell> {
use pmacs::cell::{Cell, CellGrid};
use pmacs::view::{View, Viewport};
use pmacs::window::Rect;
let mut core = state.core.borrow_mut();
let active = core.active_window_id();
let registry = core.registry.clone();
let win = core.windows.get_mut(&active).expect("active window");
let rect = Rect::new(0, 0, rows, cols);
let mut backing = vec![Cell::default(); (rows * cols) as usize];
let reg = registry.borrow();
let buf = reg.get(win.buffer_id).expect("buffer in registry");
let viewport = Viewport {
buffer_start: 0,
buffer_end: buf.len(),
cell_origin: rect.origin,
cell_size: CellSize::new(rows, cols),
gutter_w: 0,
folds: None,
};
let mut grid = CellGrid {
cells: &mut backing,
stride: cols,
size: CellSize::new(rows, cols),
};
win.text_view.render(buf, viewport, &mut grid);
backing
}
fn grid_row(cells: &[pmacs::cell::Cell], row: u32, cols: u32) -> String {
(0..cols)
.map(|c| match cells[(row * cols + c) as usize].glyph {
Glyph::Char(ch) => ch,
_ => ' ',
})
.collect::<String>()
.trim_end()
.to_owned()
}
/// Review round 3, P1. A rope write is only half of an edit: the window
/// showing the buffer holds a `TextView` line index that only `on_edit`
/// maintains, so a write that reaches the rope without the notification
/// leaves the two disagreeing.
///
/// Pinned by PAINTING, because that is where the disagreement bites: with
/// the fan-out dropped, the next render indexes the new rope with the old
/// line offsets. A shrinking write is used deliberately — stale offsets
/// then point past the buffer end, which is the reported crash rather than
/// merely stale pixels.
///
/// Driven through `pmacs.buffer.set_generated_contents`, the seam copy
/// mode's refresh actually calls, so it also covers `*compilation*` and
/// any other owner that adopts the primitive later.
#[test]
fn acc16d_a_generated_write_notifies_the_window_that_displays_it() {
let mut state = EditorState::new();
exec(
&state,
r"
GEN = pmacs.buffer.create('*generated-probe*')
pmacs.buffer.set_generated_contents(GEN, 'alpha\nbeta\ngamma\ndelta\nepsilon\n')
pmacs.window.switch_buffer(GEN)
",
);
let painted = render_active_window_to_grid(&mut state, 6, 20);
assert_eq!(
grid_row(&painted, 0, 20),
"alpha",
"precondition: the window paints the generated buffer"
);
exec(
&state,
r"pmacs.buffer.set_generated_contents(GEN, 'CHANGED\n')",
);
let painted = render_active_window_to_grid(&mut state, 6, 20);
assert_eq!(
grid_row(&painted, 0, 20),
"CHANGED",
"the window must paint the refreshed contents"
);
assert_eq!(
grid_row(&painted, 1, 20),
"",
"and nothing of the longer contents it replaced"
);
}
/// Review round 3, P1, CRDT half. The same dropped fan-out also skips
/// `queue_daemon_origin_crdt_op`, so replica mirrors never import the
/// owner's write and their optimistic edits are generated against content
/// the owner has already replaced.
///
/// Gated because `upgrade_to_crdt` is — and therefore dark in CI, which
/// never enables the feature. The default-configuration half above is the
/// one that actually runs there.
#[cfg(feature = "crdt")]
#[test]
fn acc16e_a_refresh_queues_the_owners_write_for_replica_mirrors() {
let mut state = EditorState::new();
let terminal = open_fill_terminal(&mut state);
focus_terminal(&state, terminal);
exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)");
let snapshot = snapshot_buffer_id(&state);
{
let core = state.core.borrow();
let mut reg = core.registry.borrow_mut();
let buffer = reg.get_mut(snapshot).expect("snapshot buffer");
// `read_only` refuses the upgrade's own bookkeeping path the same
// way it refuses everything else, so lift it around the upgrade.
buffer.set_read_only(false);
buffer.upgrade_to_crdt(2).expect("upgrade");
buffer.set_read_only(true);
}
state.core.borrow_mut().pending_crdt_ops.clear();
emit_into_child(&mut state, terminal, "MIRRORME");
exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)");
let queued: Vec<_> = state
.core
.borrow()
.pending_crdt_ops
.iter()
.map(|(_, id, _)| *id)
.collect();
assert!(
queued.contains(&snapshot),
"the owner's refresh must be queued for broadcast; queued: {queued:?}"
);
state.process_supervisor.borrow_mut().shutdown();
}
/// Acceptance 18: re-invoking refreshes in place, and the lifecycle runs
/// both directions.
#[test]
fn acc18_reinvoke_refreshes_in_place_and_lifecycle_runs_both_ways() {
let mut state = EditorState::new();
let terminal = open_fill_terminal(&mut state);
focus_terminal(&state, terminal);
exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)");
let count_after_first = buffer_count(&state);
assert!(
!buffer_text_by_name(&state, SNAPSHOT_NAME)
.expect("snapshot")
.contains("REINVOKE"),
"precondition: the marker has not been emitted yet"
);
// Advance the world, then re-invoke. Counting buffers alone is
// vacuous: it passes with the render replaced by a no-op, so the
// refresh must be observed by CONTENT that only exists after the
// first snapshot was taken.
emit_into_child(&mut state, terminal, "REINVOKE");
exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)");
assert!(
buffer_text_by_name(&state, SNAPSHOT_NAME)
.expect("snapshot")
.contains("REINVOKE"),
"re-invoking must actually re-serialize, not just reuse the buffer"
);
exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)");
assert_eq!(
buffer_count(&state),
count_after_first,
"...and it must refresh IN PLACE, not accumulate buffers"
);
// Killing the snapshot alone leaves the terminal running.
exec(
&state,
&format!(
r"
for _, id in ipairs(pmacs.buffer.list()) do
local ok, d = pcall(pmacs.describe.buffer, id)
if ok and d and d.name == {SNAPSHOT_NAME:?} then pmacs.buffer.kill(id) end
end
"
),
);
assert!(
state.terminal_manager.borrow().is_terminal(terminal),
"killing the snapshot must leave the terminal untouched"
);
assert!(
buffer_text_by_name(&state, SNAPSHOT_NAME).is_none(),
"the snapshot buffer is gone"
);
// ...and it can be rebuilt afterwards.
exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)");
assert!(
buffer_text_by_name(&state, SNAPSHOT_NAME).is_some(),
"a later invoke rebuilds the snapshot"
);
// Killing the terminal takes its snapshot with it.
exec(&state, "pmacs.terminal.terminate(TERM_BUF)");
exec(&state, "pmacs.buffer.kill(TERM_BUF)");
assert!(
buffer_text_by_name(&state, SNAPSHOT_NAME).is_none(),
"killing the terminal must remove its snapshot"
);
state.process_supervisor.borrow_mut().shutdown();
}
/// Acceptance 19: `C-t` in a terminal — physically `C-c C-t`, because every
/// unescaped key goes to the child — enters copy mode; `g` refreshes and
/// `q` returns to the source terminal.
#[test]
fn acc19_escape_c_t_enters_copy_mode_and_g_and_q_work() {
let mut state = EditorState::new();
let terminal = open_fill_terminal(&mut state);
focus_terminal(&state, terminal);
let terminal_name = active_buffer_name(&state);
// The escape, then the terminal-local binding.
press(&mut state, KeyCode::Char('c'), KeyModifiers::CONTROL);
press(&mut state, KeyCode::Char('t'), KeyModifiers::CONTROL);
assert_eq!(
active_buffer_name(&state),
SNAPSHOT_NAME,
"C-c C-t must enter copy mode"
);
// `q` returns to the source terminal.
press(&mut state, KeyCode::Char('q'), KeyModifiers::NONE);
assert_eq!(
active_buffer_name(&state),
terminal_name,
"q must return to the terminal the snapshot was taken from"
);
// Now advance the world and come back WITHOUT re-invoking copy mode,
// so the snapshot is genuinely stale. Comparing a quiet terminal's
// snapshot against itself is vacuous — it passes with `render_snapshot`
// replaced by a no-op.
emit_into_child(&mut state, terminal, "AFTER-G");
exec(
&state,
&format!(
r"
for _, id in ipairs(pmacs.buffer.list()) do
local ok, d = pcall(pmacs.describe.buffer, id)
if ok and d and d.name == {SNAPSHOT_NAME:?} then
pmacs.window.switch_buffer(id)
end
end
"
),
);
assert!(
!buffer_text_by_name(&state, SNAPSHOT_NAME)
.expect("snapshot")
.contains("AFTER-G"),
"the snapshot must still be stale before `g` — otherwise the next \
assertion proves nothing"
);
press(&mut state, KeyCode::Char('g'), KeyModifiers::NONE);
assert!(
buffer_text_by_name(&state, SNAPSHOT_NAME)
.expect("snapshot")
.contains("AFTER-G"),
"`g` must re-snapshot from the live terminal"
);
assert_eq!(
active_buffer_name(&state),
SNAPSHOT_NAME,
"g must not move us"
);
state.process_supervisor.borrow_mut().shutdown();
}
/// Acceptance 20: copy mode is additive — the live terminal's own keys are
/// unchanged while a snapshot exists, and the terminal still follows its
/// tail.
#[test]
fn acc20_live_terminal_keys_are_unchanged_while_a_snapshot_exists() {
let mut state = EditorState::new();
let terminal = open_fill_terminal(&mut state);
let key = focus_terminal(&state, terminal);
exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)");
// Back to the terminal; its five live bindings must still resolve.
exec(&state, "pmacs.window.switch_buffer(TERM_BUF)");
for (sequence, command) in [
("M-w", "terminal.copy-selection"),
("M-v", "terminal.page-up"),
("C-v", "terminal.page-down"),
("M-<", "terminal.scroll-oldest"),
("M->", "terminal.scroll-bottom"),
] {
let resolved: Option<String> = eval(
&state,
&format!(r"local d = pmacs.describe.key({sequence:?}); return d and d.command"),
);
assert_eq!(
resolved.as_deref(),
Some(command),
"{sequence} must still be the live terminal binding"
);
}
// The terminal still FOLLOWS ITS TAIL while a snapshot exists.
//
// Read through the registered view, not `manager.snapshot(buffer)`:
// that call is context-free and always returns the live screen, so it
// reports "at the tail" even for a view forced to the oldest retained
// row. The projected view is the only thing that can distinguish them.
assert!(
view_at_bottom(&state, key),
"precondition: the view starts at the tail"
);
emit_into_child(&mut state, terminal, "TAILMARK");
assert!(
view_at_bottom(&state, key),
"new child output must not knock the view off the tail"
);
assert!(
view_text(&state, key).contains("TAILMARK"),
"the freshest output must be visible in the PROJECTED view: {:?}",
view_text(&state, key)
);
state.process_supervisor.borrow_mut().shutdown();
}
/// Acceptance 21: the dispatch-shadow count is unchanged at six, pinned by
/// the observable difference between a buffer-local keymap and a shadow —
/// `describe-key` telling the truth about `g` and `q` in the snapshot.
///
/// A seventh shadow would decode these keys before `KeymapStack::resolve`
/// ever ran, so introspection would report whatever the global binding is
/// (or nothing) while the keys behaved differently.
#[test]
fn acc21_describe_key_reports_the_truth_for_the_snapshot_bindings() {
let mut state = EditorState::new();
let terminal = open_fill_terminal(&mut state);
focus_terminal(&state, terminal);
exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)");
for (sequence, command) in [("g", "terminal.copy-refresh"), ("q", "terminal.copy-quit")] {
let resolved: Option<String> = eval(
&state,
&format!(r"local d = pmacs.describe.key({sequence:?}); return d and d.command"),
);
assert_eq!(
resolved.as_deref(),
Some(command),
"describe-key must report the buffer-local {sequence} binding"
);
}
// And the binding really is scoped: back in the terminal, `q` is not
// the copy-mode command.
exec(&state, "pmacs.window.switch_buffer(TERM_BUF)");
let resolved: Option<String> = eval(
&state,
r#"local d = pmacs.describe.key("q"); return d and d.command"#,
);
assert_ne!(
resolved.as_deref(),
Some("terminal.copy-quit"),
"the snapshot's q must not leak into the terminal buffer"
);
state.process_supervisor.borrow_mut().shutdown();
}
/// Acceptance 18a (review round 1, P1): a foreign buffer that happens to
/// carry the snapshot's name is **never adopted**.
///
/// `pmacs.buffer.create` takes any caller-chosen name, and snapshot writes
/// use `bypass_intercept`, so found-by-name adoption clobbers a user's
/// data outright. Ownership means "in copy mode's own handle table"
/// (dired's F7 rule); a taken name gets a `<2>` variant instead.
#[test]
fn acc18a_a_foreign_same_named_buffer_is_never_adopted_or_clobbered() {
let mut state = EditorState::new();
let terminal = open_fill_terminal(&mut state);
focus_terminal(&state, terminal);
// A user's buffer, sitting exactly where the snapshot wants to go.
exec(
&state,
&format!(
r"
FOREIGN = pmacs.buffer.create({SNAPSHOT_NAME:?})
FOREIGN:insert(0, 'do not clobber')
"
),
);
exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)");
let foreign_text: String = eval(&state, r"return FOREIGN:slice(0, FOREIGN:len())");
assert_eq!(
foreign_text, "do not clobber",
"the foreign buffer must be untouched"
);
assert_ne!(
active_buffer_name(&state),
SNAPSHOT_NAME,
"copy mode must not display the foreign buffer"
);
assert_eq!(
active_buffer_name(&state),
format!("{SNAPSHOT_NAME}<2>"),
"a taken name must yield a unique variant"
);
assert!(
buffer_text_by_name(&state, &format!("{SNAPSHOT_NAME}<2>"))
.expect("variant snapshot")
.contains("LINE200"),
"the variant is the real snapshot"
);
state.process_supervisor.borrow_mut().shutdown();
}
/// Acceptance 18b (review round 1, P1): snapshot identity is the terminal
/// BUFFER, not its name.
///
/// `TerminalManager::open` uniquifies only the *derived* name — an
/// explicit `name = ...` is inserted verbatim — so two valid terminals can
/// share a name. Keying snapshots by name gives them one buffer between
/// them: the second invocation retargets it, `q` returns to the wrong
/// terminal, and killing either one removes the shared snapshot.
#[test]
fn acc18b_two_same_named_terminals_get_two_independent_snapshots() {
let mut state = EditorState::new();
exec(&state, FILL_PROFILE);
let before = terminal_buffers(&state);
exec(
&state,
r#"TERM_A = pmacs.terminal.open { profile = "fill", name = "*same*" }"#,
);
exec(
&state,
r#"TERM_B = pmacs.terminal.open { profile = "fill", name = "*same*" }"#,
);
let fresh: Vec<_> = terminal_buffers(&state)
.into_iter()
.filter(|id| !before.contains(id))
.collect();
assert_eq!(fresh.len(), 2, "two terminals opened under one name");
// Distinguish them by content, since their names are identical.
emit_into_child(&mut state, fresh[0], "AAAA");
emit_into_child(&mut state, fresh[1], "BBBB");
focus_terminal(&state, fresh[0]);
let snap_a: String = eval(
&state,
r"local b = pmacs.terminal.copy_mode(TERM_A); return (pmacs.describe.buffer(b)).name",
);
focus_terminal(&state, fresh[1]);
let snap_b: String = eval(
&state,
r"local b = pmacs.terminal.copy_mode(TERM_B); return (pmacs.describe.buffer(b)).name",
);
assert_ne!(
snap_a, snap_b,
"two terminals must not share one snapshot buffer"
);
let text_a = buffer_text_by_name(&state, &snap_a).expect("snapshot A");
let text_b = buffer_text_by_name(&state, &snap_b).expect("snapshot B");
assert!(
text_a.contains("AAAA") && !text_a.contains("BBBB"),
"snapshot A must hold only A's output: {:?}",
&text_a[text_a.len().saturating_sub(60)..]
);
assert!(
text_b.contains("BBBB") && !text_b.contains("AAAA"),
"snapshot B must hold only B's output"
);
// `q` from each snapshot returns to ITS OWN terminal, which is only
// observable through the buffer id — the two names are the same.
exec(
&state,
&format!(
r"
for _, id in ipairs(pmacs.buffer.list()) do
local ok, d = pcall(pmacs.describe.buffer, id)
if ok and d and d.name == {snap_b:?} then pmacs.window.switch_buffer(id) end
end
"
),
);
press(&mut state, KeyCode::Char('q'), KeyModifiers::NONE);
let returned_is_b: bool = eval(&state, r"return pmacs.window.buffer() == TERM_B");
assert!(
returned_is_b,
"q from B's snapshot must return to terminal B"
);
// Killing terminal A removes only A's snapshot.
exec(&state, "pmacs.terminal.terminate(TERM_A)");
exec(&state, "pmacs.buffer.kill(TERM_A)");
assert!(
buffer_text_by_name(&state, &snap_a).is_none(),
"A's snapshot dies with A"
);
assert!(
buffer_text_by_name(&state, &snap_b).is_some(),
"B's snapshot must SURVIVE — a shared buffer would have gone too"
);
state.process_supervisor.borrow_mut().shutdown();
}
/// Copy mode refuses a non-terminal buffer rather than producing an empty
/// snapshot of nothing.
#[test]
fn copy_mode_refuses_a_non_terminal_buffer() {
let state = EditorState::new();
let err = eval_err(&state, "return pmacs.terminal.copy_mode()");
assert!(
err.contains("not a terminal"),
"the refusal must say why: {err}"
);
}

View File

@ -0,0 +1,735 @@
//! Typed-edit consumer chain acceptance (Arc 8 Stage 4a,
//! docs/lean4-mode-framing.md Q#LN10, criteria 46a46h).
//!
//! The chain owns the single `buffer.after-edit` subscriber that reads
//! the one-shot typed-edit record (Q#AP9) and offers it to consumers in
//! priority order. These tests pin the chain's OWN behavior — take-once,
//! priority ordering, claim-stops-chain, throw containment, per-consumer
//! record isolation, snapshot iteration under re-entrant registration,
//! the registration lifecycle, and the Q#AP7 flush ordering it inherited
//! from `pair.lua`.
//!
//! They deliberately do not re-test auto-pairing: criterion 46 requires
//! `tests/auto_pair_acceptance.rs` to pass byte-identical, and that
//! suite is the no-behavior-change pin. Pairing appears here only as
//! the chain's last consumer, which is how 46c observes that a claim
//! really stopped the chain.
//!
//! Dispatch-driven throughout: `dispatch_key` is the producer that arms
//! the record for a grid frontend.
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};
use std::time::{Duration, Instant};
fn fresh_state_dir() -> PathBuf {
static SEQ: AtomicUsize = AtomicUsize::new(0);
let dir = std::env::temp_dir().join(format!(
"pmacs-typededit-{}-{}",
std::process::id(),
SEQ.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
KeyEvent {
code,
modifiers: mods,
kind: KeyEventKind::Press,
state: KeyEventState::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<T: mlua::FromLuaMulti>(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 status(s: &EditorState) -> String {
s.core.borrow().status.clone()
}
/// Fresh scratch-buffer editor, cursor at 0. Scratch pairing uses the
/// `default` set, so `(` pairs — which is what 46c reads.
fn editor_with(body: &str) -> EditorState {
let s = EditorState::new();
if !body.is_empty() {
exec(&s, &format!("pmacs.window.buffer():insert(0, {body:?})"));
}
exec(&s, "pmacs.editor.goto_byte(0)");
s
}
// ---------------------------------------------------------------------------
// 46a — one read for the whole fan-out
// ---------------------------------------------------------------------------
#[test]
fn chain_reads_the_record_once_and_hands_the_same_one_to_every_consumer() {
let mut s = editor_with("");
exec(
&s,
r#"
_G.seen = {}
local function spy(tag)
return function(rec)
-- Each consumer independently attempts its own take. Under
-- the pre-chain design this is exactly what a second
-- consumer would have done, and exactly what would have
-- returned nil (or stolen the record from pairing).
local own = pmacs.editor.take_typed_edit()
_G.seen[#_G.seen + 1] = {
tag = tag,
char = rec and rec.char,
post_cursor = rec and rec.post_cursor,
clean = rec and rec.clean,
own_take_was_nil = (own == nil),
}
return false
end
end
pmacs.typed_edit.add_consumer { name = "spy-a", priority = 1, fn = spy("a") }
pmacs.typed_edit.add_consumer { name = "spy-b", priority = 2, fn = spy("b") }
"#,
);
type_str(&mut s, "x");
let (n, a_char, b_char, a_pc, b_pc, a_clean, b_clean, a_nil, b_nil): (
i64,
String,
String,
i64,
i64,
bool,
bool,
bool,
bool,
) = eval(
&s,
"
local a, b = _G.seen[1], _G.seen[2]
return #_G.seen, a.char, b.char, a.post_cursor, b.post_cursor,
a.clean, b.clean, a.own_take_was_nil, b.own_take_was_nil
",
);
assert_eq!(n, 2, "both consumers ran for one typed character");
// The same record, not two reads of a slot that only one could win.
assert_eq!(a_char, "x");
assert_eq!(b_char, "x", "the second consumer sees the record too");
assert_eq!((a_pc, b_pc), (1, 1), "identical post_cursor");
assert!(a_clean && b_clean, "identical clean verdict");
// ...and the chain, not the consumers, did the taking.
assert!(
a_nil && b_nil,
"a consumer's own take_typed_edit() observes nil — the chain \
already consumed the one-shot slot (Q#AP9)"
);
}
#[test]
fn consumers_run_when_the_fan_out_carries_no_record() {
// The chain calls consumers with nil rather than skipping them.
// Three tests in the auto-pairing suite depend on this (they assert
// `_last_record == nil` after a record-less fan-out), so it is a
// load-bearing decision and not an implementation detail.
let s = editor_with("");
exec(
&s,
r#"
_G.calls, _G.nil_calls = 0, 0
pmacs.typed_edit.add_consumer {
name = "nil-spy", priority = 1,
fn = function(rec)
_G.calls = _G.calls + 1
if rec == nil then _G.nil_calls = _G.nil_calls + 1 end
return false
end,
}
"#,
);
// A manual fan-out arms no record.
exec(&s, "pmacs.hook.run(\"buffer.after-edit\")");
let (calls, nil_calls): (i64, i64) = eval(&s, "return _G.calls, _G.nil_calls");
assert_eq!(calls, 1, "the consumer ran");
assert_eq!(nil_calls, 1, "and was handed nil, not skipped");
}
// ---------------------------------------------------------------------------
// 46b — priority order, not registration order
// ---------------------------------------------------------------------------
#[test]
fn consumers_run_in_priority_order_not_registration_order() {
let mut s = editor_with("");
// Registered HIGH priority first. If the chain honored registration
// order (or `include_str!` order, which is the same failure dressed
// differently), the observed order would be the registration order.
exec(
&s,
r#"
_G.order = {}
local function mark(tag)
return function() _G.order[#_G.order + 1] = tag; return false end
end
pmacs.typed_edit.add_consumer { name = "late", priority = 30, fn = mark("late") }
pmacs.typed_edit.add_consumer { name = "early", priority = 10, fn = mark("early") }
pmacs.typed_edit.add_consumer { name = "mid", priority = 20, fn = mark("mid") }
"#,
);
type_str(&mut s, "x");
let order: String = eval(&s, "return table.concat(_G.order, ',')");
assert_eq!(
order, "early,mid,late",
"lowest priority runs first, regardless of when it registered"
);
}
#[test]
fn equal_priorities_break_by_registration_order() {
// The stated tiebreak. Lua's `table.sort` is not stable, so this
// bites an implementation that sorts instead of inserting in place.
let mut s = editor_with("");
exec(
&s,
r#"
_G.order = {}
local function mark(tag)
return function() _G.order[#_G.order + 1] = tag; return false end
end
pmacs.typed_edit.add_consumer { name = "first", priority = 5, fn = mark("first") }
pmacs.typed_edit.add_consumer { name = "second", priority = 5, fn = mark("second") }
pmacs.typed_edit.add_consumer { name = "third", priority = 5, fn = mark("third") }
"#,
);
type_str(&mut s, "x");
let order: String = eval(&s, "return table.concat(_G.order, ',')");
assert_eq!(order, "first,second,third");
}
// ---------------------------------------------------------------------------
// 46c — a claim stops the chain
// ---------------------------------------------------------------------------
#[test]
fn a_claiming_consumer_stops_the_chain() {
let mut s = editor_with("");
exec(
&s,
r#"
_G.later_ran = false
pmacs.typed_edit.add_consumer {
name = "claimer", priority = 1, fn = function() return true end,
}
pmacs.typed_edit.add_consumer {
name = "later", priority = 2,
fn = function() _G.later_ran = true; return false end,
}
"#,
);
type_str(&mut s, "(");
let later_ran: bool = eval(&s, "return _G.later_ran");
assert!(!later_ran, "a later consumer must not run after a claim");
// Pairing is the chain's last consumer at priority 100, so the
// claim is observable in the buffer: no closer was inserted. This
// is the assertion that makes the criterion about behavior rather
// than about a bookkeeping flag.
assert_eq!(
buffer_text(&s),
"(",
"auto-pairing never ran, so the opener stands alone"
);
}
#[test]
fn a_non_claiming_consumer_does_not_stop_the_chain() {
let mut s = editor_with("");
exec(
&s,
r#"
_G.later_ran = false
pmacs.typed_edit.add_consumer {
name = "passer", priority = 1, fn = function() return false end,
}
pmacs.typed_edit.add_consumer {
name = "later", priority = 2,
fn = function() _G.later_ran = true; return false end,
}
"#,
);
type_str(&mut s, "(");
let later_ran: bool = eval(&s, "return _G.later_ran");
assert!(later_ran, "a declining consumer passes the edit along");
assert_eq!(
buffer_text(&s),
"()",
"and pairing, still last in the chain, reacted normally"
);
}
// ---------------------------------------------------------------------------
// 46d — a throwing consumer is contained
// ---------------------------------------------------------------------------
#[test]
fn a_throwing_consumer_is_contained_reported_and_does_not_stop_the_chain() {
let mut s = editor_with("");
exec(
&s,
r#"
_G.later_ran = false
pmacs.typed_edit.add_consumer {
name = "boom", priority = 1,
fn = function() error("consumer exploded") end,
}
pmacs.typed_edit.add_consumer {
name = "later", priority = 2,
fn = function() _G.later_ran = true; return false end,
}
"#,
);
// An uncontained throw would abandon every LATER consumer in the
// chain and mark the whole `buffer.after-edit` run failed. It would
// NOT stop the hook's other subscribers — all-must-succeed collects
// errors and keeps going (`src/hook.rs`'s `run_all_must_succeed`) —
// so what this pins is that one broken consumer cannot silently
// disable the ones behind it.
type_str(&mut s, "(");
let later_ran: bool = eval(&s, "return _G.later_ran");
assert!(later_ran, "a throwing consumer must not stop the chain");
assert_eq!(
buffer_text(&s),
"()",
"and pairing still ran — the fan-out survived the throw"
);
let st = status(&s);
assert!(
st.contains("boom") && st.contains("consumer exploded"),
"the failure is reported by consumer name and message, got {st:?}"
);
}
#[test]
fn add_consumer_rejects_malformed_registrations() {
let s = editor_with("");
for (src, want) in [
(
"pmacs.typed_edit.add_consumer(\"nope\")",
"spec must be a table",
),
(
"pmacs.typed_edit.add_consumer{ priority = 1, fn = function() end }",
"name must be a non-empty string",
),
(
"pmacs.typed_edit.add_consumer{ name = \"n\", fn = function() end }",
"priority must be a finite integer",
),
(
"pmacs.typed_edit.add_consumer{ name = \"n\", priority = 1 }",
"fn must be a function",
),
// NaN is a number and every ordered comparison with it is
// false, so a bare type check lets it land wherever the
// insertion scan gives up — and the lowest-first contract the
// Lean expander depends on quietly stops holding. The
// infinities and non-integers go with it: priority matches
// `pmacs.completion.register`'s i32.
(
"pmacs.typed_edit.add_consumer{ name = \"n\", priority = 0/0, \
fn = function() end }",
"priority must be a finite integer",
),
(
"pmacs.typed_edit.add_consumer{ name = \"n\", priority = math.huge, \
fn = function() end }",
"priority must be a finite integer",
),
(
"pmacs.typed_edit.add_consumer{ name = \"n\", priority = -math.huge, \
fn = function() end }",
"priority must be a finite integer",
),
(
"pmacs.typed_edit.add_consumer{ name = \"n\", priority = 1.5, \
fn = function() end }",
"priority must be a finite integer",
),
(
"pmacs.typed_edit.add_consumer{ name = \"n\", priority = 4e9, \
fn = function() end }",
"priority must be a finite integer",
),
] {
let err = s
.lua_host
.lua()
.load(src.to_string())
.exec()
.expect_err("malformed registration must throw");
let msg = err.to_string();
assert!(
msg.contains(want),
"expected {want:?} in the error for {src:?}, got {msg:?}"
);
}
}
#[test]
fn an_error_whose_rendering_throws_is_still_contained() {
// A Lua error may be any value, including a table whose
// `__tostring` throws. Rendering it outside the containment is a
// second, uncontained throw — the chain would stop at exactly the
// consumer it was trying to report.
let mut s = editor_with("");
exec(
&s,
r#"
_G.later_ran = false
local hostile = setmetatable({}, {
__tostring = function() error("rendering exploded") end,
})
pmacs.typed_edit.add_consumer {
name = "boom", priority = 1, fn = function() error(hostile) end,
}
pmacs.typed_edit.add_consumer {
name = "later", priority = 2,
fn = function() _G.later_ran = true; return false end,
}
"#,
);
type_str(&mut s, "(");
let later_ran: bool = eval(&s, "return _G.later_ran");
assert!(
later_ran,
"an unrenderable error must not escape the containment"
);
assert_eq!(buffer_text(&s), "()", "and pairing still ran");
let st = status(&s);
assert!(
st.contains("boom") && st.contains("<unprintable error>"),
"the consumer is still named, with a placeholder body, got {st:?}"
);
}
// ---------------------------------------------------------------------------
// The record a consumer sees is its own
// ---------------------------------------------------------------------------
#[test]
fn a_consumers_mutation_of_the_record_cannot_reach_the_next_consumer() {
// The record is plain Lua data. Handing every consumer the same
// table lets a DECLINING consumer rewrite provenance for the ones
// behind it — and pairing decides what to close from `rec.char`,
// so a forged `char` makes it insert a pair the user never typed.
let mut s = editor_with("");
exec(
&s,
r#"
_G.downstream_char = "unset"
pmacs.typed_edit.add_consumer {
name = "vandal", priority = 1,
fn = function(rec)
if rec then rec.char = "("; rec.codepoint = 40 end
return false
end,
}
pmacs.typed_edit.add_consumer {
name = "witness", priority = 2,
fn = function(rec)
_G.downstream_char = rec and rec.char or "nil"
return false
end,
}
"#,
);
type_str(&mut s, "x");
let downstream: String = eval(&s, "return _G.downstream_char");
assert_eq!(
downstream, "x",
"the next consumer sees the real typed character"
);
assert_eq!(
buffer_text(&s),
"x",
"and pairing, reading the same field, did not close a forged opener"
);
}
// ---------------------------------------------------------------------------
// Re-entrant registration, and the consumer lifecycle
// ---------------------------------------------------------------------------
#[test]
fn registering_or_removing_during_a_fan_out_takes_effect_on_the_next_one() {
// The fan-out iterates a snapshot. Iterating the live array instead
// lets a consumer that registers a LOWER-priority one shift itself
// forward under `ipairs` and run twice in a single fan-out — and
// repeating the registration makes that unbounded.
let mut s = editor_with("");
exec(
&s,
r#"
_G.order = {}
local function mark(tag)
return function() _G.order[#_G.order + 1] = tag; return false end
end
_G.doomed = pmacs.typed_edit.add_consumer {
name = "doomed", priority = 50, fn = mark("doomed"),
}
_G.did_register = false
pmacs.typed_edit.add_consumer {
name = "a", priority = 10,
fn = function()
_G.order[#_G.order + 1] = "a"
if not _G.did_register then
_G.did_register = true
pmacs.typed_edit.add_consumer { name = "b", priority = 5, fn = mark("b") }
pmacs.typed_edit.remove_consumer(_G.doomed)
end
return false
end,
}
"#,
);
type_str(&mut s, "x");
let first: String = eval(&s, "return table.concat(_G.order, ',')");
assert_eq!(
first, "a,doomed",
"`a` runs once even though it registered ahead of itself, and \
`doomed` still runs in the fan-out it was removed during"
);
exec(&s, "_G.order = {}");
type_str(&mut s, "y");
let second: String = eval(&s, "return table.concat(_G.order, ',')");
assert_eq!(
second, "b,a",
"both the registration and the removal land on the next fan-out"
);
}
#[test]
fn remove_consumer_unregisters_and_reports_whether_it_was_live() {
// Without removal, re-evaluating a config or reloading a package
// accumulates callbacks permanently — the leak COHERENCE.md §13
// already records against `pmacs.hook.add`. A chain with no
// teardown would inherit it and spread it to every consumer.
let mut s = editor_with("");
exec(
&s,
r#"
_G.runs = 0
_G.h = pmacs.typed_edit.add_consumer {
name = "temporary", priority = 1,
fn = function() _G.runs = _G.runs + 1; return false end,
}
"#,
);
type_str(&mut s, "x");
let runs: i64 = eval(&s, "return _G.runs");
assert_eq!(runs, 1, "registered consumers run");
let first_removal: bool = eval(&s, "return pmacs.typed_edit.remove_consumer(_G.h)");
let second_removal: bool = eval(&s, "return pmacs.typed_edit.remove_consumer(_G.h)");
assert!(first_removal, "removing a live consumer reports true");
assert!(
!second_removal,
"a double-remove is a reportable no-op, not a throw"
);
type_str(&mut s, "y");
let runs: i64 = eval(&s, "return _G.runs");
assert_eq!(runs, 1, "the removed consumer no longer runs");
// Removal is surgical: the chain itself, and pairing on it, survive.
exec(&s, "pmacs.editor.goto_byte(pmacs.window.buffer():len())");
type_str(&mut s, "(");
assert_eq!(
buffer_text(&s),
"xy()",
"the rest of the chain is untouched"
);
}
// ---------------------------------------------------------------------------
// 46e — the Q#AP7 flush ordering the chain inherited
// ---------------------------------------------------------------------------
fn fake_lsp_path() -> String {
env!("CARGO_BIN_EXE_pmacs_fake_lsp").to_owned()
}
fn pump_lua_flag(state: &mut EditorState, flag: &str, secs: u64) -> bool {
let deadline = Instant::now() + Duration::from_secs(secs);
loop {
state.tick_processes();
state.tick_lsp();
state.tick_async();
let done: bool = state
.lua_host
.lua()
.load(format!("return ({flag}) == true"))
.eval()
.unwrap_or(false);
if done {
return true;
}
if Instant::now() >= deadline {
return false;
}
std::thread::sleep(Duration::from_millis(10));
}
}
/// The `text` of every `textDocument/didChange` line in the sink, in
/// arrival order.
fn did_change_texts(sink: &std::path::Path) -> Vec<String> {
let Ok(raw) = std::fs::read_to_string(sink) else {
return Vec::new();
};
raw.lines()
.filter_map(|l| serde_json::from_str::<serde_json::Value>(l).ok())
.filter(|v| v.get("method").and_then(|m| m.as_str()) == Some("textDocument/didChange"))
.filter_map(|v| v.get("text").and_then(|t| t.as_str()).map(str::to_owned))
.collect()
}
#[test]
fn a_chain_consumers_edit_reaches_the_first_did_change() {
// Q#AP7 generalized from pairing to the chain: lsp.lua's after-edit
// callback flushes didChange SYNCHRONOUSLY on the signature-trigger
// path, so every reaction to a typed character must already be in
// the buffer when it runs. The auto-pairing suite pins this for
// pairing; this pins it for the chain itself, which is what now
// owns the registration position.
//
// Falsified by loading typed_edit.lua after lsp.lua in
// `src/editor.rs`: the consumer's text would then arrive in the
// SECOND didChange, or not at all.
let dir = fresh_state_dir();
let sink = dir.join("changes.jsonl");
let sink_disp = sink.display().to_string();
let fake = fake_lsp_path();
let mut s = EditorState::new();
s.lua_host.lua().remove_app_data::<StateDir>();
s.lua_host.lua().set_app_data(StateDir(dir.clone()));
exec(&s, "pmacs.lsp.config = {}");
exec(
&s,
&format!(
"pmacs.lsp.config.rust = {{
command = '{fake}',
env = {{
PMACS_FAKE_LSP_MODE = 'sighelp',
PMACS_FAKE_LSP_CHANGE_SINK = '{sink_disp}',
}},
}}"
),
);
// A consumer that appends a marker of its own, ahead of pairing.
// It declines the claim so pairing still runs — the assertion is
// about ordering against the flush, not about claiming.
exec(
&s,
r#"
pmacs.typed_edit.add_consumer {
name = "marker", priority = 1,
fn = function(rec)
if not rec then return false end
if rec.char ~= "(" then return false end
local buf = pmacs.window.buffer()
buf:insert(buf:len(), "Z")
return false
end,
}
"#,
);
let f = dir.join("a.rs");
std::fs::write(&f, "\n").unwrap();
let fd = f.display().to_string();
exec(&s, &format!("pmacs.buffer.find_or_open({fd:?})"));
exec(&s, "pmacs.editor.goto_byte(0)");
let initialized = "(function() \
for _,r in ipairs(pmacs.lsp.list()) do \
if r.state and r.state.kind=='initialized' then return true end \
end \
return false \
end)()";
assert!(pump_lua_flag(&mut s, initialized, 5), "fake server init");
type_str(&mut s, "(");
assert_eq!(
buffer_text(&s),
"()\nZ",
"both the chain consumer's marker and pairing's closer landed"
);
let deadline = Instant::now() + Duration::from_secs(5);
let changes = loop {
s.tick_processes();
s.tick_lsp();
s.tick_async();
let c = did_change_texts(&sink);
if !c.is_empty() {
break c;
}
assert!(
Instant::now() < deadline,
"no didChange reached the fake server"
);
std::thread::sleep(Duration::from_millis(10));
};
assert_eq!(
changes[0], "()\nZ",
"the FIRST didChange carries BOTH reactions — the chain ran \
before lsp.lua's synchronous flush (Q#AP7)"
);
}

View File

@ -691,6 +691,9 @@ fn a37_real_daemon_real_pty_and_headless_gpu_render_one_terminal_session() {
.arg(&report)
// The chord the probe presses to run `vterm-probe.open`.
.env("PMACS_GPU_PROBE_OPEN_KEY", "t")
// This producer fixture does not consume the probe's input; wait
// instead for its own live cursor-addressed breadcrumb.
.env("PMACS_GPU_PROBE_EXPECT_TEXT", "VTERMROW")
.output()
.expect("run the headless GPU probe");
@ -717,7 +720,7 @@ fn a37_real_daemon_real_pty_and_headless_gpu_render_one_terminal_session() {
assert_eq!(
facts.get("server_protocol_version").copied(),
Some("20"),
"the real daemon negotiated v20 with the real client: {text}"
"the dark v21 wire slice must keep the real client on v20: {text}"
);
assert_eq!(
facts.get("entered_terminal_mode").copied(),
@ -746,6 +749,11 @@ fn a37_real_daemon_real_pty_and_headless_gpu_render_one_terminal_session() {
.is_some_and(|t| t.contains("VTERMROW")),
"the child's cursor-addressed output must reach the rendered frame: {text}"
);
assert_eq!(
facts.get("completion_observed").copied(),
Some("true"),
"the probe must finish on the fixture's PTY evidence, not its deadline: {text}"
);
let declarations: u32 = facts
.get("declarations")
.and_then(|v| v.parse().ok())
@ -846,7 +854,7 @@ fn terminal_mode_keeps_reporting_presence_so_peers_drop_the_stale_caret() {
panic!("timed out waiting for {what}");
}
assert_eq!(PROTOCOL_VERSION, 20);
assert_eq!(PROTOCOL_VERSION, 21);
let daemon = common::daemon::TestDaemon::spawn_with_env_and_init(
&[
("PMACS_INSTANCE_SEMANTIC_RENDER", "1"),
@ -1311,4 +1319,10 @@ fn gpu_terminal_input_reaches_the_child_and_returns_in_a_frame() {
"the typed character must reach the child and return: {}",
report()
);
assert_eq!(
facts.get("completion_observed").map(String::as_str),
Some("true"),
"the probe must finish on the latched input echo, not its deadline: {}",
report()
);
}