Merge canonical main into bottom-panel Stage 2B-1

Integrate the Journey Stage 1a merge without rewriting the already
reviewed protocol branch. Record the approved three-way Stage 2B split,
advance the canonical recovery anchor, remove the landed Journey lane,
and put 2B-1 into its full-gating state.
This commit is contained in:
Levi Neuwirth 2026-07-27 22:16:11 -04:00
commit d88d60eba6
21 changed files with 8045 additions and 652 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 |
@ -112,7 +112,7 @@ remain open to them.
| 16 | Semantic frontend | **Strong** | v6..=v20 negotiated protocol; degradation practiced; TUI/GPU share the model |
| 17 | Distribution | **Missing** | CI is test-only; no binaries, channels, checksums, or update path |
| 18 | Onboarding | **Missing** | No welcome, no tutorial; `C-h` deletes a word; `M-x` is the only door in |
| 19 | Coherence acceptance tests | **Missing (culture ready)** | Superb per-arc acceptance discipline; zero cross-subsystem journey tests |
| 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
@ -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,7 +372,7 @@ 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 (merged #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 |
@ -1041,12 +1050,15 @@ 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 eight 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), and `terminal.default-profile` +
(window.lua), `terminal.default-profile` +
`terminal.scrollback-rows` + `terminal.escape-key` (terminal.lua,
#173). Everything else a user might set — theme, fonts, LSP
#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`
@ -1076,7 +1088,7 @@ layering, provenance, and adoption have not followed.**
- **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 eight 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.
@ -1222,14 +1234,23 @@ Primitive-by-primitive against the list above:
`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; `*compilation*` and listview panels have
not yet adopted it and remain emptiable. **A second half of the same
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.
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`
@ -1443,20 +1464,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*
@ -1474,14 +1499,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, #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
@ -1545,11 +1574,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. Dired Stage 1 has landed (#165), so the
buffer a directory resolves *to* already exists; this arc routes
`pmacs .` into it rather than growing a second directory surface.
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
-- ---------------------------------------------------------------------------

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

@ -1,18 +1,22 @@
# Active work — cross-machine resume ledger
**Snapshot: 2026-07-26.** This file records volatile work that has not
**Snapshot: 2026-07-27.** 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.
**Two lane headers below are stale on purpose**, pending the docs updates
their own lanes owe: multi-root LSP affinity **#161 has merged** (the
Lean 4 lane still says IN REVIEW; its continuation is PR #167) and GPU
terminal input **#166 has merged** (its lane still says IN REVIEW; PR
#168 records it). Trust the canonical-base line below over a lane header:
if a PR number appears in `git log --first-parent githubsucks/main`, it
has landed regardless of what its lane says. (The inline-math lane was
here too until #172 removed it — that is the update those two owe.)
**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
@ -23,16 +27,15 @@ here too until #172 removed it — that is the update those two owe.)
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` @ `a27f646` (Lean 4 Stage 4a #179 atop 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, and the minimap blank-slab fix #159; protocol v20). The
previous snapshot named `d152120`; the recovery check below accepts it
or anything newer.
`githubsucks/main` @ `c2d56ff` (Journey Stage 1a #182, which
incorporated terminal configuration + copy mode #180, atop Lean 4
Stage 4b #181 and the previously recorded landed work; protocol v20).
The previous snapshot named `42025e4`, and **the recovery floor
advances with it**: the check below now requires `c2d56ff` or newer,
so a tree at `42025e4` 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
@ -68,14 +71,21 @@ git worktree list
git status --short --branch
```
The `git log` command must expose `c93f9ee` — the base named above — or a
The `git log` command must expose `c2d56ff` — 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.
## PTY terminate diagnostic lane — IN REVIEW (PR #176)
## PTY terminate diagnostic lane — MERGED (PR #176)
> **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/pty-terminate-eperm`; worktree
`../pmacs-math-slice`. **PR #176**, base `main`, based on `ccf29e3`
@ -161,176 +171,6 @@ If it does not, stop and repair the remote/fetch configuration.
to recur; the next occurrence carries its own evidence under whoever's
PR, and a Stage B framing follows then.
## Lean 4 lane (Arc 8) — Stages 1, 2, 3a, 3b, 4a MERGED; 4b is next
- **Stages 1, 2, 3a and 3b are MERGED**#160 (`main` @ `0827dd1`),
#161 (`46a1b8f`), #167 (`6f348c9`), #170 (`d400f30`). Their full
histories were pruned from this ledger in round 6, per this file's own
instruction to remove entries when their PR merges; the durable facts
now live in `docs/agent-handoff.md` §1's Lean 4 bullet, which is where
a fresh machine should read them. `docs/lean4-mode-framing.md` rev 8
carries the decisions.
### Stage 4 — framing rev 8, split into 4a/4b (branch `lean4-stage4a-typed-edit-chain`)
- Stages 3a and 3b **merged as #167** (`main` @ `6f348c9`) and **#170**
(`main` @ `d400f30`), 2026-07-26. Both were integrated against a main
that had advanced 50 commits mid-review; the only conflict either time
was this ledger's own lane headings, resolved by keeping both sides.
- **Stage 4a merged as #179** (`main` @ `a27f646`, 2026-07-26) — the
typed-edit consumer chain. Worktree `../pmacs-lean-stage4`, branched
off `main` @ `d400f30`; retained, carrying nothing unmerged.
`docs/lean4-mode-framing.md` **revision 8** remains the approved
framing. **Stage 4b (the Lean-specific half) is framed and not
started.**
- **Round 6 review found five P1s, four of them internal to rev 6**
facts about pmacs the revision asserted without checking, while its
external (upstream) facts held. Fixed in rev 7: Stage 4a's footprint
omitted the test file its own acceptance requires; pending
abbreviation state was keyed by buffer when pmacs is **multi-frontend**
(`EditorCore.views` is per-`FrontendId`, `take_typed_edit` is already
frontend-keyed, and `buffer.after-switch` fires with NO arguments, so
a buffer-keyed clear lets any frontend discard another's pending
state); the shortest-match rule was missing its **tie-break by source
declaration order**, which 101 prefixes depend on and a `pairs`-
iterated Lua map cannot express; and the generator's "abort on keys
needing escaping" rule **rejects the real table** (`\` is a key, `"`
begins eleven).
- **A 404 on a guessed path is not evidence of absence.** Rev 6 declared
the upstream package ships no README after fetching the package root,
with the directory listing showing `src/README.md` already in hand.
The README states the tie rule in one sentence.
- **Round 7 review found one remaining P1 in acceptance 45i.** Rev 7
required A's pending abbreviation to survive B editing the same
buffer, while Q#LN22 also required an exact buffer-revision advance.
Those cannot both hold: revisions are buffer-global and every edit
bumps them. Rev 8 keeps the conservative guard and separates
ownership from survival — B cannot consume A's record, but B editing
the shared buffer invalidates A lazily; B switching buffers or
detaching remains frontend-scoped when no shared-buffer edit
intervenes.
- **Round 5 re-scout split Stage 4 into 4a (substrate) and 4b (Lean).**
4a is the typed-edit consumer chain — `builtin/runtime/typed_edit.lua`
plus `pair.lua` re-expressed as one registered consumer, no behavior
change. 4b is the input method. The split is forced by §4's own rule,
which Stage 4's risk column ("refactors `pair.lua`'s provenance read")
broke while the prose called the stage Lean-only.
- **This is the SECOND consecutive re-scout to find that rule broken**
(round 4 found it for Stage 3). Rev 5 had even noticed the shape and
answered it with a commit boundary. **A commit boundary is not a review
boundary.** Re-check every remaining stage against §4 at scout time;
the rule is not self-enforcing.
- **Rev 5's expansion semantics were wrong in three ways**, found by
reading `leanprover/vscode-lean4` @ `17d1d08` rather than inferring
from behavior. Resolution is *shortest key having the input as a
prefix* (`\al` → `∀` from `all`, not `alpha`); there is **no
terminator list** (`'+ '` is a key, so space extends after `\+`; `'\'`
is a key, so `\\``\`); and an unmatchable tail is **appended**,
not dropped (`\alp7` → `α7`).
- **There is no cursor-motion hook**, so rev 5's acceptance 43 ("moving
the cursor out abandons it") was not buildable. Abandonment is lazy —
validated at the next typed edit — and the criterion now asserts what
pmacs can actually detect. Upstream drives this off `changeSelections`;
that seam does not exist here.
- **`dispatch_key` is only half the production path for 4b.** The
auto-pair suite gets away with dispatch-only because Q#AP1 removed the
pair chars from the optimistic classifiers; `\` and the letters are
NOT excluded, so on a CRDT frontend the optimistic producer is the real
path. That producer is `#[cfg(feature = "crdt")]` and CI never enables
`crdt`, and the gate list runs `--features crdt` only for `--lib` — a
crdt-gated integration test is **dark twice over**.
- The whole expansion has cross-peer-degraded undo (Q#LN21): six
source-peer optimistic inserts replaced by one daemon-peer op.
`set_round_trip_input` would fix it and is rejected — it also disables
`dispatch_idle`, so RET stops inserting a newline.
- Table facts re-derived at `17d1d08`: 1,855 entries, 36,861 bytes, all
keys ASCII, **64** keys carry a `lean4` pair-set char, **305** keys are
proper prefixes of another (so 1,550 expand eagerly), **26** values
carry `$CURSOR`, and **119** are multi-codepoint — the 26
`$CURSOR`-bearing values plus 93 others.
- Citation sweep per COHERENCE §25: five live citations moved in the 50
commits since rev 5 — `take_typed_edit` 12827→12990,
`handle_server_requests` 1549→1815, `fs.stat` 93→133,
`detect_buffer_language` 452→457, `send_request`/`send_notification`
9342/9361→9507/9527.
### Stage 4a — the typed-edit consumer chain (IMPLEMENTED, same branch)
- Footprint exactly as Q#LN10 declares it: `builtin/runtime/typed_edit.lua`
(new), `pair.lua` re-expressed as one consumer,
`src/editor.rs` +15 (the `include_str!` and its ordering comment), and
`tests/typed_edit_chain_acceptance.rs` (new, 13 tests).
**`tests/auto_pair_acceptance.rs` is UNCHANGED — `git diff --stat
main...HEAD -- tests/auto_pair_acceptance.rs` is empty.** That is
criterion 46 checked at the diff, which is the only way it means
anything.
- **The chain calls consumers even when the record is nil.** This is a
decision, not an implementation detail: three existing auto-pairing
tests assert `pmacs.pair._last_record == nil` after a record-less
fan-out (paste, programmatic insert, nested manual `hook.run`), so
skipping consumers on nil fails them. Stage 4b needs the same
delivery to abandon a pending abbreviation an unrelated edit
invalidated.
- **Ordered insertion, not `table.sort`** — Lua's sort is not stable, and
"ties broken by registration order" is a stated contract.
- **The chain `pcall`s each consumer** and reports through
`set_status`. Rev 7 justified this by claiming an uncontained throw
would fail the fan-out for every other subscriber including lsp.lua's
didChange flush; **that is wrong**`run_all_must_succeed`
(`src/hook.rs:332`) collects errors and continues, so the other
subscribers still run. The real consequence is narrower and still
worth containing: the throw skips every LATER consumer in the chain.
The rendering is protected too, because a Lua error may be a table
whose `__tostring` throws.
- **Round 8 (review) findings, all fixed on this branch:** each consumer
now gets its **own shallow copy** of the record (the same table let a
declining consumer rewrite `rec.char`, which pairing reads — typing
`x` could produce `x)`); the fan-out iterates a **snapshot** (a
consumer registering a lower-priority one shifted itself forward under
`ipairs` and ran twice, unbounded if repeated); `tostring` moved
inside the containment; **non-finite and non-integer priorities are
rejected** (NaN is a number and every ordered comparison with it is
false, so it landed wherever the insertion scan gave up and silently
voided the ordering contract); and `add_consumer` now returns a handle
with `remove_consumer` beside it, so re-evaluating a config no longer
leaks callbacks the way `pmacs.hook.add` does (COHERENCE §13).
- **Every acceptance test is bite-verified by mutation**, per the
standing rule that a test is not evidence until the mutation it
targets has been shown to fail it:
| Mutation | Tests it fails |
|---|---|
| append instead of ordered insert | 5 chain |
| `>=` instead of `>` in the insert scan | 1 chain (tiebreak) |
| re-take the record per consumer | 4 chain |
| ignore the claim return value | 1 chain |
| drop the `pcall` | 1 chain |
| skip consumers when `rec == nil` | 1 chain + **3 auto-pair** |
| load `typed_edit.lua` after `lsp.lua` | 1 chain + **2 auto-pair** (Q#AP7) |
| hand every consumer the same record table | 1 chain (46f) |
| iterate the live array instead of a snapshot | 1 chain (46g) |
| render the error outside the `pcall` | 1 chain (46d) |
| accept any Lua number as a priority | 1 chain (46h) |
| make `remove_consumer` a no-op | 2 chain (46g, 46h) |
The first attempt at the last bite was WORTHLESS as written: moving
only `typed_edit.lua` past `lsp.lua` left `pair.lua` calling a nil
`add_consumer`, so the runtime failed to load and all 9 tests died —
loud, but not a test of the flush-ordering property. Moving
`typed_edit.lua` AND `pair.lua` past `lsp.lua` is the faithful
falsification: registration succeeds, the hook lands late, and exactly
the three ordering tests fail. **A bite that kills everything has not
isolated anything.**
- Verification on this branch (commit-then-gate, so this describes the
pushed tree): `cargo fmt --check` clean; strict workspace Clippy
clean; 1,832 default + 2,009 CRDT library tests; auto-pair 45/45;
typed-edit chain 13/13 (and 13/13 again under `--no-default-features
--features lua54`, since the fixes touch `math.huge`, `%`, and
`__tostring` behavior that differs between the backends); M4 121;
required GPU 202; **isolated-config workspace sweep 3,332 across 97
suites, zero failures** with `grep -c basedpyright` = 0; `git diff
--check` clean.
- Stage 4b (the input method) is NOT in this PR and not started.
## The CRDT half of the test corpus is dark in CI — NEEDS A LANE
- **No branch, no framing yet.** Found while gating #166, then measured
@ -342,11 +182,18 @@ If it does not, stop and repair the remote/fetch configuration.
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,024 vs 3,288 — 264 tests dark.** Per target:
`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:
| dark | CI | full | target |
|---:|---:|---:|---|
| 177 | 1,832 | 2,009 | **the library itself** (`src/lib.rs`) |
| 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` |
@ -358,15 +205,20 @@ If it does not, stop and repair the remote/fetch configuration.
| 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` |
The rows sum to 273; the table is the whole census, not its head.
- **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. 177
library tests — the whole CRDT half — are developer-machine-only.
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
@ -420,287 +272,65 @@ If it does not, stop and repair the remote/fetch configuration.
- 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.
## Terminal config + copy mode arc — Stage 1 MERGED; Stage 2 IN REVIEW
## Bottom-panel lane (Arc 7) — 2B-1 IN GATING
- Approved framing: `docs/terminal-config-and-copy-mode-framing.md`
**revision 4** (four review rounds), committed as the first commit of
Stage 1's branch. Two stages, two branches, two PRs; **no protocol
change**.
- **Stage 1 MERGED as #173** (`main` @ `cf54270`, 2026-07-26, one review
round, all twelve checks green). Branch `githubsucks/terminal-config`
and worktree `../pmacs-terminal-config` retained. Profiles, scrollback,
a per-terminal configurable escape key, and the `C-c t` opening
binding; no protocol change. Main was integrated **twice** during the
single review round (`ccf29e3`, then `c93f9ee` after the first merge
left the PR conflicting) — see the no-CI-while-conflicting fact below.
- **Stage 2 = `githubsucks/terminal-copy-mode`**, worktree
`../pmacs-terminal-copy-mode`, based on `githubsucks/main` @
`cf54270`. Copy mode: `M-x terminal.copy-mode` / `C-c C-t`.
- **Stage 2 ships eight of nine criteria, and the missing one is named.**
Criterion 17 (a real semantic frontend proving neither daemon buffer
nor mirror mutates) is **not pinned**: the optimistic apply exists only
in `pmacs-gpu/src/main.rs`, and the headless `SemanticClient` every
other semantic test uses has no optimistic path, so a faithful test
must drive the real GPU binary — the `a37` foundation, which CI never
compiles, silently skips without the binary, and is load-sensitive. A
second test on that footing buys the appearance of coverage. Both
halves of the mechanism are pinned **ungated** instead: acceptance 16
(the guard is armed — `dispatch_idle` false while the snapshot is
focused) and 16b (the daemon holds — `is_read_only()` is **true** at
the rope, so an op that did arrive is refused by `ensure_writable()`).
**Rounds 2-3 changed what 17 must show.** 16b asserted `false` through
round 1, documenting the hazard; round 2 closed it. So the eventual
real-GPU test must look for **mirror mutation plus daemon refusal —
divergence** — not the "mutates both sides, silently" the criterion
originally specified, which after the fix cannot happen and would pass
for the wrong reason. The wire-level half stays an explicit obligation
of the CI `crdt`-coverage lane.
- Load-bearing Stage 2 decisions:
- **The snapshot MATERIALIZES into an ordinary buffer**, so isearch,
motion, selection and the kill ring work with no new substrate, and
"keys must not reach the child" dissolves structurally — the
transport arm keys on `is_terminal(buffer_id)` and a snapshot is not
a terminal. **The dispatch-shadow count stays at six.**
- **One serializer, not two** (Q#TC7): `copy_retained` builds a
whole-range *selection* and hands it to `copy_selection_bytes`.
- **`prune` reacts to removal rather than causing it** — it filters on
`!registry.contains(buffer_id)`, so a child exiting does NOT remove
the terminal buffer. That is why `on_removed` is a sound teardown
hook, and why a finished command's output stays readable.
- **Five bites, five different wrong implementations.** Removing
`set_round_trip_input` fails acceptance 16 **in the default
configuration** (the whole reason that pin is ungated); a naive
independently-written serializer fails all four unit pins, with the
diffs naming each drift mode (broken soft wrap, untrimmed blanks,
trailing newline); making re-invoke create a fresh buffer fails 18;
dropping the kill-with-terminal teardown fails 18; removing the
intercept fails 16b. Each failed exactly one test.
- **Review round 1 — four findings, all real, and they rhyme in pairs.**
Two P1 implementation defects and two P2 vacuous pins, all four tracing
to one root: **a name is not an identity, and a context-free readout is
not a state observation.**
- *P1 — a foreign same-named buffer was adopted and clobbered.* Snapshot
writes use `bypass_intercept`, so found-by-name adoption overwrote a
user's buffer; the reviewer reproduced "do not clobber" becoming 23
newlines. Fixed by dired's F7 rule: **ownership means "in our own
handle table"**, and a taken name yields a `<2>` variant.
- *P1 — snapshot identity was keyed by terminal NAME.*
`TerminalManager::open` uniquifies only the *derived* name, so an
explicit `name = "*same*"` lets two valid terminals share one; they
then shared a snapshot, `q` returned to the wrong terminal, and
killing either removed it. Now 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**.
- *P2 — the refresh pins were vacuous.* 19 compared a quiet terminal's
snapshot against itself and 18 counted buffers, so both passed with
`render_snapshot` replaced by a no-op. Now the test types a marker
into the `cat` child, requires it **absent** first, then refreshes.
- *P2 — the tail-follow pin could not observe view state.*
`manager.snapshot(buffer_id)` is context-free and always reads the
live screen, so it reported "at the tail" for a view forced to the
oldest retained row. Now read through `snapshot_for_view`'s
`at_bottom` and projected cells.
- **Four more bites, all discriminating.** Restoring adopt-by-name fails
18a *and* 18b; restoring name-keyed identity fails 18b; making
`render_snapshot` a no-op fails **both** 18 and 19 (the vacuity,
demonstrated); and forcing the view off the tail fails 20.
- **Review round 2 — one P1, and its fix retires half a named deferral.**
**Undo emptied the "read-only" snapshot.** `render_snapshot` wrote with
`bypass_intercept`, leaving ordinary undo history, and **`Buffer::undo`
reaches the rope through `ensure_writable` without ever consulting the
intercept chain** — so `C-/` *or* `M-x buffer.undo` replaced a freshly
rendered snapshot with an empty buffer. `set_round_trip_input` does not
help: it routes the key into the daemon command path, which is where
undo runs.
- **Rebinding the undo chords would NOT have fixed it**, and
`compile.lua` already says so in a comment — "command/menu undo stays
dispatchable". `*compilation*` and listview panels therefore carry the
same latent defect today.
- Fixed with `Buffer::set_generated_contents` (Lua
`pmacs.buffer.set_generated_contents`): lift `read_only`, replace
skipping intercepts, **discard history**, re-assert `read_only`. This
ships the deferred lane's two halves *as one primitive* — a bare
`set_read_only` would let a caller lock a buffer it can no longer
refresh, which is exactly why that lane was deferred. Clearing history
also stops a periodically refreshed buffer accumulating rope clones
nothing can ever pop.
- New pins: **acc16c** drives the real M-x path
(`command.invoke_interactive`), the chord, and redo, and asserts the
owner's refresh still works; **acc16b** flipped from asserting
`is_read_only()` is *false* to *true*, because the property it
described is the one that was fixed; plus three `buffer.rs` unit tests.
- Bite: restoring the `delete`+`insert` render reproduces the report
exactly — `left: Some("")` against the full snapshot — failing acc16c
and acc16b.
- **Still open:** `*compilation*` and listview remain emptiable by
`M-x buffer.undo`; the primitive they need now exists and is proven,
so the remainder is adoption plus a streaming-friendly variant.
- **Review round 3 — one P1 and two P2s, all on the round-2 primitive.**
The lesson: **a rope write is only half of an edit, and "discard
history" means whichever history the buffer actually has.**
- **P1 — the binding swallowed the edit.** `set_generated_contents`
returned `()`, so nothing called `notify_buffer_edit_to_windows`.
Two consequences, both reproduced by the reviewer: in the default
build a window showing the buffer kept a `TextView` line index
describing the *previous* contents, and the next paint indexed the
new rope with stale ranges — `assertion failed: end <= self.len()`
in `src/rope.rs`; in the CRDT build `pending_crdt_ops` stayed empty,
so replica mirrors never received the owner's write. The prior
`buf:delete`/`buf:insert` pair had done this fan-out for free.
Fixed by applying **one whole-buffer `Replace`**, returning its
`Edit`, and notifying from the binding.
- **P2 — "discard history" was false in CRDT mode.** The v0.1 stacks
are bypassed entirely there; the history lives in loro's
`UndoManager`. `read_only` stops the replay but not the retention,
which is the memory cost the contract claims to eliminate.
`UndoManager` has no `clear`, but needs none — it records only what
happens after construction, the property `CrdtState::from_bytes`
already uses to keep the seed insert out of undo. New
`CrdtState::clear_undo_history` rebinds a fresh manager to the
same doc.
- **P2 — the docs described the pre-fix architecture.** Q#TC6a said no
Lua binding sets `read_only` and round-trip input is the only guard;
the acceptance text still said `is_read_only() == false` while 16b
had been flipped to `true`; `terminal.lua`'s comment repeated the
obsolete claim. The architecture is **layered** and now says so:
rope-level read-only protects the daemon copy, round-trip input
protects the replica's optimistic mirror, and neither substitutes
for the other. Q#TC6a carries a superseded-in-part box rather than
being silently rewritten.
- New pins: **acc16d** paints the window after a *shrinking* generated
write (the stale offsets then point past the end, which is the
reported crash rather than stale pixels); **acc16e** asserts the
refresh is queued for mirrors through the real copy-mode path
(`crdt`-gated, therefore dark in CI — 16d is the half that runs);
plus a CRDT `buffer.rs` unit test that ten renders leave the
`UndoManager` with nothing recorded.
- Bites: dropping the notify panics acc16d at `rope.rs:145` and fails
acc16e with `queued: []`; dropping the `UndoManager` rebind fails
the new unit test on `can_undo`.
- **Still open:** the fan-out obligation makes `*compilation*`/listview
adoption more than a one-line swap — recorded in `COHERENCE.md` §14
alongside the undo half.
- **Review round 4 — one P2, docs only, and it is the interesting kind.**
**A fix can invalidate a test that was never written.** Criterion 17's
*bite* still described the pre-round-2 world: remove
`set_round_trip_input` and the op "mutates both sides, silently, with
no divergence to notice". True while nothing set `read_only` from Lua;
false once `set_generated_contents` did. A real-GPU test written to
that spec would hunt for a daemon-side edit that can no longer occur
and pass for the wrong reason — the specification would have leaked
the round-2 regression back in, through a test not yet built.
- Restated around **unauthorized mirror mutation plus daemon refusal =
divergence**, in all four places that carried the old claim: the
criterion, the Q#TC6a heading, the acceptance-16 doc comment, and the
bite roster. The heading's "ONLY thing" now says what it is the only
thing *for* — the replica's own mirror.
- Why round-trip input is still load-bearing rather than redundant: a
daemon refusal arrives after the frontend has already applied
optimistically and painted. It buys divergence instead of silent
agreement; it does not prevent the mutation the user sees.
- **Gate-run flake observed and scoped without overclaiming its cause.**
`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:** this branch
does not touch `src/process.rs` (last changed by the Darwin PTY
signal-name fix), 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.
It belongs to the CI `crdt`-coverage lane for discrimination. The two
round-2 CRDT failures had no captured test names; this flake is a
plausible candidate for them, but they remain **unattributed**.
- Load-bearing decisions, each forced by scouted ground truth:
- profiles are a **raw Lua table**`ConfigValue` is four scalars with
no table kind, so they join `pmacs.lsp.config` / `pmacs.pair.sets`;
- the **two open-time settings resolve through the global chain**,
because they are read before the identity buffer exists; only
`terminal.escape-key` resolves per buffer;
- the escape cache lives on **`TerminalSession`** so its lifetime is
the terminal's. `value_epoch` alone is not a sufficient key: it does
not advance when focus moves between terminals with different
buffer-local values;
- repeating the escape sends **that chord**, not a hardcoded `0x03`.
- **Four bites, each against a different plausible wrong
implementation** — hardcoded ETX fails acc6/9; epoch-only cache key
fails acc7; single last-entry cache fails acc8's parse count; removing
the invalid-value fallback fails acc10. The first version of acc7
passed against the epoch-only bite because it asserted only that
terminal A still worked; the discriminating assertion is that **each**
terminal honors its own chord and not the other's.
- 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 the probe **counts occurrences** rather than
testing presence, because a single-character probe collides with the
child's own banner text.
- **Review round 1 (2026-07-25) — five findings, all real, all fixed.**
One blocker and two majors were the same failure in three places: a
claim asserted somewhere cheaper than where it lives.
- *Blocker — `COHERENCE.md` was stale in four places, not the three
reported.* Step 8 still read "no keybinding"; §11 still read "five
settings"; and §6's dispatch table still cited
`is_terminal_escape_chord`, **a symbol this PR deletes**. §25 makes
that update ride the PR. A PR that changes audited ground truth has
to re-grep the audit for its own symbols, not only for its topic.
- *Major — acceptance 5 was vacuous.* It asserted a registry
round-trip, so it stayed green with the setting's **only** consumer
deleted. It now opens a real terminal whose child overflows the
24-row screen, scrolls the view to its oldest retained row, and
asserts `LINE001` is present at 10,000 and absent at 0. **Asserting
a value was stored is not asserting anything reads it.**
- *Major — acceptance 8a asserted the session count, not the cache.*
An editor-side map with no purge hook — the exact rejected design —
leaks *while* sessions drain, so it passed. Fixed with a
`TerminalManager::escape_caches()` seam. **A lifecycle claim needs a
lifecycle observable.**
- *Moderate — `table.sort` over user-controlled profile keys.* A
table holding both a string and a numeric key raised `attempt to
compare number with string` **on the unknown-profile path**,
replacing the diagnostic being asked for; `%q` raised likewise on a
non-string `profile` argument. Both are partial functions applied to
user input **on a diagnostic path** — the error reporter was the
thing that failed.
- *Minor — the committed framing still said "not yet approved".*
- **Three new bites, each falsified by revert**: deleting the scrollback
consumer fails acc5 (and only acc5); restoring the raw-key sort
reproduces `attempt to compare string with number` verbatim; and
implementing the rejected editor-side map fails the new acc8a at
`left: 2, right: 1` **while passing the old session-count version**
which is the review finding demonstrated rather than argued.
- Verification after the round-1 fixes, on the tree merged with
`githubsucks/main` @ `c93f9ee`: `cargo fmt --check` clean; strict
workspace Clippy clean; 1,832 default + 2,009 CRDT library tests;
`terminal_config_acceptance` **12/12 in both configurations**; vterm
Stage 1/2 9+10 / 6+6; config registry 16+16; bottom-panel Stage 1
46+46; M4 121; required GPU 202; `git diff --check` clean.
- `compile_mode_acceptance` fails 11/67 against the **real** user
config and passes 67/67 with an isolated `XDG_CONFIG_HOME` — the
known pre-existing trap, not this branch.
- **`vterm_stage3_acceptance::a37` fails on this machine — and fails
identically on the PR's own base `d152120`**, so it is not this
branch's regression. It is load-sensitive: it passed at `d152120`
once and failed at that same commit twenty minutes later, with a
second agent saturating the machine with `rustc` in between. Two
ways it lies, both worth knowing: it **silently returns `ok` when
`pmacs-gpu` is not built** in the same target dir (only
`PMACS_REQUIRE_GPU=1` promotes that skip to a failure, and the gate
list applies that flag to `-p pmacs-gpu`, a *different* package), and
it is **crdt-gated, so CI has never run it at all**. A green a37 in
a gate log means nothing unless the binary was built and the flag
was set. Needs its own lane; see the CI `crdt`-coverage lane on #168.
- `pmacs-gpu` itself failed 201/202 once under the same load and passed
202/202 on immediate rerun.
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. **Stage 2B-1 is implemented, integrated with canonical
`main`, and awaiting its full gate result before a PR is opened.**
## Bottom-panel lane (Arc 7) — Stages 1, 2A + framing MERGED; 2B is next
Stage 1, the Stage 2 framing, and Stage 2A are all on `main`. **Stage 2B
has not started.**
- **Stage 2B-1 branch:** `bottom-panel-stage2b`, based on
`githubsucks/main` @ `c2d56ff` by merge because review had begun.
Recovery: `git fetch githubsucks && git checkout
bottom-panel-stage2b`. Everything described through the integration
checkpoint is committed and pushed; nothing depends on a worktree or
`/tmp`. No PR exists yet.
- **Ships only the v21 wire layer:** the four wire shapes, version bump,
shared cell-grid validator, and version-ladder move. It has no
producer, consumer, or capability change; `panel_capable` stays
`false`, so this slice changes no user-visible journey grade.
- **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.
- **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
@ -713,19 +343,7 @@ has not started.**
`primary_document_window` returns `view.active` in every existing
configuration, so this is seam adoption that becomes load-bearing in
2B.
- **Stage 2B is approved and unstarted.** It branches from `main`, **not
stacked on 2A**, per the framing §9. Scope: protocol v21, the daemon
panel projection, the GPU band, and the negotiated `panel_capable`
flip. `docs/bottom-panel-stage2-framing.md` §7.2 carries its five
acceptance criteria (A2B-1..5) plus the reassertion of parent
criterion 52, and §8 records **no open items**, so 2B needs no further
framing round. Its sharpest trap is §5.3's three-boundary split: the
GPU `text_area_bottom` is `status_band_top`,
`geometry_capacity_bottom` and `document_text_bottom` at once, and a
blanket rewrite moves the status chrome along with the document while
still satisfying an "everything moved" assertion — hence A2B-4's
contrast form.
- Verification on the merge result: `cargo fmt --check` clean; strict
- **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
@ -781,26 +399,29 @@ has not started.**
`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 4**,
- **Stage 2 framing: `docs/bottom-panel-stage2-framing.md` revision 5**,
on branch `githubsucks/bottom-panel-stage2-framing` (three commits,
one per revision), worktree `../pmacs-bp-stage2`, based on
one per pre-implementation revision), worktree `../pmacs-bp-stage2`, based on
`githubsucks/main` @ `ccf29e3`. 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. The approved
remain. Revision 5 adds no decision; it records the approved
2B-1/2B-2/2B-3 implementation split. 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 ships as two serial slices**, 2A landing before 2B branches:
- **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** = protocol **v21**
protocol change; **2B-1** = protocol **v21**
(`InstanceMessage::PanelFrame` plus
`FrontendEvent::{FrontendCellGeometry, PanelResizeRows, PanelPointer}`,
gated both directions, each extended enum byte-pinned on its own
previous final variant), daemon panel projection, the GPU band, and the
negotiated `panel_capable` flip. Stage 3 is the adopter default flip.
previous final variant); **2B-2** = daemon panel projection and epoch
machine; **2B-3** = the GPU band and 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
@ -883,6 +504,24 @@ 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
@ -932,6 +571,15 @@ git worktree add --track \
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,9 +1,15 @@
# Agent handoff — cross-machine continuity
**Last updated: 2026-07-26, after Lean 4 Stage 4a (#179) — the typed-edit
**Last updated: 2026-07-27, after Journey Stage 1a (#182) — the
directory-open seam and golden-journey ratchet, incorporating terminal
configuration + copy mode (#180) — and after 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`; following the bottom-panel Stage 2 framing
`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;
@ -35,9 +41,13 @@ 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-26)
## 1. Where the project stands (2026-07-27)
- `main` @ `a27f646` (Lean 4 Stage 4a #179 atop bottom-panel Stage 2A
- `main` @ `c2d56ff` (Journey Stage 1a #182, incorporating terminal
configuration + copy mode #180, atop 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
@ -54,11 +64,100 @@ commands, read `docs/active-work.md` immediately after this file.
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).
- **Lean 4 arc (Arc 8) — stages 1, 2, 3a, 3b LANDED**
(`docs/lean4-mode-framing.md`; #160, #161, #167, #170; merge
`d400f30`). pmacs edits Lean 4: `arborium-lean` highlighting, a
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
@ -101,9 +200,10 @@ commands, read `docs/active-work.md` immediately after this file.
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 (typed-edit consumer chain) is implemented and in review
as PR #179** (branch `lean4-stage4a-typed-edit-chain`, framing rev
8). It is substrate only: `builtin/runtime/typed_edit.lua` owns the
- **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
@ -117,10 +217,51 @@ commands, read `docs/active-work.md` immediately after this file.
iterates a **snapshot**, because a consumer that registers a
lower-priority one shifts itself forward under `ipairs` and runs
twice.
- Remaining: Stage 4b (the Unicode input method) is framed and
awaiting approval — not started; stages 5 (goal panel), 6 (`#eval`
output channel), and 7 (module hierarchy) are framed but not
scouted against current `main`.
- **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
@ -319,12 +460,14 @@ commands, read `docs/active-work.md` immediately after this file.
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) is FRAMED**
`docs/bottom-panel-stage2-framing.md`, four review rounds, no open
items. It takes protocol **v21** and ships as two serial slices:
**2A** classified census routing + per-window painter extraction (no
wire change), then **2B** the wire, the daemon projection, the band,
and the negotiated `panel_capable` flip. Parent acceptance 3755
remains authoritative. Stage 3 is the adopter default flip.
`docs/bottom-panel-stage2-framing.md` rev 5, four review rounds, no
open items; the rev-5 implementation split was explicitly approved
2026-07-27. It takes 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 and negotiated `panel_capable` flip. 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
@ -951,6 +1094,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,

View File

@ -1,8 +1,8 @@
# Bottom panel Stage 2 — the GPU panel band (framing)
**Revision 5 — 2A merged, 2B in progress. Ground truth: canonical
`main` @ `42025e4`, protocol v20 on `main` and v21 on
`bottom-panel-stage2b`, 2026-07-26.** Revisions 14 were
**Revision 5 — APPROVED 2026-07-27; 2A merged, 2B-1 in progress.
Ground truth: canonical `main` @ `c2d56ff`, protocol v20 on `main` and
v21 on `bottom-panel-stage2b`.** Revisions 14 were
pre-implementation; rev 5 records the three-way slice of Stage 2B
(§0.0, §7.2, §9) after its first slice was already built.
@ -885,7 +885,7 @@ 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 both: the standing suite from `CLAUDE.md`, plus the **touched
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:

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

File diff suppressed because it is too large Load Diff

View File

@ -46,7 +46,7 @@ during a rebase.
## 0.1 Revision history
Revision 1 — initial. Current revision: **8**.
Revision 1 — initial. Current revision: **12**.
### Round 1 (rev 1 → rev 2)
@ -503,6 +503,110 @@ documentation cleanups.
others — matching §2.11 and Q#LN11.
3. **§9.1's revision label was stale.** It now names rev 8.
### Round 9 (rev 8 → rev 9)
Found during Stage 4b implementation, by simulating Q#LN22's state
machine over all 1,855 vendored entries and re-reading upstream's
`TrackedAbbreviation.ts` and `AbbreviationProvider.ts` at `17d1d08`.
**Three acceptance criteria named examples that the real table
contradicts** — every one of them written from what the abbreviation
*looks* like rather than from whether the table makes it eager.
1. **Acceptance 41 was false.** `\to` does not expand eagerly: `to` is a
proper prefix of `top`, `to0`, `toa` and others, so upstream's
`isAbbreviationUniqueAndComplete` is false and `to` is not among the
1,550 eager keys. The criterion now uses `\alpha`, which has no
extension, and additionally pins that `\to` alone does **not**
expand — the false half is worth an assertion because it reads as
correct until the table is consulted.
2. **Acceptance 42 was false.** `\zzzz` + space yields `ζzzz `, not
literal text: `z` opens a pending abbreviation (`ze`, `zeta`,
`zsqrtd`) and the second `z` finishes it. Exactly six printable
characters open no key — `$ % , ; @ W` — and the criterion now uses
`\WWWW`.
3. **Acceptance 38's undo claim was false for its own example.**
`alpha` is eager, so `\alpha` expands before the space is typed and
the space is a separate edit; one undo removes the space rather than
restoring `\alpha `. The criterion now states the finish path and the
eager path separately, since "one expansion is one undo step" is true
of both while the text an undo restores is not.
The mechanism (Q#LN11, Q#LN21, Q#LN22) needed no change — these were
errors in the examples chosen to pin it, which is why a simulation over
the real data found them and four review rounds over the prose did not.
### Round 10 (rev 9 → rev 10)
Review of the Stage 4b implementation. Three defects in the expander,
all of them about what happens AROUND the expansion rather than about
resolving an abbreviation, plus one stale count.
1. **A pair character that terminates an abbreviation never reached
auto-pairing.** Q#LN22 already said the terminator is not claimed;
the implementation claimed it whenever an expansion succeeded, so
`\alp(` gave `α(`. Not claiming is necessary and not sufficient —
the chain hands each consumer a copy of the record made before any
consumer ran, so expanding inside the chain invalidates the copy
pairing is holding and the closer is lost anyway. Q#LN22 now
specifies the deferred subscriber and the span that stops before the
terminator; acceptance 45j pins all three failure modes.
2. **Post-insert point motion was mistaken for a valid pending span.**
The relevance check compared buffer and window but not
`ed.cursor() == rec.post_cursor`, so a redefined self-insert that
inserts and then moves the point still expanded — and teleported the
point back. Pairing has made this three-part check since #110.
Acceptance 45k.
3. **Cursor placement could move the wrong buffer.** A buffer intercept
may switch buffers during `buf:replace`; the unguarded `goto_byte`
afterwards moved the switched-to buffer's point. `repair_cursor` is
the precedent. Acceptance 45l.
4. **The coherence census contradicted itself** — nine settings in one
paragraph, eight three paragraphs below.
Acceptance 45m was added with them: the expansion now runs on its own
`buffer.after-edit` subscriber, which is a new instance of Q#AP7 and
was unpinned.
### Round 11 (rev 10 → rev 11)
One P1 in the round-10 fix, and one stale comment.
1. **The deferred expansion was not tied to the fan-out that queued
it.** `buffer.after-edit` fan-outs nest — the typed-edit contract
supports a consumer calling `pmacs.hook.run` — and a nested run
re-enters the expander's subscriber while the OUTER chain is still
mid-list. A consumer at priority 75 running one nested fan-out made
`\alp(` yield `α(` again: the nested pass expanded, and outer
pairing then resumed with a record the replace had invalidated.
Round 10's own failure mode, reached through re-entrancy instead of
claiming. Q#LN22 now specifies matching chain invocations against
expander invocations so only the outermost pass expands; acceptance
45n pins it.
2. **A test comment still described the discarded span design** — it
said the expansion replaces the span "INCLUDING the terminator",
which round 10 deliberately stopped doing. The behaviour it asserts
was correct; only the explanation was stale.
### Round 12 (rev 11 → rev 12)
One P1: round 11's counter was in the wrong place.
1. **The nesting count lived in the expander, which is optional.** A
consumer at a lower priority can claim and stop the chain before the
expander runs, while that fan-out's deferred-expansion subscriber
still runs — so the nested pass went uncounted, looked like the
outermost one, expanded early, and outer pairing resumed with an
invalidated record. `\alp(` gave `α(` again. The count now comes
from a no-op consumer at the minimum priority, which runs first in
every chain invocation that reaches any consumer; acceptance 45o
pins the short-circuit path that 45n does not reach.
The pattern across rounds 1012 is worth naming: each fix was correct
about the failure it was shown and wrong about the boundary of the
mechanism it relied on — the chain's copy semantics, then its
re-entrancy, then its short-circuit. **A queue that outlives the thing
that filled it needs to name that thing, not approximate it.**
## 1. What ships
Nine stages, after round 4 split Stage 3 and round 5 split Stage 4. The
@ -1686,8 +1790,9 @@ reconstruction of it:
- A subsequent self-insert `c` is claimed iff at least one key has
`text .. c` as a prefix; then `text = text .. c`. If it is also
uniquely-and-completely matching (one of the 1,550), expand now.
- If no key extends `text .. c`, expand `text` **first**, then let `c`
land normally — the chain does *not* claim `c`.
- If no key extends `text .. c`, `c` TERMINATES the abbreviation: the
chain does *not* claim it, and the expansion of `text` is
**deferred** until after the chain has run (round 10; see below).
- **A terminating `c` that is itself `\` is then reprocessed as a new
leader**, opening a fresh pending abbreviation at its position. This
is the rule acceptance 45d depends on (`\alpha\to` → `α→`) and rev 6
@ -1701,6 +1806,69 @@ reconstruction of it:
broken by source rank, unmatchable tail appended (`\alp7` → `α7`).
- `$CURSOR` is stripped from the symbol and its index becomes the point.
**The expansion is deferred past the chain, and its span stops before
the terminator** (round 10). "Not claiming the terminator" is necessary
and not sufficient: a pair character is a legal terminator (`\alp(` must
give `α()`), and the chain hands every consumer a *copy* of the record
made before any consumer ran. So expanding inside the chain and then
declining leaves auto-pairing holding offsets the replace has already
invalidated — pairing declines and the closer is silently lost, which a
probe confirmed. Claiming the terminator instead suppresses pairing
outright. Neither is recoverable from inside the chain.
The expander therefore records the pending expansion and performs it on
its **own `buffer.after-edit` subscriber**, registered after
typed_edit.lua's and before lsp.lua's. A claim by any consumer stops the
chain but not a separate subscriber — which is the point, since pairing
claims the terminator it reacts to. The replaced span covers the leader
and the typed text only; whatever pairing did lands after it and
survives untouched. One undo restores the same text either way, because
the terminator was always its own insert.
**The deferred expansion must belong to its own fan-out** (round 11).
`buffer.after-edit` fan-outs NEST — Q#AP9 and typed_edit.lua's header
both say so explicitly, and a consumer may call `pmacs.hook.run`. A
nested run re-enters every subscriber, including the deferred
expansion's, while the OUTER chain is still walking its consumer list
and pairing has not yet seen the terminator. A nested pass that
performed the expansion would reproduce the exact bug deferring exists
to fix, reached through the chain's documented re-entrancy seam instead
of through claiming.
The nesting level is counted by a **no-op consumer registered at the
minimum priority**, matched off in the expander's subscriber. Only the
outermost pass expands; a nested one leaves the expansion queued. No
new seam in typed_edit.lua, which is merged substrate.
Where the count lives is the whole difficulty, and two plausible places
are both wrong (round 12):
- **A subscriber registered beside the expander's is too late.** The
entire nested fan-out completes inside the OUTER chain's subscriber,
before any subscriber registered after it runs.
- **The expander itself is optional.** 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 — and
would then look like the outermost one.
A minimum-priority consumer 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.
Two guards this exposes, both of which pairing already carries:
- The relevance check is **three-part**, not two: buffer, window, **and
`ed.cursor() == rec.post_cursor`**. A redefined self-insert can insert
the completing character and then move the point, and expanding over a
span the user has left teleports them back into it.
- Cursor placement after the replace is **context-guarded**. A buffer
intercept may switch window or buffer while `buf:replace` runs; an
unguarded `goto_byte` then moves the point of a buffer that has
nothing to do with the expansion. `pair.lua`'s `repair_cursor` is the
precedent.
**Ownership is per frontend, not per buffer** (§2.11). The key is
`(pmacs.frontend.id(), rec.buffer)`, and the stored `window` must still
match `rec.window` for the state to be usable — a frontend that moved
@ -2452,12 +2620,25 @@ criterion 46 requires to stay byte-identical.
**Stage 4b — the Unicode input method**
38. `\alpha` + space yields `α ` — the space lands first and the
expansion runs in the following `buffer.after-edit`, so the
terminator is **retained**, not consumed. The expansion is a single
undo step: one undo restores `\alpha ` (with its space), not
`\alph`. Rev 6 wrote the post-undo text as `\alpha`, which would be
true only if the terminator were swallowed.
38. **Terminators are retained, and one expansion is one undo step —
but which text an undo restores depends on the path.** Rev 8 stated
a single rule here and it is wrong against the real table, because
it assumed `\alpha` takes the finish path when `alpha` is in the
1,550-key eager set (round 9; see 41).
- *Finish path.* `\alp` + space yields `α `: the space lands first
and the expansion runs later in the same `buffer.after-edit`
fan-out, so the terminator is **retained**, not consumed. It sits
OUTSIDE the replaced span, which covers only the leader and the
typed text (round 10) — the observable text and the post-undo
text are the same either way, because the terminator was its own
insert. One undo restores `\alp ` — with its space, not `\al`.
Rev 6 wrote the post-undo text without the terminator, which
would be true only if the terminator were swallowed.
- *Eager path.* `\alpha` yields `α` with no terminator typed, and a
following space is a **separate** edit. One undo removes the
space; a second restores `\alpha`. Asserting the finish-path undo
text here would fail, which is the trap this split exists to
record.
39. `\<>` yields `⟨⟩` with the point between them, from the `$CURSOR`
placeholder.
40. **Pair-collision pin (Q#LN22).** `\[[]]` yields `⟦⟧`: each `[` is
@ -2467,9 +2648,21 @@ criterion 46 requires to stay byte-identical.
only completed expansions rather than pending extensions — **both
failure modes must be shown**, since they are distinct bugs with the
same symptom.
41. `\to` yields `→` eagerly on uniqueness, with no terminator typed.
42. A prefix with no match (`\zzzz` + space) is left as literal text; no
edit is made.
41. **Eager expansion on uniqueness**, with no terminator typed:
`\alpha` yields `α` the moment the final `a` lands. Rev 8 used `\to`
here and that is false against the real table (round 9): `to` is a
proper prefix of `top`, `to0`, `toa` and others, so
`isAbbreviationUniqueAndComplete` is false and `to` is **not** in
the 1,550-key eager set. `\to` alone stays `\to`; `\to` + space
yields `→ ` by the finish path. Both are asserted, because the
wrong one reads as correct until the table is consulted.
42. A prefix that opens no key at all — `\WWWW` + space — is left as
literal text and **no edit is made**. Rev 8 used `\zzzz`, which
expands (round 9): `z` opens a pending abbreviation because `ze`,
`zeta` and `zsqrtd` exist, and the second `z` finishes it, giving
`ζzzz `. Exactly six printable characters open no key: `$ % , ; @
W`. Bites against an implementation that treats "no complete match"
as "no pending state".
43. **Lazy abandonment (Q#LN22).** Because there is no cursor-motion
hook, this asserts what pmacs can actually detect: after `\alp`, an
explicit `goto_byte` elsewhere followed by typing `h` inserts a
@ -2544,6 +2737,49 @@ criterion 46 requires to stay byte-identical.
`$CURSOR` more than once;
- the resolution spot-set behaves: `alpha`, `to`, `<>`, `+ `, `\`,
`n`, `setminus`, and the tie cases from 45h.
45j. **A pair character that TERMINATES an abbreviation still pairs**
(round 10). `\alp(` yields `α()` with the point between the pair.
Bites three ways, all of which produce different wrong answers:
claiming the terminator gives `α(`; expanding inside the chain and
then declining also gives `α(`, because the replace invalidates the
record copy pairing is holding; and pairing running first gives
`\alp()` unexpanded. Criterion 40 is the same collision from the
other side, and passing it says nothing about this one.
45k. **The relevance check is three-part.** A redefined
`buffer.self-insert` that inserts the completing character and then
moves the point must not expand: `\alph` + `a` under such an
override leaves literal `\alpha` with the point where the command
put it. Bites against checking only buffer and window — the
expansion would otherwise teleport the point back into a span the
user has left.
45l. **Cursor placement is context-guarded.** A buffer intercept that
switches buffers during `buf:replace` must not have the
switched-to buffer's point moved. Bites against an unguarded
`goto_byte`, which translates the LEAN buffer's pre-edit point
through the LEAN buffer's edit and applies it to whatever is
ambient.
45m. **Q#AP7 for the deferred subscriber.** The expansion runs on a
second `buffer.after-edit` subscriber, so it inherits pairing's
flush-ordering obligation: no `didChange` may ever carry the
unexpanded text. Pinned with the `sighelp` fake server and `(` as
the trigger — the flush carrying the terminator carries `α()`.
Falsified by loading lean_input.lua after lsp.lua.
45n. **A nested fan-out must not expand early** (round 11). A consumer
registered BETWEEN the expander and pairing that calls
`pmacs.hook.run("buffer.after-edit")` once still yields `α()` for
`\alp(`. Bites against a deferred slot consumed by whichever
fan-out happens to reach it: the nested pass would expand, and the
outer chain would then hand pairing a record the replace had
invalidated — the round-10 failure again, through the chain's
documented re-entrancy seam rather than through claiming.
45o. **A nested fan-out that never reaches the expander must not
expand early either** (round 12). Same shape as 45n, but the nested
pass is short-circuited by a consumer at priority 25 that claims
when the record is nil — so the expander never runs on it. Bites
against counting fan-outs in the expander, which is optional by
construction: the uncounted nested pass looks outermost, expands,
and outer pairing resumes with an invalidated record. 45n passes
against that bug, which is why both are pinned.
45h. **Tie-break by source order (§2.11).** `\f` + space yields ``
`f<` and `f>` are both length 2, and `f<` is declared first. Same
for `\"` + space → `Ä`, first of eleven equal-length candidates.
@ -2698,7 +2934,7 @@ uncapped event queue, the dropped `cfg.restart`, and — unchanged from
languages other than Lean, and §4's rule is what keeps them out of a Lean
PR.
### 9.1 Coherence impact — stages 4a and 4b (rev 8)
### 9.1 Coherence impact — stages 4a and 4b (rev 12)
**Sections served.** §6 (interaction islands) primarily, and in the
*preventing* direction rather than the fixing one — see below. §11

View File

@ -564,11 +564,21 @@ additive, on its own binding, and does not replace scroll-and-select.
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:** `*compilation*` and listview panels still
rely on intercept-plus-round-trip and are still emptiable by
`M-x buffer.undo`. The primitive they need now exists and is proven, so
the remaining work is adoption plus a streaming-friendly variant
(`*compilation*` appends rather than replacing wholesale).
**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

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

@ -1627,18 +1627,97 @@ fn open_initial_target(
// create and select a side window, and bootstrap must reassert the
// requested buffer in a document window rather than overwriting a
// panel merely because it became `view.active`.
let (origin_window, buffer_id, fire) = {
let (origin_window, resolved) = {
let mut core = editor.core.borrow_mut();
core.active_frontend = frontend_id;
let origin_window = core
.primary_document_window(frontend_id)
.ok_or_else(|| "attaching frontend has no document window".to_string())?;
let (buffer_id, fire) = core.resolve_target_buffer(&path)?;
let resolved = core.resolve_target_buffer(&path)?;
(origin_window, resolved)
};
// Journey Stage 1a (Q#JR6/Q#JR9): a DIRECTORY installs nothing.
//
// Nothing can be installed, because the listing that satisfies a
// directory open is asynchronous and this block is synchronous — the
// frontend is blocked on `InitialTargetResult` and will not create
// its window until it arrives, so there is no tick in which a
// listing could settle. The reply therefore names the buffer the
// fresh view's document window ALREADY holds, which is a valid,
// ready session; the listing replaces it a tick or more later.
//
// That buffer is NOT necessarily `*scratch*`: `build_fresh_frontend_view`
// clones LOCAL's primary document buffer. If LOCAL holds a real
// document, this session briefly displays and snapshots it. Accepted
// and documented rather than papered over with a placeholder buffer,
// which would need reaping and would be fought by the reassert below.
//
// `publish_to_replicas` is false for the same reason an `AfterSwitch`
// dedup sets it false: this buffer is pre-existing and already
// published, not freshly loaded here.
let (buffer_id, fire) = match resolved {
crate::editor_core::ResolvedTarget::Directory { path } => {
let dest = editor
.capture_directory_destination(frontend_id, origin_window)
.ok_or_else(|| format!("cannot open {}: no document window", path.display()))?;
editor.dispatch_directory_open(&path, dest);
editor.reconcile_panel_layout(frontend_id);
// The reply must name what the window ACTUALLY holds now, not
// what it held before the dispatch.
//
// The chain runs synchronously. dired's handler defers (it
// spawns a coroutine for the listing), but a user's resolver
// is under no such obligation: a handler that opens something
// synchronously -- through `commit_to`, which is exactly the
// supported way to do it -- has already replaced this
// window's buffer by the time we get here. Reporting the
// captured id would then send the snapshot of one buffer and
// the identity of another, and the frontend would render a
// document nobody asked for.
//
// Re-reading also covers the case a hook closed the window,
// which is why this rehomes through `non_side_target` exactly
// as the file arm's reassert does rather than returning early
// and skipping that check.
let mut core = editor.core.borrow_mut();
core.active_frontend = frontend_id;
let destination = if core
.views
.get(&frontend_id)
.is_some_and(|view| view.layout.iter_ids().contains(&origin_window))
{
origin_window
} else {
core.non_side_target(frontend_id)
.map_err(|error| format!("cannot reselect {}: {error}", path.display()))?
};
core.focus_window(frontend_id, destination);
let buffer_id = core
.windows
.get(&destination)
.map(|window| window.buffer_id)
.ok_or_else(|| format!("cannot reselect {}: window died", path.display()))?;
return Ok(OpenedInitialTarget {
buffer_id,
// False whether or not the chain replaced the buffer: an
// untouched destination is pre-existing and already
// published, and a buffer a synchronous handler installed
// went through the ordinary display path, which publishes
// on its own terms.
publish_to_replicas: false,
});
}
crate::editor_core::ResolvedTarget::Buffer { id, fire } => (id, fire),
};
{
let mut core = editor.core.borrow_mut();
core.install_buffer_in_window(origin_window, buffer_id)
.map_err(|error| format!("cannot select {}: {error}", path.display()))?;
core.focus_window(frontend_id, origin_window);
(origin_window, buffer_id, fire)
};
}
match fire {
crate::editor_core::HookKind::AfterLoad => {
@ -3978,6 +4057,143 @@ mod tests {
);
}
/// Arc 8 Stage 4b acceptance 45f: the Lean abbreviation expander
/// works on the OPTIMISTIC producer, not only on `dispatch_key`.
///
/// This is the path most users take and the one no other Stage 4b
/// test covers. `classify_key` (`src/optimistic.rs`) returns
/// `Insert(c)` for `\` and for every ASCII letter — only the nine
/// built-in pair chars are excluded (Q#AP1) — so on a CRDT frontend
/// `\alpha` arrives here as six source-peer optimistic inserts,
/// while the expansion is a single daemon-peer replace spanning all
/// six. That asymmetry is the accepted undo degradation of Q#LN21;
/// what this pins is that the expansion happens at all.
///
/// It lives in `--lib` deliberately: the gate list runs
/// `--features crdt` only for `cargo test --lib`, so a crdt-gated
/// INTEGRATION test would be dark in CI and dark in the gates both.
///
/// The source frontend needs a REGISTERED WINDOW on the edited
/// buffer or nothing is armed at all — `handle_remote_crdt_op`
/// arms the record only when the source's active window displays
/// the buffer, so a source with no view fails closed and silently.
/// A version of this test without the view below passed six
/// fan-outs with a nil record and proved nothing.
#[cfg(feature = "crdt")]
#[test]
fn the_optimistic_producer_also_expands_a_lean_abbreviation() {
use crate::editor::EditorState;
use crate::protocol::FrontendId;
use crate::window::{FrontendView, Layout, Window, WindowId};
let dir = std::env::temp_dir().join(format!("pmacs-lean-opt-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("create temp dir");
let path = dir.join("a.lean");
std::fs::write(&path, "").expect("write fixture");
let source = FrontendId(77);
let mut editor = EditorState::new();
editor
.lua_host
.eval(Some("test"), "pmacs.lsp.config = {}")
.expect("clear lsp config");
editor
.lua_host
.eval(
Some("test-open"),
&format!(
"pmacs.buffer.find_or_open({:?}); pmacs.editor.goto_byte(0)",
path.display().to_string()
),
)
.expect("open the lean fixture");
let buffer_id = editor.core.borrow().active_window().buffer_id;
{
let mut core = editor.core.borrow_mut();
let mut reg = core.registry.borrow_mut();
reg.get_mut(buffer_id)
.expect("active buffer")
.upgrade_to_crdt(2)
.expect("upgrade to crdt");
drop(reg);
// The replica's own window on the shared buffer.
let text_view = {
let registry = core.registry.clone();
let reg = registry.borrow();
crate::text_view::TextView::new(reg.get(buffer_id).expect("buffer"))
};
let win_id = WindowId::next();
core.windows
.insert(win_id, Window::new(win_id, buffer_id, text_view));
core.register_frontend_view(
source,
FrontendView {
layout: Layout::single(win_id),
active: win_id,
fold_projection: true,
panel_capable: true,
frame_geometry: None,
panel_hidden: false,
},
);
}
let snapshot_bytes = {
let core = editor.core.borrow();
let reg = core.registry.borrow();
reg.get(buffer_id)
.expect("buffer")
.crdt_state()
.expect("crdt-backed")
.export_snapshot()
.expect("export snapshot")
};
let peer = loro::LoroDoc::new();
peer.set_peer_id(77).expect("set peer id");
peer.import(&snapshot_bytes).expect("import snapshot");
// One op per keystroke, exactly as the attach loop's
// optimistic-apply branch produces them.
for (i, ch) in "\\alpha".chars().enumerate() {
let v_before = peer.oplog_vv();
peer.get_text("body")
.insert(i, &ch.to_string())
.expect("peer insert");
let op_bytes = peer
.export(loro::ExportMode::updates(&v_before))
.expect("export op");
super::handle_remote_crdt_op(
&mut editor,
source,
buffer_id,
crate::rope::CrdtOp {
peer_id: 77,
bytes: op_bytes,
},
);
}
let text = match editor
.lua_host
.eval(
Some("test-readback"),
"local b = pmacs.window.buffer(); return b:slice(0, b:len())",
)
.expect("read buffer text")
{
mlua::Value::String(s) => String::from_utf8_lossy(&s.as_bytes()).into_owned(),
other => panic!("expected buffer text, got {other:?}"),
};
assert_eq!(
text, "α",
"the abbreviation expanded on the optimistic path — the \
record the expander reads is armed by handle_remote_crdt_op, \
not only by dispatch_key"
);
}
/// Q#AI9 (PR #109 round 1): the optimistic-apply arm clears an
/// EMPTY anchor on the source window — the GPU always takes this
/// path, and the TUI attach mirror tracks no selection state, so
@ -4899,6 +5115,177 @@ mod tests {
);
}
/// **N2** (Journey Stage 1a) — a DIRECTORY initial target reaches
/// readiness instead of failing.
///
/// This deliberately supersedes the directory half of the GPU
/// initial-target framing's Q#GT6 and its acceptance 10, which
/// required `IsADirectory` to fail before window creation.
/// Permission-denied and every other pre-readiness failure keep that
/// contract.
#[test]
fn initial_target_directory_reaches_ready() {
use crate::editor::EditorState;
use crate::protocol::FrontendId;
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join("alpha.txt"), b"alpha\n").expect("write");
let mut editor = EditorState::new();
editor
.lua_host
.lua()
.load("pmacs.lsp.config = {}")
.exec()
.expect("wipe lsp config");
let fid = FrontendId(131);
let view = build_fresh_frontend_view(&mut editor, false, false);
editor.core.borrow_mut().register_frontend_view(fid, view);
let opened = open_initial_target(
&mut editor,
fid,
InitialTarget {
path: dir.path().as_os_str().as_bytes().to_vec(),
cwd: dir.path().as_os_str().as_bytes().to_vec(),
},
)
.expect("a directory target must reach readiness, not fail");
// The reply names a live buffer in a live document window: a
// valid, ready session. The listing arrives later, asynchronously.
let core = editor.core.borrow();
assert!(
core.registry.borrow().contains(opened.buffer_id),
"the reported buffer must exist so its snapshot can be sent"
);
let active = core.views[&fid].active;
assert_eq!(
core.windows[&active].buffer_id, opened.buffer_id,
"the reported buffer is the one the document window shows"
);
}
/// **N5** — the bootstrap buffer is not necessarily `*scratch*`.
///
/// `build_fresh_frontend_view` clones LOCAL's PRIMARY DOCUMENT
/// buffer, so when LOCAL holds a real document the fresh session
/// briefly displays and snapshots it. Q#JR9 accepts that rather than
/// introducing a placeholder; this observes it instead of assuming.
#[test]
fn initial_target_directory_reports_a_non_scratch_primary() {
use crate::editor::EditorState;
use crate::protocol::FrontendId;
let dir = tempfile::tempdir().expect("tempdir");
let doc = dir.path().join("already-open.txt");
std::fs::write(&doc, b"local document\n").expect("write");
// LOCAL holds a real document, not scratch.
let mut editor = EditorState::open(doc.clone()).expect("open");
editor
.lua_host
.lua()
.load("pmacs.lsp.config = {}")
.exec()
.expect("wipe lsp config");
let local_primary = editor
.core
.borrow()
.primary_document_buffer(FrontendId::LOCAL)
.expect("LOCAL always has a document window");
let fid = FrontendId(132);
let view = build_fresh_frontend_view(&mut editor, false, false);
editor.core.borrow_mut().register_frontend_view(fid, view);
let opened = open_initial_target(
&mut editor,
fid,
InitialTarget {
path: dir.path().as_os_str().as_bytes().to_vec(),
cwd: dir.path().as_os_str().as_bytes().to_vec(),
},
)
.expect("a directory target must reach readiness");
assert_eq!(
opened.buffer_id, local_primary,
"the bootstrap reply names LOCAL's primary document buffer, \
which is a real document here rather than *scratch*"
);
}
/// **N2b (rev 6)** — a resolver that claims SYNCHRONOUSLY is reported
/// correctly.
///
/// The bug this pins: the arm captured the destination buffer id
/// *before* dispatching the chain and reported that. The chain runs
/// synchronously, so a handler that opens something immediately —
/// through `commit_to`, the supported way — had already replaced the
/// window's buffer, and the reply paired one buffer's snapshot with
/// another's identity.
///
/// Falsified by reporting the captured id instead of re-reading.
#[test]
fn initial_target_directory_reports_what_a_synchronous_handler_installed() {
use crate::editor::EditorState;
use crate::protocol::FrontendId;
let dir = tempfile::tempdir().expect("tempdir");
let mut editor = EditorState::new();
editor
.lua_host
.lua()
.load(
"pmacs.lsp.config = {}
claimed = pmacs.buffer.create('*claimed*')
pmacs.path.set_directory_handler(function(path, dest)
pmacs.window.commit_to(dest, function()
pmacs.window.display(claimed, { select = true })
end)
end)",
)
.exec()
.expect("install a synchronous handler");
let fid = FrontendId(133);
let view = build_fresh_frontend_view(&mut editor, false, false);
editor.core.borrow_mut().register_frontend_view(fid, view);
let opened = open_initial_target(
&mut editor,
fid,
InitialTarget {
path: dir.path().as_os_str().as_bytes().to_vec(),
cwd: dir.path().as_os_str().as_bytes().to_vec(),
},
)
.expect("a claimed directory target must reach readiness");
// Compare by NAME: the reported id must be the handler's buffer,
// and naming it is what makes the failure legible when it is not.
let core = editor.core.borrow();
let reported_name = core
.registry
.borrow()
.get(opened.buffer_id)
.expect("the reported buffer exists")
.name()
.to_string();
assert_eq!(
reported_name, "*claimed*",
"the reply must name what the handler installed, not the \
buffer captured before the dispatch"
);
let active = core.views[&fid].active;
assert_eq!(
core.windows[&active].buffer_id, opened.buffer_id,
"…and that buffer is what the window shows"
);
}
/// Bottom-panel §1.3 #1/#3/#21 — the three Projection producers whose
/// only production caller is `dispatcher_loop`, pinned at the named
/// seams that loop calls. Round 2 finding: reverting any of them to

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");
@ -445,6 +554,27 @@ impl EditorState {
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"),
@ -769,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
@ -809,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);

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

@ -3650,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.
@ -6938,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(

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);

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