Merge main, and fold #173's a37 findings into the CI-coverage lane

Integrates githubsucks/main @ cf54270 (terminal config Stage 1, #173).
Both conflicts were additive appends to the same lists — the handoff
preamble and the ledger's "Closed since the last snapshot" — so both
sides are kept and the preamble now leads with #173.

Also records what gating #173 measured about a37, because it changes
this lane's proposed fix rather than merely annotating it:

a37 reports `ok` without running whenever `pmacs-gpu` is absent from
the target directory. A fresh worktree reports the Stage 3 suite 9/9 in
0.17 s having never executed the arc's only real-daemon/real-PTY/real-
wgpu path; a genuine run takes about four seconds. Only
PMACS_REQUIRE_GPU=1 promotes that skip to a failure, and the standing
gate list applies that flag to `cargo test -p pmacs-gpu`, a different
package. So fix-shape part 2 must state the flag as a requirement of
the gpu-render job: a crdt leg added to the plain `test` job would run
a37 vacuously and report green.

a37 is also load-sensitive — it passed at d152120 and failed at that
same commit twenty minutes later under machine contention — which makes
a red first CI run ambiguous by construction. The lane now says to
re-run on the merge base before believing a failure, and to prefer
serialized execution over retry-until-green.

The vterm audit's "only 3 of 9 Stage 3 tests drive a real daemon" is
corrected: without the frontend binary the honest number is 2.

Ledger: terminal config Stage 1 flipped from IN REVIEW to MERGED.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016gGQC6eqHJVbZJ5Hg7aLer
This commit is contained in:
Levi Neuwirth 2026-07-26 09:27:49 -04:00
commit 23486fd572
30 changed files with 9857 additions and 335 deletions

View File

@ -119,9 +119,10 @@ detailed in §1.1§1.3: **substrate without surface**, **the silence
asymmetry**, and **per-arc coherence debt**.
Coherence-shaped work already in flight at audit time: find-file /
dired Stage 0 (`C-x C-f`, PR #162, `docs/dired-framing.md`), bottom
panel Stage 1 (merged #155), multi-root LSP affinity (PR #161), the
config registry foundation (merged #127).
dired Stage 0 (`C-x C-f`, merged #162, `docs/dired-framing.md`) and its
Stage 1 directory view (PR #165), bottom panel Stage 1 (merged #155),
multi-root LSP affinity (merged #161), the config registry foundation
(merged #127).
---
@ -191,9 +192,13 @@ working, unreachable capability:
is Lua-bound; no builtin command opens `*lsp*` (§2, §9).
- **Interactive file opening.** `pmacs.buffer.find_or_open`
(`src/lua_bindings/mod.rs:3103`) had no interactive caller at audit
time; a complete 1,384-line dired exists as a frozen test fixture
(`tests/fixtures/pmacs-dired/init.lua`). Being fixed now: dired Stage
0 (PR #162).
time; a complete 1,384-line dired existed only as a frozen test
fixture (`tests/fixtures/pmacs-dired/init.lua`). **Fixed:** dired
Stage 0 opens a path (`C-x C-f`, merged #162) and Stage 1 ships the
browsing view as a builtin (`C-x d` / `C-x C-j`, PR #165). The fixture
stays frozen — its `install_local` + `require` routing *is* the M8
package-universality proof (Q#DR1) — and shrinking it is scheduled
after Stage 3.
The strategic consequence: **most coherence gaps in pmacs are doors,
not engines** — deliberately deferred surface, not design error. That is
@ -358,12 +363,12 @@ Full verdict table:
|---|---|---|---|
| 1 | Install | **Partial** | Source build only: `cargo build --release --workspace --features pmacs/crdt` (`README.md`). No binaries, no packaging. Runtime deps (`/bin/sh`, git, tar, coreutils) documented, never checked at runtime |
| 2 | Launch unconfigured | **Works** | `EditorState::new()` → empty `*scratch*`; missing config is not an error (`src/config.rs:7-9`); recentf/saveplace/autosave default-on |
| 3 | Open real project | **Missing** | `pmacs .` exits 1 (above). No directory handling anywhere |
| 3 | Open real project | **Missing at the CLI** | `pmacs .` still exits 1 (above): `load_file` does `File::open` (which succeeds on a directory) then `read_to_end` → EISDIR, which is not `NotFound`, so `resolve_target_buffer`'s create-a-`[new file]` arm never fires. Dired Stage 1 (PR #165) supplies the buffer a directory should resolve *to*; routing `pmacs .` into it is Journey Stage 1's work, which must not invent a second directory surface |
| 4 | Understand interface | **Partial** | Mode line gives name/modified/L:C/scroll + mode/LSP/terminal segments; but no welcome text (`EditorCore::new` sets `status: String::new()`), no cheat sheet, and `C-h` deletes a word (§18) |
| 5 | Edit | **Works** | Full CUA + Emacs keymap in 161 lines (`builtin/keymaps/default.lua`); isearch, query-replace, kill ring, undo/redo, auto-indent/pair/comment, atomic save. Genuinely excellent zero-config |
| 6 | Language intelligence | **Partial** | Rust grammar bundled and auto-attaches; rust-analyzer preconfigured (`builtin/runtime/lsp.lua:44-52`) — but a missing binary fails silently (§1.2) and highlighting masks it. No LSP status command exists to diagnose |
| 7 | Find symbol / file | **File: missing → in flight (PR #162). Symbol: works but undiscoverable** | No find-file/dired/picker existed at audit; `M-.`/`M-?`/`C-c o` bound but advertised nowhere and server-gated; no workspace-symbol command; `pmacs.index.*` has no UI |
| 8 | Open terminal | **Works but undiscoverable** | Full PTY with scrollback + modeline segment — reachable only as `M-x terminal`, no keybinding. *Was broken outright on the GPU frontend until the double terminal-layout sync was fixed: the child took a `SIGWINCH` storm at tick cadence, so typing into it was impossible while output still flowed.* |
| 7 | Find symbol / file | **File: fixed (open by path merged #162; browsing PR #165). Symbol: works but undiscoverable** | No find-file/dired/picker existed at audit. Now `C-x C-f` opens a known path and `C-x d` / `C-x C-j` browse (flat listing, `dired` mode keymap); `M-.`/`M-?`/`C-c o` still bound but advertised nowhere and server-gated; no workspace-symbol command; `pmacs.index.*` has no UI |
| 8 | Open terminal | **Works** | Full PTY with scrollback + modeline segment, bound to `C-c t` and configurable through three registered settings (`terminal.default-profile`, `terminal.scrollback-rows`, `terminal.escape-key`) plus named `pmacs.terminal.profiles` (PR #173). Named limitation: `C-c t` is unreachable from *inside* a terminal window, where `C-c` is consumed as the escape — `M-x terminal` still works there. *Was broken outright on the GPU frontend until the double terminal-layout sync was fixed: the child took a `SIGWINCH` storm at tick cadence, so typing into it was impossible while output still flowed.* |
| 9 | Build / test | **Partial** | `M-x compile.run` works, defaults cwd to detected project root, parses Rust `-->` errors — but no keybinding, an **empty first prompt** (`initial = last and last.cmdline or ""`, `builtin/runtime/compile.lua:1134-1138`), and no `cargo build`/`cargo test` suggestion despite `ProjectKind::Cargo` existing (`src/project.rs:77`) |
| 10 | Inspect error | **Partial (good once reached)** | `E:n W:n` modeline counts, underlines, `M-g n/p` + ``C-x ` `` walking a unified compile/grep/diag source, message echo, `RET` visits. Gated entirely on step 6 or 9 succeeding first |
| 11 | See background work | **Works but undiscoverable** | `*workers*` view via `M-x editor.list-workers`; `C-c C-k` cancel-at-point. No keybinding, no statusline spinner/progress indicator anywhere (§9) |
@ -374,6 +379,13 @@ A journey observation worth keeping verbatim from the audit:
C-M-s` opens all folds, while opening a file, opening a terminal, and
running a build have no bindings at all.
Two of that observation's three examples have since been answered —
opening a file by `C-x C-f` (#162) and opening a terminal by `C-c t`
(#173). **Running a build still has no binding**, and the underlying
inversion is a standing bias in how new work gets bound, not three
isolated omissions: the quote stays as written because it names the
pattern, and the pattern is not retired until step 9 is.
---
## 3. A Strong Zero-Configuration State
@ -455,7 +467,8 @@ level is the one missing. Audited level-by-level:
**Beginner** (should see: files, buffers, search, diagnostics, terminal,
build actions, menus, missing-tool guidance):
- files ✗ (no find-file at audit; PR #162 in flight) · buffers ✓ (`C-x
- files ✓ since #162 / PR #165 (`C-x C-f` opens a path, `C-x d` browses;
neither is advertised anywhere but the keymap) · buffers ✓ (`C-x
b`, `*buffer-list*`) · search ✓ (`C-s`/`C-r`/`C-M-s`; project.search
is M-x-only) · diagnostics ✓ once a server runs · terminal ✓ but
M-x-only · build ✓ but M-x-only with empty prompt · menus △
@ -633,7 +646,7 @@ Everything funnels through one function: `EditorInstance::dispatch_key`
| 3 | query-replace | `editor.rs:945` | `QueryReplaceKey::from_chord` (`editor.rs:2967`) | **full shadow** |
| 4 | Minibuffer | `editor.rs:951` | `MinibufferAction::from_chord` (`src/minibuffer.rs:468`) | **full shadow** |
| 5 | Completion popup | `editor.rs:958-971` | `CompletionPopupKey::from_chord` (`editor.rs:3056`) | **partial shadow** (control chords only; skipped while a multi-key prefix is pending) |
| 6 | Terminal transport + `C-c` escape | `editor.rs:973-1010` | `is_terminal_escape_chord` (`editor.rs:4355`) | **partial, transport-level** |
| 6 | Terminal transport + configurable escape | `editor.rs:973-1010` | `EditorState::terminal_escape_chord``TerminalManager::escape_chord` (`src/terminal/session.rs`) | **partial, transport-level** |
| 7 | Ordinary dispatch | `editor.rs:1018-1032` | `KeymapStack::resolve` | the only inspectable layer |
Facts that define the gap:
@ -641,8 +654,10 @@ Facts that define the gap:
- **Full shadows eat every key**, including unrecognized ones (each
decoder has an `Ignore`/`Dismiss` fallback arm). While a terminal
buffer is focused and unescaped, *all* keys encode to the child —
`C-c`-leading user bindings are **structurally unreachable** in a
terminal buffer.
bindings led by the escape chord are **structurally unreachable** in
a terminal buffer. Since #173 that chord is `terminal.escape-key`
rather than a hardcoded `C-c`, so a user can *move* which prefix is
eaten; they cannot make the shadow stop eating one.
- **No transient-keymap mechanism exists to migrate to.** `KeymapStack`
has exactly three fixed scopes — `Buffer(BufferId)`, `Mode(String)`,
`Global` (`src/keymap_stack.rs:37-44`); resolution order buffer →
@ -1007,20 +1022,29 @@ layering, provenance, and adoption have not followed.**
`ConfigValue`s; `describe-setting`'s "Source:" names where `define()`
ran. The inspection view sketched above is currently impossible to
render.
- **Adoption is five settings**: `editing.auto-pair` (pair.lua),
- **Adoption is eight settings**: `editing.auto-pair` (pair.lua),
`editing.trim-on-save` (editops.lua), `autosave.interval-ms`
(autosave.lua), `window.panel-height` + `window.min-height`
(window.lua). Everything else a user might set — theme, fonts, LSP
(window.lua), and `terminal.default-profile` +
`terminal.scrollback-rows` + `terminal.escape-key` (terminal.lua,
#173). Everything else a user might set — theme, fonts, LSP
server config, killring size, recentf/saveplace/desktop enables,
pair sets, comment strings, `pmacs.parse.*` — lives in raw Lua
outside the registry and is therefore invisible to `describe-setting`
and any future settings UI. The migration list is already written:
`docs/config-registry-framing.md` "named deferrals" (table-valued
settings are the hard prerequisite for LSP/pair/comment tables).
- **The table-valued gap now has a named, shipped instance.**
`pmacs.terminal.profiles` (#173) is a raw Lua table sitting beside
three registered scalars *for the same feature*, because a profile is
inherently `{ command, args, cwd, env }` and the registry stores four
scalars. It is the clearest evidence yet that table-valued settings
are the blocking prerequisite: the terminal is now half-registered,
and no settings UI can render the half that matters most.
- **No persistence**: settings changed at runtime do not survive
restart (the `custom-file` split-brain question is a named deferral).
- The three-level separation holds in principle today (registry /
hooks+keymaps / packages), but with five settings registered, level 1
hooks+keymaps / packages), but with eight 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.
@ -1177,7 +1201,11 @@ Primitive-by-primitive against the list above:
hierarchy, package dependency graph, worker trees, git status) will
each need it; building it once *before* dired's directory view and
the workers tree harden their own conventions is exactly this
section's point.
section's point. Dired Stage 1 (PR #165) landed **without** inventing
one: its listing is flat (Emacs parity), and the recursive
in-buffer case — `i` insert-subdirectory — is a named deferral in
`docs/dired-framing.md` §13, which is where a shared tree primitive
would land.
- **Structured table / inspector / diff view** ✗ — none. (`describe.*`
tables are the inspector's data model without a view; the
wire-declared `ResourceOffer` family was reserved for diff/blame
@ -1262,9 +1290,16 @@ its asks are already practiced.**
exclusive by construction; every other per-frontend-kind pair in the
dispatcher remains two adjacent `if`s that a reader must notice are
alternatives.
- The GPU frontend exceeds the TUI (minimap, squiggles, typography)
without the TUI losing the model — the "no privileged frontend" rule
is holding under real divergence pressure.
- The GPU frontend exceeds the TUI (minimap, squiggles, typography,
and since #158 rendered inline math) without the TUI losing the
model — the "no privileged frontend" rule is holding under real
divergence pressure. Inline math is the sharpest case so far: the
GPU shapes `$…$` spans through a bundled MATH-table font while the
TUI shows the LaTeX source unchanged, and the TUI's distinct-face
fallback is a **named deferral rather than an oversight**. What
keeps it inside the rule is that the slice reserves no protocol
version and adds no wire surface — the divergence is presentational
only, and the semantic model both frontends read is identical.
Remaining, honestly small relative to the section's ambition: capability
negotiation is per-bit rather than a first-class declared capability
@ -1392,8 +1427,10 @@ missing runtime entity — a real arc).
Establish the end-to-end workflow; treat regressions as release
blockers. **State: broken at step 3 (§2). Mostly wiring, and unusually
cheap:** directory-argument handling; a find-file surface (in flight,
PR #162); surfacing the LSP spawn failure with guidance (§1.2); a
cheap:** directory-argument handling (the remaining half of step 3 —
dired Stage 1 landed the buffer it should resolve to); a find-file
surface (**done**: #162 open-by-path, PR #165 browsing); surfacing the
LSP spawn failure with guidance (§1.2); a
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.

1
Cargo.lock generated
View File

@ -2630,6 +2630,7 @@ dependencies = [
"pollster",
"sys-locale",
"tempfile",
"ttf-parser",
"unicode-width",
"wgpu",
"winit",

895
builtin/runtime/dired.lua Normal file
View File

@ -0,0 +1,895 @@
-- dired.lua --- the directory view (dired arc Stage 1).
--
-- Dired is not a convenience rider on an existing file surface: until
-- Stage 0 (`C-x C-f`, #162) there was no way to open a file by path at
-- all, and browsing is the half a user reaches for when they do NOT
-- already know the path. So this is a primary surface, and the one
-- thing it may never do is refuse to render a listing --- hence the
-- per-entry-tolerant `read_dir` opt it drives (Q#DR6), the only Rust
-- this stage needed besides exposing the path normalizer.
--
-- Framing: docs/dired-framing.md (Q#DR1-DR10). Stage 1 is the view:
-- listing, navigation, sort, revert, quit. Marks and operations are
-- Stage 2; the editable wdired layer is Stage 3.
--
-- Public surface:
--
-- pmacs.dired.open(path [, opts]) -- awaits; run inside pmacs.async
-- opts.display = "current" | "panel" (Q#BP11b, default "current")
-- opts.select_name = "<basename>" -- seat the cursor on it
--
-- M-x dired / C-x d -- prompt for a directory
-- M-x dired-jump / C-x C-j -- dired on this file's directory
--
-- In a dired buffer (mode-scoped keys, Q#DR8):
-- RET, f visit (directory -> descend, file -> display_file)
-- ^ parent directory
-- n / p move by line (<down> / <up> too)
-- g revert (re-read, preserving the cursor's entry)
-- q quit (restore the previous buffer, or window.quit in a panel)
-- s cycle sort mode (name -> mtime -> size)
--
-- Three structural decisions worth knowing before editing this file:
--
-- 1. ONE BUFFER PER DIRECTORY, named `*dired:<canonical path>*`
-- (Q#DR2). Navigation *opens the target's buffer*; it never mutates
-- the current one. That is Emacs behavior, and it is also the only
-- way to keep the name honest --- there is no
-- `pmacs.buffer.set_name`, so the M8.2 fixture's in-place repaint
-- leaves a buffer named after a directory it no longer shows.
--
-- 2. THE CANONICAL FORM IS THE CORE'S, not a copy of it
-- (`pmacs.path.canonicalize` is `normalize_buffer_path` itself).
-- Dired's name-dedup and `display_file`'s `find_buffer_for_path`
-- dedup have to agree; two implementations that disagree on `//tmp`
-- or a `..` at root would mint two buffers for one directory with no
-- error anywhere.
--
-- 3. EVERY LISTING IS ASYNC. `pmacs.fs.read_dir` is worker-dispatched,
-- so each command spawns a coroutine and the work after the first
-- `:await()` resumes on a later tick --- outside interactive
-- dispatch. Three consequences:
--
-- * Errors MUST be `pcall`ed and reported here, and that is
-- load-bearing rather than tidy. An uncaught raise inside a
-- `pmacs.async` coroutine reaches `step()`, which reports through
-- `pmacs.error` --- a channel that **is never defined in
-- production** (`COHERENCE.md` §1.1) --- and so falls through to a
-- bare `error()` inside `pmacs._async.tick()`, whose result
-- `EditorState::tick_async` discards with `let _ =`. The failure
-- would not reach the status line, the `*errors*` buffer, or a log:
-- it would reach nowhere, and dired would look like it silently did
-- nothing.
-- * Reporting therefore goes through `pmacs.editor.set_status`, which
-- exists and which the acceptance suite observes --- the corollary
-- COHERENCE draws from that dead channel: report through a surface
-- a test can see, or the guard is indistinguishable from the
-- silence it was meant to fix.
-- * `pmacs.window.*` calls made after the await act for the *ambient*
-- active frontend, since interactive origin does not survive the
-- tick boundary; and `pmacs.editor.move_to_line` acts on the
-- ambient *buffer*, which is why every post-await re-seat is
-- guarded (see `seat_cursor`).
-- Emacs 28's dired-kill-when-opening-new-dired-buffer, as a setting
-- rather than a hardcoded policy: buffer-per-directory accumulates
-- buffers when walking a deep tree, and Emacs users differ on whether
-- that is a feature.
pmacs.config.define {
name = "dired.kill-when-opening",
description = "Kill the dired buffer being left when descending or ascending.",
type = "boolean",
default = false,
mutability = "live",
}
-- ---------------------------------------------------------------------------
-- Layout
-- ---------------------------------------------------------------------------
--
-- The mark column is column 0 (Q#DR4), so every other column sits two
-- bytes right of the M8.2 fixture's offsets. Stage 1 always renders it
-- blank: filling it in is Stage 2's job, but reserving it now means
-- Stage 2 does not have to move every column, and Stage 3's
-- column-classifying intercept can be written against constants that
-- did not shift under it. Offsets are computed from the widths for the
-- same reason --- the fixture hardcoded `NAME_START = 39` and paid for
-- it in every wdired test.
local MARK_BYTES = 2
local KIND_BYTES = 1
local PERMS_BYTES = 9
local SIZE_BYTES = 10
local MTIME_BYTES = 16
local MARK_START = 0
local KIND_START = MARK_START + MARK_BYTES -- 2
local PERMS_START = KIND_START + KIND_BYTES -- 3
local PERMS_END = PERMS_START + PERMS_BYTES -- 12 (exclusive)
local SIZE_START = PERMS_END + 1 -- 13
local MTIME_START = SIZE_START + SIZE_BYTES + 1 -- 24
local NAME_START = MTIME_START + MTIME_BYTES + 1 -- 41
local BLANK_MARK = string.rep(" ", MARK_BYTES)
local SORT_MODES = { "name", "mtime", "size" }
-- ---------------------------------------------------------------------------
-- Per-buffer state
-- ---------------------------------------------------------------------------
--
-- handles: array of { buf, path, entries, errors, sort_mode, prev }.
--
-- Keyed by linear scan over `BufferIdLua.__eq` rather than by table
-- key: two BufferIdLua values for the same buffer are distinct
-- userdata, so a `handles[buf]` lookup would miss. The scan is over a
-- handful of dired buffers. Dead buffers are compacted out first, so a
-- command in a removed dired buffer sees "not in dired" rather than
-- operating on dead state (the M8.2 fixture's `find_handle` lesson).
local handles = {}
local function live_handles()
local live = {}
for _, h in ipairs(handles) do
local ok, valid = pcall(h.buf.is_valid, h.buf)
if ok and valid then live[#live + 1] = h end
end
handles = live
return live
end
local function handle_for_buffer(buf)
if buf == nil then return nil end
for _, h in ipairs(live_handles()) do
if h.buf == buf then return h end
end
return nil
end
local function handle_for_path(path)
for _, h in ipairs(live_handles()) do
if h.path == path then return h end
end
return nil
end
local function active_handle()
return handle_for_buffer(pmacs.window.buffer())
end
-- ---------------------------------------------------------------------------
-- Paths and names
-- ---------------------------------------------------------------------------
local canonicalize = pmacs.path.canonicalize
local function join_path(dir, name)
if dir:sub(-1) == "/" then return dir .. name end
return dir .. "/" .. name
end
-- Parent of a canonical directory, through the same normalizer: `..`
-- against the root folds away, so `/` is its own parent and no separate
-- root special case can drift out of agreement with the canonical form.
local function parent_path(path)
return canonicalize(join_path(path, ".."))
end
local function basename(path)
return path:match("([^/]+)/*$")
end
local function dirname(path)
local dir = path:match("^(.*)/[^/]*$")
if dir == nil then return nil end
if dir == "" then return "/" end
return dir
end
local function buffer_name(path)
return "*dired:" .. path .. "*"
end
local function buffer_named(name)
for _, id in ipairs(pmacs.buffer.list()) do
local ok, described = pcall(pmacs.describe.buffer, id)
if ok and described and described.name == name then return id end
end
return nil
end
-- The directory a prompt or a jump should start from: the active
-- buffer's own directory, else the process cwd (which the normalizer
-- yields for a bare "." because it absolutizes against it).
local function current_directory()
local buf = pmacs.window.buffer()
if buf ~= nil then
local ok, path = pcall(function() return buf:path() end)
if ok and path then
local dir = dirname(path)
if dir then return canonicalize(dir) end
end
local h = handle_for_buffer(buf)
if h then return h.path end
end
return canonicalize(".")
end
-- ---------------------------------------------------------------------------
-- Failure reporting
-- ---------------------------------------------------------------------------
-- `Handle:await()` raises structured tables (R45), so `tostring` on a
-- failure yields "table: 0x...". Every user-visible dired failure goes
-- through here.
local function failure_message(err)
if type(err) == "table" then
return tostring(err.message or err.tag or "error")
end
return tostring(err)
end
local function report(where, err)
pmacs.editor.set_status(where .. ": " .. failure_message(err))
end
-- ---------------------------------------------------------------------------
-- Rendering
-- ---------------------------------------------------------------------------
-- `rwxr-xr-x`, without the leading kind char (rendered separately so a
-- symlink shows `l` and a directory `d`). Arithmetic rather than bit
-- ops: this file has to run on LuaJIT (5.1) as well as Lua 5.4.
--
-- The nine basic bits only: setuid / setgid / sticky are deliberately
-- not surfaced as Emacs's `s` / `t`, matching the M8.3 fixture's
-- `parse_perm_string`, which edits exactly these nine. Rendering a bit
-- Stage 3 could not accept back would be worse than omitting it.
local function fmt_perms(mode)
local function tri(bits)
local r = (bits >= 4) and "r" or "-"
local w = ((bits % 4) >= 2) and "w" or "-"
local x = ((bits % 2) >= 1) and "x" or "-"
return r .. w .. x
end
return tri(math.floor(mode / 64) % 8)
.. tri(math.floor(mode / 8) % 8)
.. tri(mode % 8)
end
local function kind_char(kind)
if kind == "dir" then return "d"
elseif kind == "symlink" then return "l"
elseif kind == "file" then return "-"
else return "?" -- device, fifo, socket
end
end
-- Exact bytes while they fit the column; a magnitude past that.
--
-- `%10d` holds ten digits, so a file of 10 GB or more (VM images, core
-- dumps --- ordinary things) widens the field and shifts mtime and name
-- right on that line alone. That is only cosmetic today, but
-- `_layout.NAME_START` is exported as a contract and Stage 3's
-- column-classifying intercept is planned against these constants, so a
-- line that violates them now is a Stage 3 trap. Same discipline as
-- `fmt_mtime`: the width is the invariant, and precision yields to it.
--
-- This is NOT the deferred human-readable size column (§13): the exact
-- byte count is still what a listing shows, right up to the point where
-- it cannot be shown at all.
local SIZE_UNITS = { "K", "M", "G", "T", "P", "E" }
local function fmt_size(n)
local exact = string.format("%" .. SIZE_BYTES .. "d", n)
if #exact <= SIZE_BYTES then return exact end
local value, unit = n, SIZE_UNITS[#SIZE_UNITS]
for _, suffix in ipairs(SIZE_UNITS) do
value = value / 1024
unit = suffix
if value < 1024 then break end
end
local scaled = string.format("%.1f%s", value, unit)
if #scaled > SIZE_BYTES then scaled = scaled:sub(1, SIZE_BYTES) end
return string.rep(" ", SIZE_BYTES - #scaled) .. scaled
end
local function fmt_mtime(secs)
-- Explicit format string, so the width is fixed and the result does
-- not move with LC_TIME. A pre-epoch mtime is legal and `os.date`'s
-- behavior on a negative time is platform-dependent, so a
-- non-conforming result degrades to a fixed-width placeholder rather
-- than shifting every column right of it.
local ok, formatted = pcall(os.date, "%Y-%m-%d %H:%M", secs)
if ok and type(formatted) == "string" and #formatted == MTIME_BYTES then
return formatted
end
return string.rep("?", MTIME_BYTES)
end
-- POSIX permits any byte but `/` and NUL in a filename, including `\n`.
-- Rendering one verbatim would break the one-line-per-entry invariant
-- that cursor-line -> entry resolution rests on (and that Stage 3's
-- intercept will rest on harder), so control bytes are escaped. The
-- backslash goes first, which is what makes the encoding invertible ---
-- Stage 3 needs the exact inverse so a no-op commit cannot fire a
-- spurious rename. Carried over from the M8.2 fixture as decided
-- design, not re-litigated.
local function escape_displayable(s)
if s == nil then return "" end
s = s:gsub("\\", "\\\\")
s = s:gsub("\n", "\\n")
s = s:gsub("\r", "\\r")
s = s:gsub("\t", "\\t")
-- NUL is deliberately absent from the class: the kernel forbids it in
-- a filename, so the fixture's `%z` (removed from Lua 5.2's pattern
-- syntax) was covering a case that cannot occur.
s = s:gsub("[\1-\8\11\12\14-\31]", function(ch)
return string.format("\\x%02X", string.byte(ch))
end)
return s
end
local function render_entry(entry)
local target = ""
if entry.symlink_target then
target = " -> " .. escape_displayable(entry.symlink_target)
elseif entry.kind == "symlink" then
-- A tolerant listing keeps a symlink whose target could not be
-- represented (non-UTF-8) or read; say so rather than rendering a
-- bare `l` line that looks like a complete entry.
target = " -> ?"
end
return string.format(
"%s%s%s %s %s %s%s",
BLANK_MARK, kind_char(entry.kind), fmt_perms(entry.mode),
fmt_size(entry.size), fmt_mtime(entry.mtime),
escape_displayable(entry.name), target)
end
-- Header (line 0) + one line per entry + the unreadable-count footer.
-- The footer exists because a tolerant listing that silently dropped
-- entries is worse than one that failed: the user has to know the view
-- is incomplete (and Stage 3's wdired refuses to open on one).
local function render_text(handle)
local lines = { handle.path .. ":" }
for _, entry in ipairs(handle.entries) do
lines[#lines + 1] = render_entry(entry)
end
local unreadable = #handle.errors
if unreadable > 0 then
lines[#lines + 1] = string.format("%d entries unreadable", unreadable)
end
return table.concat(lines, "\n")
end
-- Dired's own writes are the only ones that reach the buffer: the
-- read-only intercept rejects everything else, and this bypasses it.
local function paint(handle)
local text = render_text(handle)
handle.buf:replace(0, handle.buf:len(), text, { bypass_intercept = true })
end
-- ---------------------------------------------------------------------------
-- Cursor
-- ---------------------------------------------------------------------------
--
-- Entry i renders on line i (line 0 is the header), so the entry under
-- the cursor is `entries[cursor_line()]`.
local function entry_at_cursor(handle)
local line = pmacs.editor.cursor_line()
if line < 1 then return nil end
return handle.entries[line], line
end
local function index_of_name(handle, name)
if name == nil then return nil end
for i, entry in ipairs(handle.entries) do
if entry.name == name then return i end
end
return nil
end
-- Re-seat by BASENAME (Q#DR9), falling back to the nearest surviving
-- line. Every repaint is wholesale, so without this a revert, a sort,
-- or any Stage 2 operation would drop the cursor to the header.
--
-- `move_to_line` is AMBIENT --- it moves the active window's cursor, not
-- `handle.buf`'s --- so every caller that can run after an `:await()`
-- has to check that dired is still the active buffer first. Painting is
-- safe either way (it names the buffer); seating is not. Callers that
-- activate the buffer themselves (an open, which displays first) are
-- unconditionally in the right place.
local function seat_cursor(handle, name, fallback_line)
local count = #handle.entries
if count == 0 then
pmacs.editor.move_to_line(0)
return
end
local target = index_of_name(handle, name)
if target == nil then
target = math.max(1, math.min(fallback_line or 1, count))
end
pmacs.editor.move_to_line(target)
end
-- ---------------------------------------------------------------------------
-- Sorting
-- ---------------------------------------------------------------------------
local function sort_entries(entries, mode)
if mode == "name" then
table.sort(entries, function(a, b) return a.name < b.name end)
elseif mode == "mtime" then
-- Newest first, name as a stable tiebreak so a directory of
-- same-second files renders deterministically.
table.sort(entries, function(a, b)
if a.mtime ~= b.mtime then return a.mtime > b.mtime end
return a.name < b.name
end)
elseif mode == "size" then
table.sort(entries, function(a, b)
if a.size ~= b.size then return a.size > b.size end
return a.name < b.name
end)
else
error("dired: unknown sort mode: " .. tostring(mode))
end
end
local function next_sort_mode(mode)
for i, candidate in ipairs(SORT_MODES) do
if candidate == mode then
return SORT_MODES[(i % #SORT_MODES) + 1]
end
end
return SORT_MODES[1]
end
-- ---------------------------------------------------------------------------
-- Reading
-- ---------------------------------------------------------------------------
-- Read and sort one directory without touching editor state, so a
-- failure happens before any side effect is committed (acceptance 15).
-- Must run inside `pmacs.async`.
--
-- Always tolerant (Q#DR6): a plain refresh of a busy directory must not
-- fail because one child was unlinked between `readdir` and `lstat`.
-- Parent-level failures and non-UTF-8 *names* still raise.
local function read_listing(path, sort_mode)
local listing = pmacs.fs.read_dir(path, { tolerant = true }):await()
local entries = listing.entries
sort_entries(entries, sort_mode)
return entries, listing.errors
end
-- ---------------------------------------------------------------------------
-- Buffer ownership
-- ---------------------------------------------------------------------------
-- How far the `<2>`, `<3>`, ... disambiguation walks before giving up.
local NAME_VARIANT_LIMIT = 99
-- `pmacs.buffer.create` takes any caller-chosen name, so a foreign
-- buffer may already be called `*dired:/tmp*`. Painting into it through
-- `bypass_intercept` would clobber a user's data, so found-by-name is
-- NOT adoption: ownership means "this buffer is in dired's own handle
-- table" (F7).
--
-- That is deliberately narrower than the framing's "in the handle table
-- OR major_mode == dired": a foreign buffer that also carries the mode
-- is precisely the case the check exists to refuse, and a builtin's
-- handle table cannot be lost the way a reloadable package's can.
local function claim_handle(path)
local existing = handle_for_path(path)
if existing then return existing end
local name = buffer_name(path)
if buffer_named(name) then
local unique = nil
for i = 2, NAME_VARIANT_LIMIT do
local candidate = string.format("%s<%d>", name, i)
if buffer_named(candidate) == nil then
unique = candidate
break
end
end
if unique == nil then
error(string.format("dired: %s is taken and no free variant remains", name))
end
name = unique
end
local buf = pmacs.buffer.create(name)
-- Read-only by the listview idiom (Q#DR3): every non-bypass edit is
-- rejected, and the intercept lives as long as the buffer.
pmacs.buffer.add_intercept(buf, function()
error(name .. " is read-only")
end)
-- Q#DR3/Q#P6: while this buffer is active a semantic frontend must
-- round-trip keys, or optimistic apply would swallow the single-key
-- bindings (`g` would insert a `g` into a CRDT mirror instead of
-- reverting) and bypass the intercept entirely.
pmacs.buffer.set_round_trip_input(buf, true)
-- Q#DR8: the mode is what carries the keymap, and dired is #129's
-- first consumer of mode-scoped keys outside language detection.
pmacs.buffer.set_major_mode(buf, "dired")
local handle = {
buf = buf,
path = path,
entries = {},
errors = {},
sort_mode = SORT_MODES[1],
prev = nil,
}
handles[#handles + 1] = handle
return handle
end
-- ---------------------------------------------------------------------------
-- Display
-- ---------------------------------------------------------------------------
local function drop_handle(handle)
for i, candidate in ipairs(handles) do
if candidate == handle then
table.remove(handles, i)
return
end
end
end
-- Kill the dired buffer being left, when the user asked for it.
-- Deliberately after the new buffer is displayed: `pmacs.buffer.kill`
-- redirects windows showing the doomed buffer, and doing that first
-- would fight the display we are about to perform.
local function kill_departed(departed, arriving)
if departed == nil or departed == arriving then return end
if not pmacs.config.get("dired.kill-when-opening") then return end
local ok, err = pcall(pmacs.buffer.kill, departed.buf)
if ok then
drop_handle(departed)
else
-- A buffer that could not be killed keeps its handle: dropping it
-- would leave a live dired buffer no command recognizes.
report("dired", err)
end
end
-- Where a dired buffer goes.
--
-- A fresh `dired` takes the standard adopter opt (Q#BP11b): omitted or
-- "current" is the raw switch every other adopter defaults to in
-- Stages 1-2, "panel" is the bottom side window.
--
-- Navigation (`departed ~= nil`) instead reuses the window dired
-- already occupies, which is the opposite routing from a file visit and
-- 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)
local side = nil
if departed ~= nil then
-- Dired's own window, not the request's: walking a tree in a side
-- window keeps the side window.
local params = pmacs.window.params()
side = params and params.side
elseif opts and opts.display == "panel" then
side = "bottom"
end
if side ~= nil then
-- A side slot DEDICATED to another buffer refuses the replacement
-- and this falls back to the document window (Q#BP3 2.iii). That is
-- 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 })
else
pmacs.window.switch_buffer(handle.buf)
end
end
-- ---------------------------------------------------------------------------
-- Public: open a directory
-- ---------------------------------------------------------------------------
pmacs.dired = pmacs.dired or {}
local OPEN_OPTS = { display = true, select_name = true }
-- Open `path`'s dired buffer, replacing `departed` (a handle) in the
-- window it occupies when this is a navigation rather than a fresh
-- open. Awaits, so it must run inside `pmacs.async`; raises on a read
-- failure, having changed nothing. Returns the buffer.
local function open_directory(path, opts, departed)
if type(path) ~= "string" then
error("pmacs.dired.open: path must be a string, got " .. type(path))
end
opts = opts or {}
-- Validated up front, before the read and before any buffer exists,
-- so a bad opt leaves nothing to roll back (the
-- `parse_adopter_placement` discipline).
for key in pairs(opts) do
if not OPEN_OPTS[key] then
error(string.format("pmacs.dired.open: unknown opts key %q", tostring(key)))
end
end
local wanted = opts.display
if wanted ~= nil and wanted ~= "current" and wanted ~= "panel" then
error(string.format('pmacs.dired.open: unknown display %q (expected "current" or "panel")',
tostring(wanted)))
end
local canonical = canonicalize(path)
-- Read first: a failure must leave no buffer, no window change, and
-- no handle behind.
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
-- `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
end
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
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
-- in *errors* and leave the user with a silent no-op).
local function open_async(path, opts, departed, where)
pmacs.async(function()
local ok, err = pcall(open_directory, path, opts, departed)
if not ok then report(where or "dired", err) end
end)
end
-- ---------------------------------------------------------------------------
-- Commands
-- ---------------------------------------------------------------------------
pmacs.command.define {
name = "dired",
description = "Open a directory listing (dired).",
fn = function()
local root = current_directory()
-- No completion source, deliberately. `source = "files"` would make
-- RET-on-empty open whatever sorts first (the minibuffer selects
-- candidate 0 whenever the list is non-empty, and a selected
-- candidate shadows typed text --- S0-1/S0-4), and RET-on-the-
-- default-directory is exactly the gesture `C-x d` exists for. The
-- field is prefilled instead, which is Emacs's own shape here.
pmacs.minibuffer.read {
prompt = "Dired: ",
initial = root,
history = "dired",
on_accept = function(value)
if value == nil or value == "" then return end
open_async(value, nil, nil, "dired")
end,
}
end,
}
pmacs.command.define {
name = "dired-jump",
description = "Open dired on the current file's directory, cursor on that file.",
fn = function()
local buf = pmacs.window.buffer()
local path = nil
if buf ~= nil then
local ok, value = pcall(function() return buf:path() end)
if ok then path = value end
end
if path == nil then
pmacs.editor.set_status("dired-jump: this buffer has no file")
return
end
local dir = dirname(path)
if dir == nil then
pmacs.editor.set_status("dired-jump: cannot find the directory of " .. path)
return
end
open_async(dir, { select_name = basename(path) }, nil, "dired-jump")
end,
}
pmacs.command.define {
name = "dired.visit",
description = "Visit the entry under the cursor (descend a directory, open a file).",
fn = function()
local handle = active_handle()
if handle == nil then return end
local entry = entry_at_cursor(handle)
-- The header and the unreadable-count footer are not entries.
if entry == nil then return end
local target = join_path(handle.path, entry.name)
if entry.kind == "dir" then
open_async(target, nil, handle, "dired")
return
end
if entry.kind == "symlink" then
-- `read_dir` and `stat` are both lstat-based, so nothing in the
-- entry says whether the link points at a directory --- the only
-- way to find out is to try to list it. A symlinked directory is
-- an ordinary thing to walk into, so try the descent and fall back
-- to a file visit.
--
-- `open_directory` is the try: it reads before touching any editor
-- state and raises having changed nothing (acceptance 15), so its
-- failure IS the "not a directory" answer. An explicit probe
-- followed by the real open would list the whole directory TWICE
-- --- opendir plus one lstat per child, each time.
pmacs.async(function()
local descended = pcall(open_directory, target, nil, handle)
if descended then return end
local visited, err = pcall(pmacs.window.display_file, target, { select = true })
if not visited then report("dired", err) end
end)
return
end
-- Q#DR10: `display_file`, never `find_or_open`, which switches the
-- active window in both branches before firing hooks --- in a
-- panel-displayed dired that would replace the panel with the
-- visited file, i.e. the panel swallows itself.
local ok, err = pcall(pmacs.window.display_file, target, { select = true })
if not ok then report("dired", err) end
end,
}
pmacs.command.define {
name = "dired.parent",
description = "Open the parent directory.",
fn = function()
local handle = active_handle()
if handle == nil then return end
local parent = parent_path(handle.path)
if parent == handle.path then
pmacs.editor.set_status("dired: already at the filesystem root")
return
end
-- Seat on the directory we came from, the way Emacs's `^` does.
open_async(parent, { select_name = basename(handle.path) }, handle, "dired")
end,
}
pmacs.command.define {
name = "dired.revert",
description = "Re-read the directory, keeping the cursor on its entry.",
fn = function()
local handle = active_handle()
if handle == nil then return end
local entry, line = entry_at_cursor(handle)
local name = entry and entry.name
pmacs.async(function()
local ok, entries, errors = pcall(read_listing, handle.path, handle.sort_mode)
if not ok then
-- On failure `entries` carries the raised value, not a listing.
report("dired", entries)
return
end
if not handle.buf:is_valid() then return end
handle.entries = entries
handle.errors = errors
paint(handle)
-- The re-read settles a tick or more later, and the user may have
-- left (a buffer switch, or `q`) in the meantime. The paint names
-- its buffer and is safe; seating is ambient, so a stale seat here
-- would move an unrelated buffer's cursor to a line index that
-- only means something in this listing.
if pmacs.window.buffer() == handle.buf then
seat_cursor(handle, name, line)
end
end)
end,
}
pmacs.command.define {
name = "dired.sort-cycle",
description = "Cycle the sort mode: name -> mtime -> size.",
fn = function()
local handle = active_handle()
if handle == nil then return end
local entry, line = entry_at_cursor(handle)
local name = entry and entry.name
-- A pure reorder of the entries already in hand: sort is a display
-- decision, not a reason to re-read the directory.
handle.sort_mode = next_sort_mode(handle.sort_mode)
sort_entries(handle.entries, handle.sort_mode)
paint(handle)
seat_cursor(handle, name, line)
pmacs.editor.set_status("dired: sorted by " .. handle.sort_mode)
end,
}
pmacs.command.define {
name = "dired.quit",
description = "Leave dired, restoring the previous buffer.",
fn = function()
local handle = active_handle()
if handle == nil then return end
-- Q#BP11b, matching `listview.quit`: `q` keeps its name and its
-- user-visible behavior, delegating to `window.quit` only when
-- dired really is in a side window.
local params = pmacs.window.params()
if params and params.side and params.quit_action then
pmacs.window.quit()
return
end
local target = handle.prev
if not (target and target:is_valid()) then
target = buffer_named("*scratch*") or pmacs.buffer.create("*scratch*")
end
pmacs.window.switch_buffer(target)
end,
}
-- ---------------------------------------------------------------------------
-- Keys
-- ---------------------------------------------------------------------------
-- Global: both sequences are unbound repo-wide, and both are the Emacs
-- defaults.
pmacs.keymap.bind { scope = "global", sequence = "C-x d", command = "dired" }
pmacs.keymap.bind { scope = "global", sequence = "C-x C-j", command = "dired-jump" }
-- In-buffer keys are MODE-scoped (Q#DR8), bound once here rather than
-- per buffer: a second dired buffer needs no `keymap.bind` of its own,
-- and Stage 3's wdired swap changes the whole keymap with the mode
-- instead of unbinding key by key.
local function bind(sequence, command)
pmacs.keymap.bind { scope = "mode", mode = "dired", sequence = sequence, command = command }
end
bind("RET", "dired.visit")
bind("f", "dired.visit")
bind("^", "dired.parent")
bind("n", "cursor.down")
bind("<down>", "cursor.down")
bind("p", "cursor.up")
bind("<up>", "cursor.up")
bind("g", "dired.revert")
bind("q", "dired.quit")
bind("s", "dired.sort-cycle")
-- ---------------------------------------------------------------------------
-- Test seam
-- ---------------------------------------------------------------------------
--
-- The layout constants, so acceptance can assert column positions
-- without hardcoding the numbers this file computes.
pmacs.dired._layout = {
MARK_START = MARK_START,
KIND_START = KIND_START,
PERMS_START = PERMS_START,
PERMS_END = PERMS_END,
SIZE_START = SIZE_START,
MTIME_START = MTIME_START,
NAME_START = NAME_START,
}

View File

@ -12,7 +12,10 @@
-- `symlink_target` is present only on symlink entries.
-- `opts` may contain `supersede = "<key>"` to chain into the M3
-- supersede semantics (a later read_dir under the same key
-- cancels the earlier one).
-- cancels the earlier one), and `tolerant = true` to swap the
-- all-or-nothing listing for `{ entries = ..., errors = ... }`
-- (see fs.read_dir's own comment below). Any other key is an
-- error rather than being silently ignored.
--
-- Order: entries are returned in *filesystem iteration order*,
-- which is whatever the kernel's `readdir` syscall returns. On
@ -69,24 +72,61 @@ end
local fs = {}
-- Shared opts.supersede extractor; raises on misshapen opts.
local function supersede_key(opts, where)
if opts == nil then return nil end
-- Shared read-op opts parser; raises on misshapen opts.
--
-- Unknown keys are REJECTED, not ignored. The earlier version read
-- `opts.supersede` and silently dropped everything else, which means a
-- typo'd `tolerant` would degrade to the fatal contract with no signal
-- at all --- exactly the failure the tolerant opt exists to prevent
-- (dired framing §8, minor c). `allowed` is the per-op whitelist.
local function read_opts(opts, where, allowed)
if opts == nil then return nil, false end
if type(opts) ~= "table" then
error(where .. ": opts must be a table or nil, got " .. type(opts))
end
local k = opts.supersede
if k ~= nil and type(k) ~= "string" then
for key in pairs(opts) do
if not allowed[key] then
local names = {}
for name in pairs(allowed) do names[#names + 1] = name end
table.sort(names)
error(string.format("%s: unknown opts key %q (expected one of: %s)",
where, tostring(key), table.concat(names, ", ")))
end
end
local key = opts.supersede
if key ~= nil and type(key) ~= "string" then
error(where .. ": opts.supersede must be a string")
end
return k
local tolerant = opts.tolerant
if tolerant ~= nil and type(tolerant) ~= "boolean" then
error(where .. ": opts.tolerant must be a boolean")
end
return key, tolerant == true
end
local READ_DIR_OPTS = { supersede = true, tolerant = true }
local STAT_OPTS = { supersede = true }
-- Two result shapes, chosen by `opts.tolerant` (dired Q#DR6):
--
-- read_dir(path) -> { <entry>, ... }
-- read_dir(path, { tolerant = true }) -> { entries = { <entry>, ... },
-- errors = { { name = ...?,
-- message = ... }, ... } }
--
-- The bare array is the M8.1 contract and stays exactly as it was, so
-- an existing consumer (the frozen M8.2 dired fixture consumes it with
-- `ipairs`) is unaffected. Under the opt, a per-entry `readdir` /
-- `lstat` / `readlink` failure and a non-UTF-8 symlink *target* become
-- `errors` rows instead of failing the whole listing; a failure on the
-- parent directory, and a non-UTF-8 entry *name*, stay fatal. An
-- `errors` row has no `name` when the entry never materialized.
function fs.read_dir(path, opts)
if type(path) ~= "string" then
error("pmacs.fs.read_dir: path must be a string, got " .. type(path))
end
local id = async_mod._dispatch_fs_read_dir(path, supersede_key(opts, "pmacs.fs.read_dir"))
local key, tolerant = read_opts(opts, "pmacs.fs.read_dir", READ_DIR_OPTS)
local id = async_mod._dispatch_fs_read_dir(path, key, tolerant)
return build_handle(id)
end
@ -94,7 +134,8 @@ function fs.stat(path, opts)
if type(path) ~= "string" then
error("pmacs.fs.stat: path must be a string, got " .. type(path))
end
local id = async_mod._dispatch_fs_stat(path, supersede_key(opts, "pmacs.fs.stat"))
local key = read_opts(opts, "pmacs.fs.stat", STAT_OPTS)
local id = async_mod._dispatch_fs_stat(path, key)
return build_handle(id)
end

View File

@ -3,6 +3,38 @@
local terminal = assert(pmacs.terminal, "pmacs.terminal raw bindings are required")
local raw_open = assert(terminal._open, "pmacs.terminal._open is required")
-- Q#TC2a. Every default reproduces today's behavior exactly, so a tree
-- with no settings written and no profiles registered behaves as before.
pmacs.config.define {
name = "terminal.default-profile",
type = "string",
default = "",
allow_empty = true,
mutability = "live",
description = "Profile name from pmacs.terminal.profiles to open by default. " ..
"Empty means no profile: fall back to $SHELL.",
}
pmacs.config.define {
name = "terminal.scrollback-rows",
type = "integer",
default = 10000,
min = 0,
max = 4000000,
mutability = "live",
description = "Rows of scrollback retained per terminal. " ..
"0 retains no history.",
}
pmacs.config.define {
name = "terminal.escape-key",
type = "string",
default = "C-c",
mutability = "live",
description = "Chord that escapes to the editor from a terminal. " ..
"Pressing it twice sends the chord itself to the child.",
}
local function bind_terminal_keys(buffer)
local function bind(sequence, command)
pmacs.keymap.bind {
@ -19,22 +51,145 @@ local function bind_terminal_keys(buffer)
bind("M->", "terminal.scroll-bottom")
end
-- Q#TC1: profiles are a raw Lua table, not a config setting. The
-- registry stores four scalars and has no table kind, so a profile —
-- inherently `{ command, args, cwd, env }` — lives here beside
-- `pmacs.lsp.config` and `pmacs.pair.sets` until table-valued settings
-- exist.
terminal.profiles = terminal.profiles or {}
local PROFILE_FIELDS = {
command = "string",
args = "table",
cwd = "string",
env = "table",
}
-- Every diagnostic below renders a caller- or user-supplied value, so
-- rendering must never be the thing that fails. `%q` is partial — it
-- raises on a table or function — and a profile name arrives straight
-- from `open { profile = ... }`.
local function describe_name(name)
if type(name) == "string" then return string.format("%q", name) end
return string.format("<%s %s>", type(name), tostring(name))
end
local function validate_profile(name, profile)
local shown = describe_name(name)
if type(profile) ~= "table" then
error(string.format("terminal profile %s must be a table", shown), 0)
end
for key, value in pairs(profile) do
local expected = PROFILE_FIELDS[key]
if not expected then
error(string.format("terminal profile %s: unknown field %q", shown, tostring(key)), 0)
end
if type(value) ~= expected then
error(string.format(
"terminal profile %s: field %q must be a %s, got %s",
shown, key, expected, type(value)), 0)
end
end
return profile
end
-- `terminal.profiles` is a raw user table, so its keys are whatever the
-- user wrote. Sorting them directly raises "attempt to compare number
-- with string" the moment the table holds both a string and a numeric
-- key — and it raises on the UNKNOWN-PROFILE path, replacing the very
-- error this list exists to explain with an opaque one. Sorting DISPLAY
-- strings is total over every key type, so the diagnostic survives a
-- malformed table.
local function known_profile_names()
local names = {}
for name in pairs(terminal.profiles) do names[#names + 1] = tostring(name) end
table.sort(names)
return names
end
-- Q#TC2 / Q#TC3a: resolve a profile by name, or nil when none is
-- selected. An explicitly requested profile that does not exist is an
-- error even when `terminal.default-profile` is valid — a typo must not
-- silently fall back to the default.
local function resolve_profile(requested)
local name = requested
if name == nil then
local configured = pmacs.config.get("terminal.default-profile")
if configured == nil or configured == "" then return nil end
name = configured
end
local profile = terminal.profiles[name]
if profile == nil then
local known = known_profile_names()
local listed = #known > 0 and table.concat(known, ", ") or "(none defined)"
error(string.format(
"terminal profile %s is not defined; known profiles: %s",
describe_name(name), listed), 0)
end
return validate_profile(name, profile)
end
-- Q#TC3a merge order, per field: explicit open field, then the profile's
-- field, then the scalar setting, then the built-in fallback. `env` is
-- the one field where "first wins" would be wrong, so it MERGES with
-- explicit entries overriding the profile's — any other reading silently
-- drops half a user's environment.
local function merge_env(profile_env, explicit_env)
if profile_env == nil then return explicit_env end
local merged = {}
for key, value in pairs(profile_env) do merged[key] = value end
for key, value in pairs(explicit_env or {}) do merged[key] = value end
return merged
end
function terminal.open(spec)
local buffer = raw_open(spec)
spec = spec or {}
local resolved = {}
for key, value in pairs(spec) do
if key ~= "profile" then resolved[key] = value end
end
local profile = resolve_profile(spec.profile)
if profile then
for key in pairs(PROFILE_FIELDS) do
if key ~= "env" and resolved[key] == nil then resolved[key] = profile[key] end
end
resolved.env = merge_env(profile.env, spec.env)
end
-- The two open-time settings resolve through the GLOBAL chain
-- (Q#TC2b): they are read before the identity buffer exists, so there
-- is no terminal to resolve a buffer-local against.
if resolved.scrollback_rows == nil then
resolved.scrollback_rows = pmacs.config.get("terminal.scrollback-rows")
end
if resolved.command == nil then
resolved.command = os.getenv("SHELL") or "/bin/sh"
end
local buffer = raw_open(resolved)
bind_terminal_keys(buffer)
return buffer
end
pmacs.command.define {
name = "terminal",
description = "Open a terminal running $SHELL (or /bin/sh).",
fn = function()
return terminal.open {
command = os.getenv("SHELL") or "/bin/sh",
}
description = "Open a terminal running the configured profile, or $SHELL.",
fn = function(profile)
return terminal.open { profile = profile }
end,
}
-- Q#TC10: the opening binding. `COHERENCE.md` Priority 1 names a
-- terminal keybinding as part of protecting the golden journey, and §2
-- step 8 grades the terminal "works but undiscoverable". `C-c` is
-- already a live global prefix (fold's `C-c @ ...`), so this is a new
-- leaf under it rather than a shadow.
--
-- Named limitation: unreachable from INSIDE a terminal window, where
-- `C-c` is consumed as the escape. `M-x terminal` still works there.
pmacs.keymap.bind { scope = "global", sequence = "C-c t", command = "terminal" }
pmacs.command.define {
name = "terminal.copy-selection",
description = "Copy the active terminal selection.",

View File

@ -14,10 +14,11 @@ backlog.
machine-local: `origin` may name this canonical URL, a release mirror,
or something else, and therefore has no authority by name alone.
- Canonical base at this snapshot:
`githubsucks/main` @ `0dd16a5` (GPU initial-target #148 atop folding Stage 2
landed-doc refresh #150, folding Stage 2 #149, the ledger refresh #147, web
grammars HTML+CSS #146, and the LaTeX Stage 1 #144 / inline-math framing
#145 pair; protocol v20).
`githubsucks/main` @ `d152120` (the bottom-panel landed-doc refresh #156
atop the inline-math slice #158, dired Stage 1 #165, the GPU terminal
input fix #166, Lean 4 Stage 2 #161, the dired framing #164,
COHERENCE.md #163, find-file #162, Lean 4 Stage 1 #160, and the minimap
blank-slab fix #159; protocol v20).
- On the transfer source, `origin/main` named a release mirror at
`d3fa632` and lagged badly. On the current destination, `origin` names
the canonical URL. This difference is why all recovery begins by
@ -51,7 +52,7 @@ git worktree list
git status --short --branch
```
The `git log` command must expose `0dd16a5` or a newer intentional main.
The `git log` command must expose `d152120` or a newer intentional main.
If it does not, stop and repair the remote/fetch configuration.
## Lean 4 lane (Arc 8) — Stage 1 MERGED; Stage 2 IN REVIEW (PR #161)
@ -188,44 +189,153 @@ If it does not, stop and repair the remote/fetch configuration.
suites**; `git diff --check` clean. The sweep needs an isolated
`XDG_CONFIG_HOME` and `-- --skip basedpyright`.
## Dired lane — framing APPROVED; Stage 0 MERGED, Stage 1 next
## Dired lane — Stage 0 MERGED; Stage 1 IN REVIEW (PR #165)
- Approved framing: `docs/dired-framing.md` (revision 5), landing as its
own docs PR off `githubsucks/main` @ `2af1ab3`, branch
`githubsucks/dired-framing`, worktree `../pmacs-dired-framing`. The
repo's `-framing`-branch convention (`vterm-framing`,
`gpu-initial-target-framing`, `tab-width-parity-framing`).
- Approved framing: `docs/dired-framing.md` **revision 6** — rev 5 is the
approved text (merged as its own docs PR #164), rev 6 adds §0's Stage 1
implementation notes (S1-1…S1-9). Stages 2 (marks and operations) and 3
(wdired) each get their own detailed framing after the prior stage lands.
- **Stage 0 (`C-x C-f` find-file) MERGED as #162** (`main` @ `2af1ab3`,
2026-07-25, one review round, 12/12 CI green). Durable facts moved to
`docs/agent-handoff.md` §1 per rule 3 below.
- **Stage 1 (the dired view) is next and unstarted.** Branch `dired`
(worktree `../pmacs-dired-arc`) carries the framing commits only and is
based on the now-superseded `0827dd1`; **rebase it onto the `main`
resulting from the framing PR before implementing**, or cut a fresh
branch — its framing commits become redundant once the docs PR lands.
- Stage 1's scope, from the framing §10: `builtin/runtime/dired.lua`; the
`dired` major mode + mode keymap; buffer-per-directory with lexical
canonicalization and the ownership check; read-only intercept +
`set_round_trip_input`; visit routing through `window.display_file`;
parent/sort/revert/quit; `C-x d` (with the `display` opt) / `C-x C-j`;
cursor preservation by basename; the `dired.kill-when-opening` config
key; **and the tolerant `read_dir` opt** — the only Rust in the stage.
- The one Rust change is load-bearing and is why Stage 1 is not
pure-Lua: `read_dir_blocking` (`src/fs.rs:201`) fails the **entire
listing** on any of five per-entry conditions, and the tolerant wrapper
its own module doc delegates to package authors **cannot be written in
Lua** — the primitive returns one error and no partial vec.
- Coherence (framing §0.5, required since #163): serves `COHERENCE.md`
§20 Priority 1, which names this work explicitly; journey steps 7 and
(partially) 3; **adds no interaction island** — keys are a mode-scoped
keymap, and wdired is a mode swap; adopts `pmacs.config` for
`dired.kill-when-opening`; inherits §9's worker-attribution gap for its
`read_dir` jobs without worsening it.
- **Stage 1 branch: `githubsucks/dired-stage1`**, worktree
`../pmacs-dired-stage1`, based on `githubsucks/main` @ `8c86d34` (the
framing merge #164). **A fresh cut, not a rebase:** the older `dired`
branch (`ffdd642`, worktree `../pmacs-dired-arc`) was based on the
superseded `0827dd1` and carried only the framing content #164 already
put on `main`, so merging it would have reconciled two histories of one
document. It is left untouched and carries nothing unmerged.
- **Stage 1 implemented; no wire change (protocol stays v20).** What
landed on the branch:
- `builtin/runtime/dired.lua`: one buffer per directory named
`*dired:<canonical path>*` with the handle-table ownership check;
read-only intercept + `set_round_trip_input`; the `dired` major mode
and its mode-scoped keymap (`RET`/`f`, `^`, `n`/`p`, `g`, `q`, `s`);
basename cursor re-seating across every wholesale repaint;
`display_file` for file visits and same-window reuse for directory
descent; `C-x d` / `C-x C-j`; the `dired.kill-when-opening` setting.
Loaded after `window.lua`.
- `src/fs.rs`: `ReadDirTolerance`, `FsDirEntryError`, `FsDirListing`,
and one walk that either fails on a per-entry condition or records it
(Q#DR6). `src/async_runtime.rs` carries the listing in
`ReplyKind::ReadDir` / `JobResult::ReadDir`; `src/lua_bindings/mod.rs`
keys the Lua result **shape** on `errors.is_some()`, so the bare array
the frozen M8.2 fixture consumes with `ipairs` is untouched;
`builtin/runtime/fs.lua` validates read-op opts and **rejects unknown
keys** (a typo'd `tolerant` used to degrade silently to fatal).
- `src/editor_core.rs` + `src/lua_bindings/mod.rs`:
`normalize_buffer_path` is `pub` and exposed as
`pmacs.path.canonicalize` — Q#DR2's preferred end state, so no Lua
mirror exists and Stage 2 owes no mirror removal. This makes B2
("tolerant `read_dir` is the only Rust change") false by one small
binding, deliberately.
- `tests/dired_acceptance.rs`: 22 tests over framing items 116,
dispatch-driven; item 17 is the m8_1/m8_2/m8_3 additivity gate.
- **The framing claim the substrate falsified (S1-2):** R2-3 expected a
dedicated dired panel to carry its dedication across a descent.
`display_buffer` never replaces the buffer in a slot dedicated to
another one — it discards every side-specific parameter and falls back
to the document window (Q#BP3 2.iii), and the exact-window arm errors.
Dired does not unpin the user's panel; both arms are pinned.
- **The vacuity the bites found (S1-3):** acceptance 3c cannot pin the
descent *routing*. Dired holds focus in its own panel, so a raw
`switch_buffer` lands in the same window and every 3c assertion holds
either way. Dedication is the only discriminator, so the
dedicated-panel test is the real pin — and the vacuity is documented at
the assertion rather than relabelled.
- **The pre-existing test dired's first mode-scoped binding broke
(S1-4):** `describe_key_identifies_every_default_binding` asserted every
binding in the stack resolves through `describe.key` context-free, which
held only while the modes table was empty. It now sets the effective
context per binding and explicitly *clears* the mode for global ones,
because a leaked mode legitimately shadows a global chord of the same
name (dired's `RET` shadows `edit.newline-and-indent`).
- Durable substrate facts, independent of this arc:
- `pmacs.buffer.kill` (not `remove`) redirects windows off a doomed
buffer before removal, so `kill-when-opening` kills **after** the
replacement is displayed.
- Interactive origin does **not** survive an await: work resumed in
`tick_async` sees no `InteractiveCommandOrigin`, so `pmacs.window.*`
acts for the *ambient* active frontend (S1-9).
- Kinds are lstat-based in both `read_dir` and `stat`, so nothing in an
entry says whether a symlink points at a directory; `RET` probes by
trying to list it (S1-8).
- A path-backed buffer's *name* is its full path, not its basename —
worth knowing before writing any name assertion.
- `C-x d` takes **no** completion source on purpose (S1-5): with one,
RET on an empty field opens whatever sorts first, and
RET-on-where-you-are is the gesture the binding exists for. The field
is prefilled instead.
- **Bite verification:** 15 claims, each mutated in place and required to
fail the test that names it. `dired.lua` is new, so `scripts/bite`'s
file swap does not apply; every mutation was applied and reverted with
`git checkout --`. One came back VACUOUS and is recorded above.
- **Review round 1 addressed** (framing rev 7, S1-10…S1-12). Three
behavioral fixes, each bite-verified: `dired.revert`'s re-seat is
guarded on the active buffer (an ambient `move_to_line` after an await
moved an unrelated buffer's cursor — the buffer-level instance of
S1-9); `fmt_size` keeps the column width past ten digits, because
`_layout` is a contract Stage 3 is planned against; and the symlink
descent dropped its probe, since `open_directory`'s
changed-nothing-on-failure invariant *is* the probe (it was listing the
target directory twice). Plus a consecutive-`readdir`-error cap, because
**nothing cancels a dired listing** — it carries no supersede key, so
cancellation was never the backstop the tolerant loop implicitly relied
on. Naming/comment findings taken as-is.
- Durable process lesson, hit twice now: a mutation-bite helper restores
with `git checkout --`, which reverts to **HEAD** — so a fix must be
committed *before* it is bitten. Round 1's fixes were briefly wiped by
exactly that.
- **Canonical main integrated twice** — at `46a1b8f` (multi-root LSP
affinity #161) and again at `b889873` (GPU terminal input #166), both
merged rather than rebased per the #135/#137 precedent so the review
anchors stay addressable. Each conflict was a single doc hunk resolved
as the union: this lane owns COHERENCE's journey step 7 file half, #161
owns the in-flight list, #166 owns step 8's GPU-terminal addendum.
Three things worth carrying:
- **A conflicting PR silently stops running CI.** GitHub builds
`pull_request` runs against the merge ref, which does not exist while
the PR conflicts, so no run is created and nothing reports a
failure — the checks list simply stays as it was. Three pushes to
this branch produced no CI at all before the cause was found. Watch
`mergeable` on a long-lived lane, not just the check list.
- #161's own COHERENCE finding **falsified a claim in this lane's
module doc**: `pmacs.error` is never defined in production, so an
uncaught raise inside a `pmacs.async` coroutine does not reach
`*errors*` as the comment said. It reaches a bare `error()` inside
`pmacs._async.tick()`, whose result `tick_async` discards with
`let _ =` — i.e. nowhere. That makes dired's per-coroutine `pcall` +
`set_status` load-bearing rather than tidy, and the comment now says
so.
- **A lane in review against a fast-moving `main` needs its gates rerun
per integration, not per push.** Main advanced twice inside this
review round, and the second time landed while the first
integration's sweep was still running. The numbers below describe the
twice-merged tree.
- Verification on the twice-merged tree (`main` @ `b889873`):
`cargo fmt --check` clean; strict workspace Clippy clean; **1,832
default + 2,009 CRDT** library tests; dired acceptance **25 default +
25 CRDT**; m8_1 10 / m8_2 15 / m8_3 32 unchanged; multi-root 13 and
vterm Stage 3 5 (both suites main added, green under this lane's
`mod.rs` and `editor.rs` changes); M4 121; required GPU 155;
**isolated-`XDG_CONFIG_HOME` workspace sweep 3,205 passed across 93
suites, zero failures**; `git diff --check` clean. The sweep needs the
isolated config for the reason recorded in the bottom-panel lane
below.
- Coherence (framing §0.5, required since #163): serves `COHERENCE.md` §20
Priority 1, which names this work explicitly; journey step 7's file half
goes from no surface to a surface; **adds no interaction island** — keys
are a mode-scoped keymap, and wdired will be a mode swap; adopts
`pmacs.config` for `dired.kill-when-opening`; inherits §9's
worker-attribution gap for its `read_dir` jobs without worsening it. The
audited claims this changes are updated in `COHERENCE.md` itself, per its
§25.
- **Boundary with the Journey Stage 1 arc** (`COHERENCE.md` §20 arc-cut
1): CLI directory-argument handling (`pmacs .` exits 1) belongs there,
not here. The two meet at `resolve_target_buffer`; dired supplies the
buffer a directory should resolve *to*, and `pmacs .` should route into
it rather than growing a second directory surface.
not here — Stage 1 does **not** fix it. The two meet at
`resolve_target_buffer`; dired supplies the buffer a directory should
resolve *to*, and `pmacs .` should route into it rather than growing a
second directory surface.
## The CRDT half of the test corpus is dark in CI — NEEDS A LANE
@ -268,6 +378,33 @@ If it does not, stop and repair the remote/fetch configuration.
`a37`, the Vterm Stage 3 real-daemon/real-PTY/real-wgpu path that #135
built specifically because "a decoded-message fixture would prove none of
the three fit together".
- **`a37` will report green in the new job without running, unless the
job builds `pmacs-gpu` AND sets `PMACS_REQUIRE_GPU=1`.** Measured
2026-07-26 while gating #173. `a37_real_daemon_real_pty_and_headless_gpu_
render_one_terminal_session` derives its sibling binary path from
`CARGO_BIN_EXE_pmacs`, and on a missing binary it `eprintln!`s a skip and
**returns `ok`**. A fresh worktree running
`cargo test --features crdt --test vterm_stage3_acceptance` reports **9/9
in 0.17 s having never run it**; a real run takes ~4 s. Only
`PMACS_REQUIRE_GPU=1` promotes that skip to a failure, and `CLAUDE.md`
applies that flag to `cargo test -p pmacs-gpu` — a **different package**,
so the required local gate does not cover a37 either. The `gpu-render`
job already sets the flag, which is what makes fix-shape part 2 sound;
state it as a **requirement** of that job rather than inheriting it by
luck, because a `crdt` leg added to the plain `test` job would run a37
vacuously.
- **`a37` is also load-sensitive, which changes how to read the expected
first-run failures.** It passed at `d152120` and failed at that *same
commit* twenty minutes later, with a second agent saturating the machine
with `rustc` in between; it then failed identically on `d152120`,
`04c5ad1`, and the #173 merge commit, which is how #173 established the
failure was not its own. The signature is `last_frame_text` all spaces
with `rendered_nonuniform_frames` nonzero — frames arrive, content does
not. `pmacs-gpu`'s own suite flaked the same way under the same load
(201/202, then 202/202 on immediate rerun). **So a red a37 on the first
CI run is ambiguous by construction**: before treating it as a real
failure, run the same command on the merge base, and prefer serialized
execution for this suite over retry-until-green.
- **Sort deliberate from accidental before proposing a fix.** Some of the 264
are perf suites that are `#[ignore]`d by default and belong to their own
jobs (`m10_2_perf` 6, `m10_11_perf` 1). `m10_10_perf` has **no** `#[ignore]`
@ -290,153 +427,158 @@ If it does not, stop and repair the remote/fetch configuration.
**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.
## Bottom-panel lane (window placement + side windows) — Stage 1 IN REVIEW
## Terminal config + copy mode arc — Stage 1 MERGED; Stage 2 is next
- Portable branch: `githubsucks/bottom-panel`, worktree
`../pmacs-bottom-panel`, based on `githubsucks/main` @ `ddaa80d`.
- Approved framing: `docs/bottom-panel-framing.md` revision 4, committed
as the branch's first commit (`c27f75a`).
- **Stage 1 implemented; no wire change (protocol stays v20).** What
landed on the branch:
- `src/window.rs`: `WindowParams` (`side` / `fixed_rows` / `dedicated`
+ implementation-owned `quit_action` and `origin_document`), `Side`,
a depth-bounded `QuitAction`, `MIN_WINDOW_OUTER_ROWS = 2`,
`Layout::compute(area, fixed)`, the `subtree_min_rows` /
`interactive_min_rows` recursions, `boundary_below`, and the three
new `FrontendView` fields (`panel_capable`, `frame_geometry`,
`panel_hidden`).
- `src/editor_core.rs`: `primary_document_window`, the non-side target
rule, `display_buffer` + the Q#BP3 placement policy, `quit_window`,
`reconcile_panel_layout_core`, `resize_boundary`, per-frontend
`JumpEntry`s, and the shared `resolve_target_buffer` seam that the
#148 initial-target bootstrap now routes through as well.
- `src/editor.rs`: the reconciliation transaction, geometry
declaration, the side-window `dispatch_idle_for` gate, the divider
paint, and the divider drag.
- `src/lua_bindings/window_panel.rs`: the whole `pmacs.window` panel
surface plus the shared adopter-placement helpers;
`builtin/runtime/window.lua` owns `window.panel-height` /
`window.min-height` and the resize commands.
- Adopters: `listview.open`, `compile.run`, `pmacs.terminal.open` all
take `display = "current" | "panel"` (Stage 1 default `"current"`);
LSP/compile visits route through `display_file`.
- **Review round 1 addressed.** The load-bearing finding: the Q#BP6
side-window split guard (`try_split_active`) had **no production
caller** — `pmacs.window.split_horizontal` / `split_vertical`, and so
`C-x 2` / `C-x 3`, still went through plain `split_active`. Splitting a
focused panel made the root wrapper's final child a split rather than
`Leaf(side)`, which both `Layout::compute`'s fixed pass and
`document_subtree` key on. It survived the first round because the
acceptance test called the core method **directly**; it now goes
through the real Lua binding. This is the folding-arc round-2 lesson
repeating exactly: *after wiring a guard into a production hook, pin it
through the real path — a direct-call test misses the wiring.*
Also fixed: the armed divider drag was not scoped to its arming
frontend (it could cancel and swallow a peer's mouse events); a
recompile carries no `display` and duplicated a panel-placed
`*compilation*` into the document window; and
`paint_mode_line_graphemes` had lost its doc block to an insertion.
Five bite-verified fixes (three via `scripts/bite`, two by manual
revert since their tests share `src/daemon.rs` with the production
code).
- Two Stage-2 hazard pins now exist in `src/daemon.rs`, closing the gap
the review named: a fresh attach while `LOCAL` is focused in a panel
inherits `LOCAL`'s **document** buffer, and an initial-target bootstrap
whose `after-load` hook creates and selects a panel still reasserts
into a document window.
- **Review round 2 addressed.** The load-bearing finding: **Q#BP7 item 1
— "growth reaching the live tail re-arms follow" — was never
implemented.** `at_bottom` is the instantaneous geometric readout
`scroll_offset == 0`, which a still-anchored view satisfies whenever it
is momentarily tall enough to reach the tail, so the round-1 assertion
could not see the gap: the next rows the child printed pushed the
anchored view back into history. `src/terminal/view.rs` now has
`rearm_follow_on_growth`, reached by one shared `declare_view_size`
helper from every size-declaring path (`snapshot_for_view`,
`record_view_size`, `view_status_for_size`) so grid and semantic
declarations cannot disagree.
Also fixed: the PTY fixtures emitted LF-only output, which staircases
until every row clips to blanks — so the anchor assertions compared
`""` with `""` and could not fail (now CRLF, each guarded by
`assert!(!top_before.is_empty())`); acc33's contrast case asserted
nothing; `start_run` let `already_in_panel` override an **explicit**
`display = "current"`, which is the documented opt-out from the Stage 3
flip (now gated on omission); and `window_drag` was a daemon-global
slot that a peer's mode-line press could clear.
- Durable test lessons from this round, both the same class:
1. **A geometric readout is not a state predicate.** `at_bottom` says
"the viewport currently reaches the tail", not "this view follows
the tail". Pinning follow requires feeding MORE output and asserting
the view moved (acc32b uses a filesystem gate between two bursts).
2. **A PTY in the default mode does not translate LF to CRLF.** An
`echo`-driven fixture staircases rightward and clips to blanks past
the viewport width, so any text equality over it is vacuously true.
Emit `\r\n`, and guard text comparisons with a non-empty assertion
the way the daemon pin guards on `!panel_hidden`.
- **Round-2 self-review caught a regression the round-2 commit
introduced**, in the change it labelled "minor": routing
`pmacs.window.buffer()`'s **no-argument** arm through the fid-scoped
`selected_window` validator made it **fallible**, and
`acting_frontend` can name a frontend with **no registered view** (a
bare `dispatch_key` from an unattached peer does exactly that). The
runtime calls that function on ordinary edits from `killring`,
`syntax`, `autosave`, `pair`, `indent` and `comment` **without
`pcall`**, so the raise never surfaced as an error — it silently
dropped the operation. `kill_ring_acceptance` went 30/30 → 25/5
(`frontend_detached_drops_per_frontend_state`: "B has kill state").
The no-arg arm is back on ambient `active_buffer_id()` and documented
as deliberately infallible; the explicit-window arm keeps its Q#BP11
validation. New **acc19c** pins it through the real path (a
`buffer.after-edit` subscriber during a viewless peer's `dispatch_key`)
and bites against the regressing commit.
Generalizes: **a "uniformity" cleanup that changes a function's
fallibility is not minor** — check every caller's error discipline
first, and remember that an ambient resolver's fallback IS its
contract.
- Verification on this branch: `cargo fmt --check` clean; strict
workspace Clippy clean; 1,817 default + 1,994 CRDT library tests;
`bottom_panel_stage1_acceptance` 46/46; kill ring 30 default + 30 CRDT;
vterm Stage 1 9 default + 10 CRDT; M4 121; required GPU 152;
compile 67; vterm Stage 2 4 / Stage 3 5 (7 CRDT); folding Stage 2 48;
statusline 7; listview 6;
**isolated-config workspace sweep 3,130 passed across 89 suites, zero
failures**; `git diff --check` clean.
- **Run the sweep with an isolated `XDG_CONFIG_HOME`.** The real
`~/.config/pmacs/init.lua` on this desktop calls
`pmacs.packages.install_local(...)`, so every editor the sweep builds
races on one shared install root; a losing race sets a status message
that leaks into the mode line and breaks
`folding_stage2_acceptance::unfolded_frame_is_identical_to_the_pre_folding_baseline`,
which compares whole painted frames. Standalone it is 48/48. This
generalizes the known `compile_mode_acceptance` real-config trap:
any suite that paints the status area inherits it.
- **A latent pre-existing `main` bug surfaced while gating and is NOT
this branch's**: `buffer::tests::proptests::rope_matches_crdt_projection_after_arbitrary_edits`
fails on `main` @ `352bf0b` with `ops = [Insert(0,"a"),
Insert(0,"aaa"), Replace(0,1,"a"), Undo]` — undo of a textually-null
`Replace` returns a no-op edit result still carrying `crdt_op =
Some`, violating the suite's own shape invariant. `src/buffer.rs` is
byte-identical here, and the seed was deliberately **not** committed
(it would make an unrelated failure deterministically red on this
PR). Needs its own lane.
- Durable test lesson from this round: `TerminalViewStatus.scroll_offset`
is documented as the retained rows between **this viewport** and the
live tail, so it necessarily tracks the viewport height. Asserting it
constant across a panel height change is either vacuous or wrong —
the invariant Q#BP7 actually states is that the **anchor** is frozen,
which the acceptance now pins by comparing the first visible row's
text, plus `at_bottom` for the follow re-arm.
- `compile_mode_acceptance` needs `--test-threads=1` locally; it is
67/67 there. Under default parallelism it fails roughly 1 run in 3,
with a *different* test each time (acc14/acc25a, then acc24) —
**verified pre-existing** by swapping in `githubsucks/main`'s
`builtin/runtime/compile.lua` and reproducing the same rate. The
`pmacs-gpu` bin tests have historically gone red under a loaded sweep
(wgpu device contention). Rerun isolated before treating either as a
regression.
- Stage 2 (the GPU panel band, next available protocol version) has its
own re-framing obligation before implementation; Stage 3 is the default
placement flip.
- 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 = `terminal-copy-mode`, not started.** Branch it off `main`
after Stage 1 merges: no dependency, but both edit
`builtin/runtime/terminal.lua`.
- 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.
## Bottom-panel lane (Arc 7) — Stage 1 MERGED; Stage 2 IN FRAMING
Stage 1 is on `main`. **Stage 2 is in framing**, no implementation in
flight.
- Stage 1 merged as **#155** (`main` @ `e745068`, 2026-07-24, after two
review rounds). No protocol change. Durable substrate facts live in
`docs/agent-handoff.md` §1; the two round lessons are in §5.
- Landed-docs follow-up merged as **#156** (`main` @ `d152120`,
2026-07-25).
- **Stage 2 framing: `docs/bottom-panel-stage2-framing.md` revision 4**,
on branch `githubsucks/bottom-panel-stage2-framing` (three commits,
one per 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
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:
**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**
(`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.
- **Correction — this entry previously mis-stated the census contract.**
It is **not** "route every consumer through `primary_document_window`".
Q#BP14 classifies the 23 reads into four classes and routes only the
**Projection** class that way; focus/input (#13#15, #23), focus chrome
and surface-routed (#16#19), and focus/session (#20) keep their own
authorities. Rerouting them would break remote-op validation and
application, `DispatchIdle`, presence, focused search/menu/completion
routing, and terminal bell ownership. The Stage 2 framing carries the
full table.
- **The GPU document bottom is three boundaries, not one.**
`text_area_bottom` (`pmacs-gpu/src/main.rs:8490`) is today
`status_band_top`, `geometry_capacity_bottom`, and
`document_text_bottom` at once. Once a band is installed they diverge:
the status chrome must stay pixel-identical at the physical window
bottom while document consumers move. A blanket rewrite of that helper
moves both together and passes an "everything moved" assertion, so the
Stage 2 criterion asserts **both directions in one scenario**. The
census is 20 production sites (8 status-owned, 12 document-owned) + 1
definition + 8 test sites = 29 matches; the framing carries the
per-site table. The three easiest to misclassify are document
completion `:6140`, minibuffer candidates `:7351`, and edge scrolling
`:8561` — each with its own visible symptom.
- **Folding Stage 3 and this arc's Stage 2 both touch the semantic
projection.** Whichever is framed second re-scouts the other's landed
state.
## Folding lane (Arc 6) — Stages 1 and 2 MERGED; Stage 3 (GPU) is next
@ -511,6 +653,73 @@ 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.
- **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
a real paper after the landing. What is worth carrying forward:
- **The v0 subset is 34 Greek symbols, sub/superscript, and `\frac`.**
An unsupported command fails the **whole span** back to source, so on
a real document most inline spans still show LaTeX. Widening the
symbol map is the highest-value next increment — ahead of display
math, which is also deferred.
- **A stale frontend binary is invisible from the source tree.** The
slice lives only in `pmacs-gpu`, so after it merged the feature was
absent until `cargo build --release -p pmacs-gpu` and a client
restart; the daemon needs neither. Diagnose with `strings` on the
binary (`Latin Modern Math`, `MathBox`) rather than by re-reading the
checkout, which was already current.
- **Main was integrated three times in one day** (`8c86d34`,
`46a1b8f`, `b889873`), merged not rebased to preserve review anchors.
Two conflicts, both this ledger and nothing else. **The dangerous
case was the one that did NOT conflict**: #166 auto-merged into
`pmacs-gpu/src/main.rs`, the file this lane rewrites, because the two
edits sat in different regions of it. Decide whether to integrate
from the shared-**file** set, never from whether git complained.
- **Integration was proved by test-count reconciliation**, not by a
green run: predict what the other side adds, then check the deltas.
GPU 199→202 matched `e547a90`'s 3; later lib 1,826→1,829 and CRDT
2,003→2,006 matched #166's 3, with GPU unchanged because #166 adds
none. Suites 91→92 was #161's new binary.
- **Why the branch had no CI for a day**: a conflicting PR builds no
merge ref, so no `pull_request` run is created. The ledger previously
recorded this cause as unidentified; it is not. Check `mergeable` and
confirm a run exists for the current head SHA.
- **`m4_5_basedpyright` has no timeout and hangs forever**, parking a
`--workspace` sweep (observed 2h26m at 38 of 92 suites). It is
**intermittent**, so an earlier clean sweep proves nothing. Sweep with
`cargo test --workspace --no-fail-fast -- --skip basedpyright` and
judge progress by whether the suite count advances.
- Named v0 approximations: the peer-caret half of acceptance 14 is
pinned at the mapping level, not pixels; a soft-wrapped spacer draws
its box whole at the first run's origin; the fit budget reads the
bundled code face even under a custom `set_font` family.
- **Bottom panel Stage 1 — MERGED as #155** (`main` @ `e745068`,
2026-07-24, after two review rounds). Window placement, window
parameters, TUI side windows, the divider, and the adopter `display`
opt-in, with no protocol change. Both rounds found the same class of
defect and are worth keeping:
- **Round 1**: the Q#BP6 side-window split guard had *no production
caller* — `C-x 2` still reached plain `split_active` — and survived
because the acceptance test called the core method directly.
- **Round 2**: Q#BP7's terminal growth re-arm had *never been
implemented*, and the assertion meant to pin it (`at_bottom`) is a
geometric readout that a still-anchored view satisfies; the anchor
assertions beside it compared `""` with `""` because the PTY fixture
emitted LF-only output.
- **Post-round-2 self-review**, caught by CI going red on all four Test
jobs: resolving `pmacs.window.buffer()`'s no-argument arm through the
acting frontend made a total function partial, and six runtime modules
silently dropped operations (`kill_ring_acceptance` 30/30 → 25/5).
Fixed in `9110f9f` before merge.
- Gating fact found on the way: **the workspace sweep must run with an
isolated `XDG_CONFIG_HOME`**, because the real user `init.lua`
installs a local package and the losing race leaks a status message
into painted-frame comparisons. There is also a latent pre-existing
`main` bug in the buffer CRDT undo path, unrelated to this arc.
- `compile_mode_acceptance` is load-sensitive under default
parallelism (~1 run in 3, a different test each time); verified
pre-existing by swapping in `main`'s `compile.lua`. It is 67/67 at
`--test-threads=1`.
- **GPU initial target — MERGED as #148** (`main` @ `0dd16a5`, 2026-07-24,
after two review rounds). `pmacs --gpu [--socket …] FILE` opens a target

View File

@ -1,10 +1,12 @@
# Agent handoff — cross-machine continuity
**Last updated: 2026-07-25, after the GPU terminal input fix (#166) landed —
the double terminal-layout sync that made a GPU terminal untypable — following
Lean 4 Stage 2 (#161), the dired framing pair (#163/#164), find-file (#162) —
the dired
arc's Stage 0 — following COHERENCE.md (#163), Lean 4 Stage 1 (#160), the
**Last updated: 2026-07-26, after terminal configuration Stage 1 (#173)
landed — profiles, scrollback, a per-terminal configurable escape key, and
the `C-c t` opening binding — following the GPU terminal input fix (#166),
the double terminal-layout sync that made a GPU terminal untypable; the
inline-math slice (#158), the first mathematical typesetting in pmacs; Lean 4
Stage 2 (#161), the dired framing pair (#163/#164), find-file (#162) — the
dired arc's Stage 0 — COHERENCE.md (#163), Lean 4 Stage 1 (#160), the
minimap blank-slab fix (#159), bottom-panel Stage 1 (#155), the
inline-math re-scout (#154), the vterm PTY-flake fix (#153), and the
GPU initial-target doc refresh (#152); and before that GPU
@ -29,11 +31,13 @@ commands, read `docs/active-work.md` immediately after this file.
## 1. Where the project stands (2026-07-25)
- `main` @ `2af1ab3` (find-file #162 atop COHERENCE.md #163, Lean 4 Stage 1
#160, minimap blank-slab #159, bottom-panel Stage 1 #155, inline-math
re-scout #154, vterm PTY-flake #153, and doc refresh #152). Protocol
unchanged at **v20**. The bullets below describe the arcs in their own
terms; this line is the head-of-`main` anchor.
- `main` @ `d152120` (the bottom-panel landed-doc refresh #156 atop the
inline-math slice #158, dired Stage 1 #165, the GPU terminal input fix
#166, Lean 4 Stage 2 #161, the dired framing #164, COHERENCE.md #163,
find-file #162, Lean 4 Stage 1 #160, minimap blank-slab #159,
bottom-panel Stage 1 #155). Protocol unchanged at **v20**. The bullets
below describe the arcs in their own terms; this line is the
head-of-`main` anchor.
- **`COHERENCE.md` is now required reading and a required framing input
#163.** It carries the product-coherence thesis, an audited
scorecard, per-concern gaps, and §20's priority order, and it is the
@ -42,6 +46,32 @@ commands, read `docs/active-work.md` immediately after this file.
interaction islands added, config-registry adoption, background-work
attribution. Its §2 grades the golden journey **broken at step 3**
(`pmacs .` exits 1).
- **Inline math LANDED — #158** (`docs/inline-math-slice-framing.md` rev 3;
merge `5aa9044`). pmacs renders `$…$` as typeset mathematics in the GPU
frontend. **No protocol change (still v20); the whole slice lives in
`pmacs-gpu`**, because `pmacs-gpu` depends only on `pmacs-protocol` and
never on `pmacs` — a core-crate parser would have been unreachable from
where rendering happens.
- `math_parse.rs``math_layout.rs` → a `ChunkSource::MathBox` spacer
chunk → per-glyph mini-buffers drawn at the shaped line's real
baseline, with fraction rules as quads. Font is bundled Latin Modern
Math (~717 KiB) under the **GUST Font License** — not OFL.
- **The v0 subset is narrow and deliberately so**: Greek (34 entries),
sub/superscript, and `\frac`. Everything else — including relations
like `\geq`, fences, big operators, and all display math (`$$…$$`,
`\[…\]`) — is a named deferral, and an unsupported command degrades
the **whole span** back to source rather than rendering partially.
In a real paper most inline spans still show source; that is the
designed behaviour, not a defect.
- **Math is suppressed while the caret is inside its span**, so editing
always sees source. That gate reads the effective caret plus
selection endpoints and is fed by three separate refresh triggers;
it is the most delicate part of the slice.
- Selection and search washes cover the whole box rectangle, not
sub-ranges (sub-range washes are deferred).
- TUI shows the LaTeX source unchanged. That divergence is recorded
against `COHERENCE.md` §16, which audits the "no privileged
frontend" rule.
- **find-file LANDED — #162** (`docs/dired-framing.md` §10, Q#DR11; merge
`2af1ab3`; one review round). `C-x C-f` is the dired arc's **Stage 0**:
pmacs previously had no discoverable way to open a file by path — no
@ -73,14 +103,92 @@ commands, read `docs/active-work.md` immediately after this file.
against an open buffer yet fails to load one that is not open —
find-file expands the tilde Lua-side. Loading through the normalized
path is a named deferral.
- **GPU initial target LANDED — #148**
(`docs/gpu-initial-target-framing.md` rev 3; merge `0dd16a5`; two review
rounds). `pmacs --gpu [--socket NAME|PATH] FILE` transports exact Unix path
refresh #150, folding Stage 2 #149, ledger refresh #147, web grammars #146,
LaTeX Stage 1 #144 / inline-math framing #145, and folding Stage 1 #142),
protocol **v20** (`SUPPORTED=[6..=20]`; v16 = `ThemeFacts`, v17 =
- **Stage 1 (the directory view) is IN REVIEW as PR #165** — the
builtin `dired.lua`, the per-entry-tolerant `read_dir` opt, and
`pmacs.path.canonicalize`. Its branch state, substrate facts, and
verification live in `docs/active-work.md`; this section absorbs them
when it merges.
- Protocol **v20** (`SUPPORTED=[6..=20]`; v16 = `ThemeFacts`, v17 =
`FontFacts`, v18 = `StatuslineSegments`, v19 = terminal frames/events, v20 =
the GPU initial-target semantic bootstrap family).
- **Bottom panel Stage 1 (window placement + TUI side windows) LANDED —
#155** (`docs/bottom-panel-framing.md` rev 4; merge `e745068`; two review
rounds). **No protocol change (still v20).** Arc 7's substrate: pmacs now
has Emacs's `display-buffer` + window parameters, and a buffer can be
displayed in a fixed-height window pinned to the bottom of the frame that
feature code targets **by policy** instead of by stealing the selected
window.
- `src/window.rs`: `WindowParams { side, fixed_rows, dedicated }` plus
implementation-owned `quit_action` / `origin_document` (Lua reads them,
`set_params` refuses them); `MIN_WINDOW_OUTER_ROWS = 2`;
`Layout::compute(area, fixed)` subtracts fixed children before dividing
the remainder by weight, preserving last-flexible-takes-the-remainder, so
a tree with no fixed leaves computes byte-identically to before.
- **`Layout::compute` has TWO production callers**, and both must feed the
same shared `panel_fixed_rows` map: `window_placements` and
`src/overlay_paint.rs`'s peer-presence pass, which derives its own
text-area `Rect` and never routes through the first. Leaving the second
on unfixed geometry paints every peer cursor at the row it would occupy
with no panel open.
- **The minimum is recursive** (`subtree_min_rows`: horizontal splits sum,
vertical splits max). "Two rows at the root" does not give each nested
leaf two rows. `interactive_min_rows` is the same recursion over the
user's `window.min-height` preference, and applies to drag/keyboard
resize ONLY — the layout pass and frame-resize reconciliation use the
structural floor, so changing a preference can never invalidate an
existing layout.
- **Hiding a panel is a durable state transition, not a per-frame effect**
(`EditorState::reconcile_panel_layout`): it moves focus out and releases
the terminal controller, because the terminal resize path merely returns
on zero content without releasing. It runs after attach/resize/display/
close and defensively before input dispatch, terminal sync, and paint.
- `FrontendView` gains `panel_capable`, `frame_geometry`
(`None` = **unknown**, never the GPU attach request's permanent 24×80
placeholder), and derived `panel_hidden` — each spelled explicitly at
every construction site, preserving folding's non-`Default` discipline.
- `EditorCore::primary_document_window` is the Q#BP14 projection seam;
`display_buffer` is Phase 1 of the display transaction (exact target →
side affinity → ordinary reuse, with option-valued height/dedication);
the Lua layer owns Phase 2 (activate → hook → reconcile → revalidate →
final-focus matrix).
- **Optimistic input is gated per WINDOW, not per buffer**:
`dispatch_idle_for` returns `false` whenever the acting frontend's active
window is a side window. Marking the panel's BUFFER round-trip would be
wrong — `round_trip_buffers` is global by `BufferId`, so it would disable
optimistic apply for another frontend editing that buffer as its document.
- Jump entries are per frontend and carry their origin `WindowId`; a stale
**side** origin is SKIPPED, because degrading it to an active-window
switch is exactly the duplicate-panel corruption the arc removes.
- `pmacs.window.display / display_file / quit / panel / params /
set_params / resize / display_target`, plus `builtin/runtime/window.lua`
(`window.panel-height`, `window.min-height`, `C-x ^` / `C-x C-^`).
Adopters take `display = "current" | "panel"`; **Stage 1 default is
`"current"`** and Stage 3 flips it.
- The divider is the upper subtree's existing mode-line row — no row added
or consumed, `ui.divider` restyles every exposed segment of one boundary,
and drag state is `HashMap<FrontendId, _>` so frontends cannot steal each
other's gestures.
- `open_initial_target` now shares one `resolve_target_buffer` +
exact-window install seam with `display_file`, and reasserts into a
document window after hooks (a startup hook can now create a panel).
- Final gates: 1,817 default + 1,994 CRDT library tests; the new
`bottom_panel_stage1_acceptance` 46; kill ring 30; compile 67; M4 121;
required GPU 152; initial-target 14 CRDT; all three vterm suites; folding
Stage 2 48. All 12 CI checks green at merge.
- **Stage 2 (the GPU panel band) 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.
- **The §1.3 census is CLASSIFIED, not uniformly redirected.** Only the
Projection class (#1#12, #21#22) routes through
`primary_document_window`; focus/input (#13#15, #23), focus chrome
and surface-routed (#16#19), and focus/session (#20) keep their own
authorities. Rerouting them breaks remote-op validation and
application, `DispatchIdle`, presence, focused
search/menu/completion routing, and terminal bell ownership.
- **GPU initial target LANDED — #148**
(`docs/gpu-initial-target-framing.md` rev 3; merge `0dd16a5`; two review
rounds). `pmacs --gpu [--socket NAME|PATH] FILE` transports exact Unix path
@ -578,8 +686,14 @@ commands, read `docs/active-work.md` immediately after this file.
(GPU) is next**, unframed.
- **Web grammars HTML+CSS LANDED — #146**, and **LaTeX Stage 1 — #144**
with its inline-math parent framing **#145**.
- Remaining ranked arcs: 6 folding Stage 3, 7 DAP, 8 GPU splits, plus
the `.ipynb` arc (its JSON-grammar prerequisite shipped in #123).
- **Arc 7 (bottom panel) Stage 1 LANDED — #155** — window placement,
window parameters, TUI side windows, the divider, and the adopter
`display` opt-in. **Stage 2 (the GPU band) is next and needs its own
re-framing**; Stage 3 is the default flip. DAP was parked awaiting
exactly this arc's Stage 1 and can now re-baseline its touch census.
- Remaining ranked arcs: 6 folding Stage 3, 7 bottom-panel Stages 23,
DAP, 8 GPU splits, plus the `.ipynb` arc (its JSON-grammar
prerequisite shipped in #123).
- **GPU terminal input LANDED — #166** (`main` @ `b889873`;
`docs/gpu-terminal-input-framing.md` rev 2; one review round). The
@ -745,6 +859,25 @@ final variant — its own round-trip cannot detect a discriminant shift.
## 5. Hard-won ops lessons
- **A test that skips on a missing precondition reports `ok`, and a gate log
cannot tell that apart from a pass.** `vterm_stage3_acceptance::a37` — the
only acceptance driving a real daemon, a real PTY and a real wgpu render
together — derives `pmacs-gpu` from `CARGO_BIN_EXE_pmacs` and, when that
binary is absent from the target directory, prints a skip and returns.
A fresh worktree reports the suite 9/9 **in 0.17 s having never run it**;
a real run takes ~4 s. `PMACS_REQUIRE_GPU=1` is what promotes the skip to
a failure, and the standing gate list applies that flag to
`cargo test -p pmacs-gpu`, a *different package*. Two habits follow:
build the workspace before believing any suite that reaches for a sibling
binary, and **judge such a suite by its elapsed time**, not its verdict.
- **Before attributing a red test to your branch, run it on the merge base.**
`a37` failed on the #173 branch, which looked like a regression; it failed
identically on the PR's own base and on two intermediate commits, and had
*passed* on that same base twenty minutes earlier. The variable was machine
load from a second agent compiling continuously. Load-sensitive tests make
both verdicts uninformative in isolation, so the base-commit run is the
cheapest way to tell a regression from weather — and it is much cheaper
than the bisect it replaces.
- **A daemon-side fix is not deployed until the daemon is restarted from a
tree that contains it.** #166's reporter rebuilt and saw no change: the
running daemon had been started from a shared checkout still on a pre-fix
@ -893,6 +1026,44 @@ final variant — its own round-trip cannot detect a discriminant shift.
before any `FrontendEvent` touches render/size/editor state. Generalizes:
when a new failure path can leave a handle installed without its owning
session, dropping a handle is not the same as tearing down the connection.
- **A guard with no production caller passes every direct-call test.**
#155 round 1: `EditorCore::try_split_active` implemented the side-window
split refusal, but `pmacs.window.split_horizontal` / `split_vertical` — and
therefore `C-x 2` / `C-x 3` — still called plain `split_active`. The
acceptance test called the core method directly, so reverting the guard
entirely would have left every test green. Same shape as folding #142
round 2. Assert through the outermost user-reachable seam
(`try_exec(&s, "pmacs.window.split_horizontal()")`), then falsify by
revert. When the test shares a file with the code it pins, `scripts/bite`
cannot swap it — break the production line by hand and `git checkout --`.
- **A geometric readout is not a state predicate.**
`TerminalViewStatus::at_bottom` is defined as `scroll_offset == 0` — "the
viewport currently reaches the tail", not "this view follows the tail". A
still-anchored view satisfies it whenever it happens to be tall enough, so
asserting it could not detect that Q#BP7's growth re-arm had never been
implemented (#155 round 2): the next rows the child printed pushed the
anchored view back into history. Pinning *following* requires advancing the
world — feed more child output through a filesystem gate — and asserting the
view came along. Related: `scroll_offset` is viewport-relative, so
"unchanged across a height change" is vacuous or wrong; the invariant is
the frozen ANCHOR.
- **A PTY in the default mode does not translate LF to CRLF.** An
`echo`-driven test fixture staircases rightward, and past the viewport
width every row clips to blanks — so `assert_eq!(top_before, top_after)`
compares `"" == ""` and passes for any regression (#155 round 2). Emit
`printf '...\r\n'`, and guard text comparisons with
`assert!(!observed.is_empty())` the same way the panel daemon pin guards on
`!panel_hidden`.
- **Widening an ambient resolver into a scoped one can make a total function
partial.** #155 round 2 resolved both arms of `pmacs.window.buffer()`
through the acting frontend "for uniformity". `acting_frontend` follows the
interactive origin, which can name a frontend with no registered view (a
bare `dispatch_key` from an unattached peer), so the no-argument arm began
raising instead of answering. No runtime caller `pcall`s it, so killring,
syntax, autosave, pair, indent and comment silently dropped operations —
`kill_ring_acceptance` went 30/30 to 25/5 on every CI platform. The ambient
resolver's fallback is what makes it *total*; keep it, and document that as
deliberate. Uniformity is not free when the paths have different totality.
- **An upgrade decision must be tracked independently of the outcome that
triggered it.** #148 published a target's fresh `BufferSnapshot` to
existing grid replicas only when the buffer was `newly_loaded ||

View File

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

View File

@ -1,11 +1,14 @@
# Dired — framing
**Revision 5 — 2026-07-25. Status: APPROVED; Stage 0 MERGED as #162.**
**Revision 7 — 2026-07-25. Status: APPROVED; Stage 0 MERGED as #162;
Stage 1 IN REVIEW as PR #165, review round 1 addressed.**
Rev 1 passed a ground-truth review; rev 2 fixed round 1's seven findings;
rev 3 fixed round 2's six and was approved; rev 4 recorded what Stage 0's
implementation falsified in the approved text (§0); rev 5 adds the
**coherence impact** statement now required of every framing
(`CLAUDE.md`, `COHERENCE.md` §20) — see §0.5. Deliberately
(`CLAUDE.md`, `COHERENCE.md` §20) — see §0.5; rev 6 records what Stage
1's implementation falsified (§0, S1-1…S1-9); rev 7 adds what its first
review round found (§0, S1-10…S1-12). Deliberately
unnumbered: the roadmap's Arc 8 is GPU
structural parity but `docs/lean4-mode-framing.md` also claims Arc 8, so
the arc space is already forked in uncommitted work. (Rev 2 also cited
@ -203,6 +206,135 @@ the correction belongs here rather than only in the code.
same normalize-before-lookup family as Q#DR5's `apply_resource_op`
correction.
### Stage 1 implementation notes (rev 5 → rev 6)
Implementing Stage 1 (PR #165) falsified four things the approved text
asserted and settled five it left open. Recorded here rather than only
in the code, per the rev-4 precedent.
- **S1-1. The normalizer is EXPOSED, not mirrored — so B2 is partly
false, in the direction Q#DR2 preferred.** Q#DR2 made the mirror
conditional (`Stage 1 may still mirror if exposure turns out to drag
in EditorCore borrow plumbing it does not otherwise need`).
`normalize_buffer_path` is a **free function** (`editor_core.rs`), so
exposure drags in nothing: it is now `pub` and reachable as
`pmacs.path.canonicalize`. Consequences, all deliberate: B2 ("tolerant
`read_dir` is the only Rust change Stage 1 needs") is false by one
small binding; acceptance 3b degenerates to the round-trip form the
framing described; and the Stage 2 mirror-removal follow-up **is not
owed** — there is no second canonical form to remove. The parity
acceptance is still carried, now as "the Lua binding and the Rust
function agree over one shared edge list", which is exactly the claim
a future re-mirroring would break.
- **S1-2. R2-3's dedication claim is falsified by the substrate.** It
read "a dedicated dired panel stays dedicated across descent and the
new dired buffer inherits it". `display_buffer` never replaces the
buffer in a slot dedicated to another one: it discards every
side-specific parameter and falls back to the document window (Q#BP3
2.iii), and the exact-window arm errors outright. Dired therefore does
**not** try to unpin the user's panel — which is also what Emacs's
`display-buffer` does with a dedicated window. Acceptance 3c is split:
a non-dedicated panel keeps the descent, and a dedicated one keeps its
buffer *and* its pin while the new directory appears in the document
window.
- **S1-3. Acceptance 3c cannot pin the descent ROUTING, and the test now
says so.** Dired holds the focus in its own panel, so a raw
`switch_buffer` lands in that same window and every 3c assertion holds
either way — the mutation is *vacuous* against it. Dedication is the
only thing that distinguishes `display { side = … }` from the raw
switch, so the dedicated-panel test is the discriminating pin. Found
by running the bite rather than by reading the test; the vacuity is
documented at the assertion instead of being left to be believed.
- **S1-4. Dired is the first builtin to bind a mode-scoped key, and one
pre-existing lib test assumed none existed.**
`describe_key_identifies_every_default_binding` iterated *every*
binding in the stack and asserted `pmacs.describe.key` resolves it
context-free, which held only while the modes table was empty. It now
sets the effective context per binding — and explicitly *clears* the
mode for a global one, because a mode left over from a previous
iteration legitimately shadows a global chord of the same name
(dired's `RET` shadows `edit.newline-and-indent`, which is the point
of the mode).
- **S1-5. `C-x d` deliberately takes NO completion source.** It is the
direct consequence of S0-1/S0-4: with a `files` source, RET on an
empty field opens whatever sorts first (the minibuffer selects
candidate 0 whenever the list is non-empty, and a selected candidate
shadows typed text), and RET-on-the-directory-you-are-in is exactly
the gesture `C-x d` exists for. The field is **prefilled** with the
current directory instead — Emacs's own shape here — and free text
always reaches `on_accept` because `CompletionSource::None` bypasses
candidate resolution entirely. Directory-name completion is what dired
itself replaces.
- **S1-6. Ownership is the handle table ALONE**, narrower than Q#DR2's
"present in dired's handle table, or `major_mode(buf) == "dired"`". A
foreign buffer that carries the mode *is* the case the check exists to
refuse, and a builtin's handle table cannot be lost the way a
reloadable package's can. Acceptance 4 sets the mode on the foreign
buffer to pin the stronger reading.
- **S1-7. The mark column ships in Stage 1, rendered blank.** Q#DR4 is a
Stage 2 decision, but reserving the two columns now means Stage 2 does
not move every offset and Stage 3's column-classifying intercept can
be written against constants that did not shift under it. The
constants are computed from the widths (the fixture hardcoded
`NAME_START = 39` and paid for it in every wdired test) and exported
as `pmacs.dired._layout` so acceptance cannot drift from them.
- **S1-8. A symlinked directory needs a probe, because kinds are
lstat-based.** Both `read_dir` and `stat` report a link as
`"symlink"`, so nothing in the entry says whether it points at a
directory. `RET` on a symlink therefore *tries* to list the target
(one extra syscall, on symlink lines only) and descends if that
succeeds, else visits it as a file. Q#DR10 specified only the
dir/file arms; this is the third.
- **S1-9. Interactive origin does not survive the await.** Every listing
is worker-dispatched, so the work after the first `:await()` resumes
inside `tick_async`, where `InteractiveCommandOrigin` is empty and
`pmacs.window.*` falls back to the **ambient** active frontend. Single
frontend: correct. Multi-frontend: a dired opened from peer B while A
is ambient would display for A. Not fixable from Lua (the display
surface takes no frontend argument) and named here rather than
discovered later.
### Stage 1 review round 1 (rev 6 → rev 7)
Three findings changed behavior; the rest were naming and comments. Each
fix is bite-verified against the test that names it.
- **S1-10. An ambient re-seat is not safe after an await.** `dired.revert`
painted its own buffer by name (safe) and then re-seated through
`pmacs.editor.move_to_line`, which moves whatever window is
**active** — so a user who switched buffers while the re-read was in
flight had an unrelated buffer's cursor moved to a line index
meaningful only in the dired listing. This is the buffer-level instance
of the hazard S1-9 named at the frontend level, and it generalizes: in
this codebase, *painting takes a buffer and seating takes the world*.
Any post-await cursor operation needs an active-buffer guard;
`open_directory` is exempt only because it displays the buffer first.
- **S1-11. The rendered columns are a contract, so precision yields to
width.** `%10d` overflowed at 10 GB (VM images, core dumps), widening
the size field and shifting mtime and name right on that line alone.
Cosmetically harmless today, but `_layout` is exported and Stage 3's
column-classifying intercept is planned against it, so a
contract-violating line now is a Stage 3 trap. `fmt_size` took
`fmt_mtime`'s shape: exact bytes while they fit, else a fixed-width
magnitude. Not the deferred human-readable column (§13) — the exact
count still renders right up to the point where it cannot.
- **S1-12. `open_directory`'s "changed nothing on failure" invariant is
reusable as a PROBE.** S1-8's symlink descent originally listed the
target to learn its kind and then opened it — two full listings of the
same directory. Because a failed open touches no editor state
(acceptance 15), the open itself is the probe: try the descent, fall
back to `display_file`. One read. The comment that claimed "one
syscall" for a full `read_dir` is corrected rather than left as a
cost claim nobody would re-check.
Also, on the tolerant channel (Q#DR6): a `readdir` iterator may keep
yielding errors without terminating, and **cancellation is not a backstop
for a dired listing** — it carries no supersede key, so nothing cancels
it. A consecutive-error cap now fails the listing the way an unopenable
directory fails, rather than accumulating error rows on a worker thread.
It is deliberately untested: faking a failing iterator would need the
walk generic over it, a refactor with no other consumer.
## 0.5. Coherence impact (`COHERENCE.md` §20)
Required of every framing since #163. This arc was scouted and approved

View File

@ -237,7 +237,7 @@ candidates, ranked:
| Font | Table quality | License | Notes |
|------|--------------|---------|-------|
| **Latin Modern Math** | Full | OFL (GUST) | Reference, ships with TeX Live, most widely tested |
| **Latin Modern Math** | Full | **GUST Font License** (not OFL) | Reference, ships with TeX Live, most widely tested; ~717 KiB. Bundled by #158 as `pmacs-gpu/fonts/latinmodern-math.otf` |
| **STIX Two Math** | Full | OFL | Broader Unicode coverage |
| **Cambria Math** | Full | Proprietary | Ships with Office; unavailable on Linux |
| **Libertinus Math** | Full | OFL | Derivative of Latin Modern, wider |

View File

@ -0,0 +1,628 @@
# Inline math — the first vertical slice (framing)
**Revision 3 — pre-implementation, framing only. Ground truth scouted against
canonical `main` @ `352bf0b`, protocol v20, 2026-07-24. Rev 2 closed review
round 1 (F1F9); rev 3 closes round 2 (R2-1 R2-4).**
### Round 2 (rev 2 → rev 3)
Verdict: converging — one deletion, one real gap, two nits.
| # | Finding | Closed in |
| --- | --- | --- |
| R2-1 | The tree-sitter paragraph appeared **twice** in Q#MS3; the second was stale rev-1 text. Introduced by rev 2's own rewrite, which added a copy without removing the original | Q#MS3 |
| R2-2 | The F7 italic fix stopped at ASCII, so `$\alpha x$` drew an **upright α beside an italic 𝑥** — mixed styles inside one expression, and Greek is the slice's second flagship | Q#MS2, acceptance 13 |
| R2-3 | §9 sat between §6 and §7 | section order |
| R2-4 | Q#MS11 said a wash "covers" a span; a search match can **partially overlap** (`2$ af` in `before $x^2$ after`), which "covers" leaves unspecified | Q#MS11 |
Carried into acceptance from a round-2 non-finding: the Q#MS10 arithmetic puts
`\frac{a}{b}` near 0.85 and suggests `\frac{x^2}{y}` also clears the 0.6
floor, so criterion 12's fallback case must be **computed rather than guessed**
or it will surprise-pass by rendering.
### Round 1 (rev 1 → rev 2)
Verdict: the slice's shape survived, both load-bearing corrections held, and
nine findings landed — two of them decisions the implementation could not have
proceeded without, one a compliance error.
| # | Finding | Closed in |
| --- | --- | --- |
| F1 | The fraction height budget was never confronted; lines cannot grow (`BASE_CODE_LINE_HEIGHT = 22.0` fixed) and a textstyle fraction does not fit | Q#MS10 (new) |
| F2 | "Contributes no glyphs, reserves width" is a mechanism the chunk model does not have — a `RichChunk`'s only width is its `text` | Q#MS4, B1 |
| F3 | Criterion 10 required source-width boxes while Q#MS4 implied layout-chosen width; the contradiction *is* the caret-toggle reflow question | Q#MS4, acceptance 10 |
| F4 | Q#MS5 makes shaping depend on the caret — a new invalidation edge, and it must read the *effective* caret or flap during optimistic typing | Q#MS5 |
| F5 | Detection had no currency guard (`$5 and $6` pairs) and no newline rule | Q#MS3 |
| F6 | **Factual:** Latin Modern Math is GUST Font License and ~717 KiB, not OFL and ~200 KB | Q#MS7, §9 |
| F7 | Without a math-italic mapping, `$x^2$` renders an upright roman `x` | Q#MS2 |
| F8 | Layout still resolves glyph IDs internally; drawing must pin `Attrs` to the math family or measured and drawn advances diverge | Q#MS6, Q#MS7 |
| F9 | Smaller: "after shaping decisions" contradicts Q#MS4; no selection/wash rule; `$$…$$` degradation untested; `Char` vs `Symbol` unmotivated | Q#MS3, Q#MS2, Q#MS11 (new), acceptance |
Two rev-1 claims were **wrong, not merely imprecise**, and are called out
where they occur: the zero-glyph strut (F2) and the font licence (F6).
Parent arc: `docs/inline-math-framing.md` (rev 2, merged as #154). Sibling
substrate lane: `docs/latex-grammar-math-substrate-framing.md` (rev 3), whose
Stage 1 landed as #144.
This lane builds the **first end-to-end slice** of the parent's four-tier
pipeline: a deliberately small LaTeX-math subset that is detected, parsed,
laid out against a real OpenType MATH table, and **actually drawn on screen**.
## 0. Why a slice, and not "Tier 2 + Tier 3"
The obvious next unit was the parser (Tier 2) plus the layout engine
(Tier 3). It is rejected here for the parent arc's own reason.
The substrate lane's **Q#LX5** refused to land the parser ahead of layout
because *"the `MathNode` shape is only validated once [a layout consumer]
exists"*. That argument does not stop at Tier 2. `MathBox` is only validated
once a **renderer** consumes it: an unrendered layout engine can be
self-consistent and still have the wrong shape — wrong units, wrong origin
convention, a baseline the draw path cannot use. Landing Tiers 2+3 with no
Tier 4 reproduces exactly the objection Q#LX5 raised, one layer up.
So the unit of work is **thin and vertical, not broad and horizontal**: the
smallest grammar subset worth rendering, carried all the way to pixels. Every
layer acquires a real consumer immediately. Breadth — big operators, stretchy
fences, radicals, accents, display math — becomes follow-on work against an
API that has already been exercised rather than one that has only been
designed.
The cost is honest and named in §7: the slice touches
`pmacs-gpu/src/main.rs`'s render path, which two other arcs also want.
## 1. Ground truth (scouted 2026-07-24 @ `352bf0b`)
### 1.1 Crate boundaries — the parent's file placement cannot work
The parent framing's integration table lists `src/math_parse.rs` and
`src/math_layout.rs`, i.e. the **core `pmacs` crate**. Verified against the
tree, that placement is unusable:
- **`pmacs-gpu` depends only on `pmacs-protocol`** (`pmacs-gpu/Cargo.toml:60`;
there is no `pmacs` dependency). A parser in the core crate is therefore
**unreachable from the frontend that renders it**.
- **`ttf-parser` reaches only `pmacs-gpu`.** Per-crate check: `pmacs` no,
`pmacs-protocol` no, `pmacs-gpu` yes (via `fontdb``cosmic-text`
`glyphon`). A layout module in the core would be a genuinely new dependency
there, which is not what the parent's C1 established.
Both also contradict the parent's own prose — its design contract ("the
instance never learns a pixel") and its protocol section ("math rendering is a
pure frontend responsibility in v0"). The table was the outlier. Q#MS1 fixes
it.
### 1.2 The GPU text pipeline this slice hooks
- `rebuild_code_slice` (`pmacs-gpu/src/main.rs:6136`) shapes **only the
visible byte slice**; spans/decorations/adornments arrive in whole-file
coordinates and are clipped and rebased onto it.
- Per line, `chunks_for_line` (`:5100`) produces `RichChunk`s whose
`ChunkSource` (`:7715`) is one of `Source { start }`,
`SourceTab { start }`, `Adornment { anchor }`.
- **Every existing variant is additive.** Adornments (inlay hints) inject text
*between* source bytes; nothing today *replaces* a source range with a box
of chosen width. That mechanism is what this slice must build (Q#MS4).
- `build_hit_runs` (`:7739`) derives the projected→source hit map from the
same chunks that feed glyphon, so the map and the shaped buffer cannot
disagree. Any new chunk kind must participate here or clicks land wrong.
- Custom drawing precedent: `SquiggleRenderer` (`:2825`) owns its WGSL shader
and pipeline; the menu/background quad pipeline is the precedent for filled
rectangles.
- Fonts are embedded with `include_bytes!` from `pmacs-gpu/fonts/` under OFL
(`JETBRAINS_MONO`, `:63`); `build_font_system` (`:217`) loads them into
`fontdb`.
### 1.3 The acceptance seam already exists
`headless_or_skip(w, h, text)` builds a real headless GPU state and
`render_offscreen()` returns mapped pixels (`copy_texture_to_buffer` at
`:6570`). `headless_diag_face_recolors_band_counter_despite_unchanged_text`
(`:12022`) is the precedent: render, mutate, render again, and assert on the
pixel difference. Real-GPU tests run under `PMACS_REQUIRE_GPU=1`.
This matters because the slice's central claim — *math is actually drawn*
is exactly the kind of claim that a non-rendering test would pass vacuously.
## 2. What ships
One PR: detection (inline `$…$` only) → parse → layout against the MATH table
→ draw, for the subset in Q#MS2, with the raw source shown whenever the
cursor is inside the span (Q#MS5).
Explicitly **not** in this slice: display math `$$…$$`, big operators,
stretchy fences, radicals, accents, `\text{}`, style overrides, tree-sitter
injection detection, any wire surface, and the TUI.
## 3. Decisions
### Q#MS1 — Both modules live in `pmacs-gpu`
`pmacs-gpu/src/math_parse.rs` and `pmacs-gpu/src/math_layout.rs`. Not
`src/`, for the three independent reasons in §1.1. This keeps v0 exactly what
the parent says it is — a pure frontend responsibility — and keeps the core
crate free of a font-metrics dependency it has no use for.
If instance-side detection ever lands (the parent's v1 `MathSpans`), the
*parser* may move to a shared crate at that point. Nothing in this slice
should assume it will.
### Q#MS2 — The subset: characters, sub/superscript, fraction
`MathNode` for this slice:
```rust
enum MathNode {
Char(char), // resolved codepoint: x, 2, +, α
Group(Vec<MathNode>),
Script { base: Box<MathNode>, sub: Option<Box<MathNode>>, sup: Option<Box<MathNode>> },
Fraction { num: Box<MathNode>, den: Box<MathNode> },
}
```
Rev 1 had both `Char` and `Symbol`, each carrying a `char`, with no stated
difference (F9d). Folded: `\alpha` resolves to `'α'` **in the parser**, so
layout sees one kind. Provenance would only matter for error messages, which
Q#MS8 does not produce.
This subset is chosen because it is the smallest one that **forces the MATH
table to matter**. Characters alone could be positioned by guesswork and prove
nothing. Scripts require `ScriptPercentScaleDown`, `SuperscriptShiftUp` and
`SubscriptShiftDown`; fractions require `AxisHeight` and the fraction rule
constants, plus nested box composition. Get those right and the remaining node
kinds are more of the same; get them wrong and no amount of breadth helps.
The symbol map ships as a **seed** (Greek letters only, ~50 entries), not the
parent's full ~200. Growing it is mechanical and needs no design.
**Math italic is in scope, and it covers Greek too (F7, R2-2).** Neither rev 1
nor the parent mentioned italics, and without them `$x^2$` renders an upright
roman `x` — which does not look like math, and would make the slice's flagship
acceptance case visibly wrong.
Rev 2 fixed that for ASCII only, which reintroduced the same defect one symbol
over: `\alpha` resolves to U+03B1 in the parser, so `$\alpha x$` would have
drawn an upright α beside an italic 𝑥 — **mixed styles inside one
expression**, with the Greek seed map being the slice's *second* flagship case.
The mapping therefore follows TeX's actual convention:
| Class | Treatment | Range |
| --- | --- | --- |
| ASCII letters | math italic | U+1D434U+1D467, **with the U+210E hole for `h`** (Letterlike Symbols, not in the 1D4xx run) |
| Lowercase Greek | math italic | U+1D6FCU+1D714 |
| Uppercase Greek | **upright** | left at U+0391U+03A9 |
| Digits, operators | upright | unchanged |
Uppercase-Greek-upright is not an omission; it is what TeX does, and matching
it is why the table is stated rather than left as "letters become italic".
Because the slice positions characters, this stays a pure char→char mapping —
the same mechanical class as the Greek seed itself.
### Q#MS3 — Detection is the frontend byte scanner, inline only, currency-guarded
A two-pass scan over the visible slice for unescaped `$…$` pairs, run in
`rebuild_code_slice` **off the edit path**. (Rev 1 said "after shaping
decisions", inherited from the parent's "post-shape hook"; that contradicts
Q#MS4, since a suppression chunk must exist *before* the line is shaped. The
property that actually matters is that detection does not run per keystroke —
F9a.)
**Currency guards are mandatory, not a refinement (F5).** Rev 1 relied on the
parent's lone-`$` case and would have rendered `prices are $5 and $6 today` as
math over `5 and ` — in exactly the grammar-less prose buffers this rule
targets. Adopt Pandoc's rule:
- an opening `$` must be followed by a **non-space**;
- a closing `$` must be preceded by a **non-space** and not followed by a
**digit**;
- `\$` is an escape and neither opens nor closes.
**A span may not cross a newline in v0.** Chunking is per line and the visible
slice is line-ranged, so single-line spans are what keep visible-slice-scoped
scanning stable under scroll. A `$` with no same-line partner yields no span.
Tree-sitter injection detection is deliberately not used, even though #144
gives us `math_environment` / `math_delimiter` for `.tex`: that path is
instance-side, the substrate lane already deferred it to this arc, and the
slice must work in the grammar-less buffers where most inline math is typed.
It stays available as the natural upgrade — and it is the principled fix for
currency false-positives, which guards only approximate.
### Q#MS4 — Suppression is a spacer chunk, width-quantized, layout-chosen (F2, F3)
**Rev 1 was wrong about the mechanism.** It said the chunk "contributes no
glyphs… reserves width". A `RichChunk`'s only width *is* its `text: String`
(`pmacs-gpu/src/main.rs:7703`), which `line_from_chunks` feeds straight into a
`BufferLine`; cosmic-text has no zero-glyph strut. There is nothing to reserve
width with except text.
The mechanism is therefore the **`SourceTab` precedent**: `ChunkSource` gains a
variant carrying the suppressed source range, and the chunk projects **spacer
text** — runs of spaces — whose advance covers the box. Reserved width is
consequently **quantized up to whole space advances**, which is a feature, not
a rounding error: the projection stays grid-aligned with the surrounding
monospace text, and hit runs stay integral.
**Width is layout-chosen, not pinned to the source width (F3).** Rev 1 implied
both, and acceptance 10 demanded the latter. Resolved deliberately in favour of
layout-chosen:
- Pinning to source width removes reflow, but `$\frac{a}{b}$` is 13 source
columns against a box roughly 2 wide, so every fraction would sit in a large
blank gap. That defect is permanent and visible on every render.
- Layout-chosen width means the line **reflows when the caret crosses a span
boundary** (Q#MS5 toggles suppression). That is a jump, but it is confined to
one line, it happens only on a deliberate caret move, and it is the same
behaviour `org-appear` has trained users to expect from Emacs.
A permanent visual defect is worse than a transient one tied to an explicit
user action. Acceptance 10 is rewritten to match: text *before* the span never
moves, text *after* it moves by exactly the quantized difference, and the
reflow is confined to the affected line.
`build_hit_runs`'s invariant — the hit map derives from the same chunks
glyphon shaped — is not weakened; the new variant participates like any other.
A click inside a math box maps to the **start byte of the suppressed range**,
the same snap-to-anchor rule `Adornment` uses. Sub-expression hit-testing is
deferred; it needs a box→byte map this slice deliberately does not build.
### Q#MS10 — The height budget: fit to the line, or fall back (F1)
The code buffer is one cosmic-text `Buffer` with uniform metrics —
`BASE_CODE_FONT_SIZE = 16.0`, `BASE_CODE_LINE_HEIGHT = 22.0`
(`pmacs-gpu/src/main.rs:362`, `:359`). **Lines cannot grow.** A textstyle
fraction at those metrics is roughly 17 px tall against an above-baseline
budget of ~1214 px, so a simple fraction is marginal and acceptance 1's own
nested `\frac{x^2}{y}` plainly exceeds. Rev 1 hid this inside Q#MS8's "a box
that would exceed the line" without saying whether that meant width or height,
or what the budget was.
**Rule: the box is uniformly scaled to fit the line box, down to a floor of
0.6×; below the floor the span falls back to source (Q#MS8).** No overdraw, no
reflow of line height, no clipping surprises. The available budget is the line
box less a one-pixel margin, split at the text baseline.
Rejected alternatives, for the record:
- **Overdraw into adjacent lines' leading.** The math pass draws after
glyphon and *could* paint outside the line box, but a tall fraction would
then visually collide with the line above — a defect the user cannot fix
except by not writing math.
- **Growing the line.** Not available: metrics are uniform for the whole
buffer.
The honest consequence: **v0 shrinks nested math uniformly rather than by
proper style level.** TeX shrinks nested fractions too, but it does so through
display/text/script/scriptscript levels with per-level constants, which is the
real answer and is deferred by name in §6. A uniform scale is a visibly
cruder approximation of the same idea, and it is what keeps the slice thin.
### Q#MS5 — The cursor rule: render math only when the cursor is outside
When the caret is anywhere inside a math span (or on either delimiter), that
span is **not** suppressed — the raw `$…$` renders as ordinary source text.
This is the parent's Q#IM5 proposal ("when the cursor approaches the boundary,
the raw `$` reappears") adopted as a hard rule, and it buys the slice a great
deal: there is no caret-inside-rendered-math problem to solve, because the two
states are mutually exclusive. Editing math shows source; moving away renders
it. Q#IM6's "best-effort fractional cursor projection" is then not needed at
all in v0, and is deferred rather than approximated.
It also gives the feature an honest, self-explaining interaction model, which
is worth more in v0 than sub-glyph caret fidelity.
**This creates a new shaping-invalidation edge, and it is the #120 trap class
(F4).** Today caret motion within the visible slice touches no shaped line:
the `CursorByte` arm updates the cursor and reshapes only on scroll-follow,
and `rebuild_lines_reusing_scroll` (`pmacs-gpu/src/main.rs:5116`) retains
lines on the premise that content and styling are unchanged. Making
suppression a function of the caret breaks that premise. Two obligations
follow, both of which the implementation owns explicitly:
- **Caret motion that crosses a span boundary must dirty the affected
lines**, and the line-reuse predicate gains suppression state as a third
input beside content and styling. A retained line computed under the
opposite suppression state is exactly the stale-mirror failure #120 taught.
- **The rule reads the *effective* caret the frontend draws**, not the last
confirmed `CursorByte`. The GPU holds an optimistic cursor during
unconfirmed edits; keying suppression off the confirmed value would make
spans flap between rendered and source while typing.
Acceptance 7 exercises the behaviour; these two are named here because a test
that only moves the caret and re-renders would pass even if the reuse
predicate were left untouched, as long as something else happened to dirty the
line.
### Q#MS6 — Layout positions CHARACTERS, not glyph IDs
```rust
struct MathBox { width: f32, ascent: f32, descent: f32, items: Vec<MathItem> }
enum MathItem {
Glyph { ch: char, x: f32, baseline: f32, size_px: f32 },
Rule { x: f32, y: f32, width: f32, thickness: f32 }, // fraction bar
}
```
Positions are in pixels relative to the box origin, resolved by the frontend
that owns font metrics — consistent with the parent's contract.
**Characters, not glyph IDs, is a deliberate boundary — on the OUTPUT only
(F8a).** Layout still resolves glyph IDs *internally*: advances and
`MathItalicsCorrection` are glyph-keyed, so a `cmap` lookup happens whatever
the item type. What the boundary buys is that the *emitted* items are
drawable by the existing text machinery.
Glyph-ID **output** exists to select *variants* from the MATH table's
`GlyphVariantRecord` / `GlyphConstruction` chains — precisely what stretchy
fences and big operators need, and precisely what this slice defers. The slice
must not pretend this generalises: when stretchy delimiters arrive they will
need glyph-ID items, and `MathItem` will gain a variant then.
The fraction rule is a filled quad on the existing quad pipeline, not a glyph.
### Q#MS7 — The MATH font and its feature declaration
Bundle **Latin Modern Math** in `pmacs-gpu/fonts/`, embedded with
`include_bytes!` beside JetBrains Mono. Two consumers read the same bytes:
`fontdb`/cosmic-text for drawing, and `ttf-parser` directly for the MATH
table, which cosmic-text does not expose.
**Licence and size, corrected (F6).** Rev 1 said "OFL, GUST" and the parent's
table says "OFL (GUST)". Both are **wrong**. Verified against a local TeX Live
copy, `latinmodern-math.otf` is **733,736 bytes (~717 KiB)** and its own
copyright string reads *"released under the GUST Font License"* — an
LPPL-derived licence, not the SIL OFL. Consequences:
- the bundled licence file must be the **GUST Font License**, named as such,
not `OFL.txt` (the existing `fonts/OFL.txt` covers JetBrains Mono only);
- the size claim must be honest: at ~717 KiB this becomes **the largest single
embedded asset in the repository**, roughly 3.5× the figure rev 1 quoted;
- GFL permits redistribution with its licence text, so the plan stands — but
it is a *different* obligation from OFL and must be discharged as one;
- if OFL-only ever becomes a requirement, **STIX Two Math** is the OFL
alternative already listed in the parent's font table.
The parent framing carries the same error and needs the same correction; that
is recorded in §9 as a follow-up rather than smuggled into this lane.
**Pin `Attrs` to the math family when drawing (F8b).** Layout measures with
`ttf-parser` against the bundled bytes; drawing goes through cosmic-text. If
fallback selects a different face for `α` or a math-italic `𝑥` than the one
measured, drawn advances diverge silently from computed geometry and the box
is subtly wrong everywhere. The draw path sets the family explicitly and does
not rely on fallback.
Declare the dependency exactly as the parent's rev-2 C1 records:
```toml
ttf-parser = { version = "0.25", default-features = false, features = ["opentype-layout"] }
```
Bare `ttf-parser = "0.25"` unions `std` in and rebuilds the font chain.
A font whose MATH table is absent or unparseable is a **hard startup error in
the math path only** — math spans fall back to raw source (Q#MS8), the editor
does not fail. Bundled-font regressions must not be silent.
### Q#MS11 — Selection, search washes, and peer carets over a box (F9b)
Rev 1 named selection as a falsifier of B4 without proposing a rule. Any
overlay addressed in *source* bytes meets a span whose source is suppressed.
- **A selection endpoint inside a span unsuppresses it.** This is Q#MS5's rule
generalised from the caret to any selection boundary: if the user is
addressing bytes inside the math, they see the bytes. A selection that
merely *spans* the region (both endpoints outside) leaves it rendered.
- **A wash that *intersects* a rendered span washes the whole reserved
rectangle.** Intersection, not containment (R2-4): for selections the
distinction is vacuous, since a contiguous selection with both endpoints
outside a span necessarily contains it — but a **search match can partially
overlap**, e.g. searching `2$ af` in `before $x^2$ after` matches from
inside the span to outside it. Search hits and peer highlights paint the
projected box, never a sub-range of it: the box has no interior byte map
(Q#MS4), so a partial wash cannot be placed honestly.
- **Peer carets snap to the span start**, the same rule as hits.
This keeps every overlay addressable without inventing a box→byte projection
the slice does not build, and it makes "you are addressing this text" and "you
see this text" the same condition throughout.
### Q#MS8 — Failure is always "show the source"
Unparseable expression, unsupported node kind, missing MATH constant, or a box
that would exceed the line: the span is not suppressed and renders as ordinary
source. The parent's red-squiggle treatment (its Q#IM4) is **deferred** — it
reuses the diagnostic squiggle path, which is a second integration this slice
does not need in order to be correct.
Consequence worth stating plainly: **an unsupported construct is
indistinguishable from ordinary text in v0.** That is acceptable precisely
because the subset is small and documented; it stops being acceptable when
breadth arrives, which is when Q#IM4 should land.
### Q#MS9 — Caching is deferred
The parent's hash-keyed `MathBox` cache is **not** in this slice. Layout runs
per visible span per reshape. This is a slice: the subset is tiny, the visible
span count is small, and an unmeasured cache is a guess. The parent's latency
targets stay as targets; the first measurement comes from this slice's own
render path, and the cache lands when a number justifies its invalidation
cost.
## 4. Bets (falsifiable)
- **B1' (restated after F2) — a spacer chunk composes with the existing
pipeline.** Reserving width via projected spaces, quantized to whole space
advances, needs only a new `ChunkSource` variant that `chunks_for_line` and
`build_hit_runs` already iterate. Falsified if it requires changing how
cosmic-text shapes the surrounding line, or if quantized spacer width cannot
keep the projected hit map integral. *(Rev 1's "zero-glyph strut" wording is
withdrawn: no such mechanism exists.)*
- **B2 — scripts and fractions are enough to validate `MathBox`.** Falsified
if adding a deferred node kind later forces a change to `MathBox`'s width /
ascent / descent / origin contract, rather than only adding a `MathItem`
variant.
- **B3 — character positioning suffices for the subset.** Falsified if any
node in Q#MS2 cannot be drawn correctly without selecting a glyph variant.
- **B4' (sharpened after F9b) — the cursor rule plus Q#MS11 remove the caret
problem rather than hiding it.** Falsified if any caret position, selection
endpoint, search wash, or peer caret inside or across a math span still needs
a projected-position approximation to behave correctly.
- **B5 — `ttf-parser` supplies every constant the subset needs.** Falsified if
script or fraction layout requires a MATH value `ttf-parser` does not
expose.
- **B6 (new, F1) — fit-to-line with a 0.6× floor keeps the subset legible.**
Falsified if a plain `\frac{a}{b}` at default metrics lands below the floor
(making the flagship case fall back to source), or if scaled output is
illegible at the floor. Either outcome means the slice needs real TeX style
levels rather than a uniform scale, which would be a scope change.
## 5. Acceptance
Parser and layout are pure and get ordinary unit tests. Everything that claims
something reaches the screen runs on a real device through
`headless_or_skip` + `render_offscreen`, under `PMACS_REQUIRE_GPU=1`.
1. **Parser**`x^2`, `x_i`, `x_i^2`, `\frac{a}{b}`, `\alpha`, nested
`\frac{x^2}{y}` produce the expected `MathNode` trees. Unbalanced `{`,
unknown command, and an empty span are errors, not panics.
2. **Detection**`$x^2$` yields one span; `$a$ and $b$` yields two;
`\$5` yields none. **Currency guards (F5):** `Price: $5.00` yields none,
`prices are $5 and $6 today` yields **none** (the rev-1 rule would have
matched `5 and `), `$ x $` yields none (space after opener), and a `$`
whose only partner is on the next line yields none.
3. **MATH constants are actually consulted** — layout of `x^2` with the real
font places the `2` above the baseline and scaled down. Bite: stubbing
`ScriptPercentScaleDown` to 100% changes the laid-out box, proving the
constant is read rather than hardcoded.
4. **Fraction geometry** — numerator above, denominator below, rule at the
axis height, box ascent/descent enclose both.
5. **It renders** — a buffer containing `$x^2$` renders differently from the
same buffer with the math span suppressed. Asserted on pixels, so a layout
engine wired to nothing cannot pass it.
6. **The fraction rule is drawn**`$\frac{a}{b}$` produces horizontal rule
pixels between the two operand rows.
7. **Cursor rule** — with the caret inside `$x^2$`, the raw `$x^2$` glyphs
render and no math is drawn; moving the caret out re-renders the math.
Both directions asserted.
8. **Hit mapping** — a click on a rendered math box places the caret at the
span's start byte, and the surrounding text's hit runs are unchanged.
9. **Failure shows source**`$\frac{a$` and `$\unknown{}$` render as
ordinary source text with no panic and no missing glyphs.
10. **Reflow is bounded and predictable (F3)** — in `before $x^2$ after`,
`before` occupies identical pixels whether or not the span is rendered;
`after` shifts by exactly the quantized width difference; no other line
moves. Toggling via the Q#MS5 caret rule reflows only the affected line.
11. **Line reuse honours suppression (F4)** — moving the caret across a span
boundary changes the rendered output. Bite: with suppression left out of
the line-reuse predicate, the retained line keeps the stale state and this
fails. Suppression follows the **effective** caret, so it does not flap
during an unconfirmed optimistic edit.
12. **Height budget (F1) — measured, not assumed.** Computed against the
bundled font, with the budget derived as Q#MS10 defines it (the line box
less a 1 px margin, baseline placed by the **code** font — JetBrains Mono
asc 16.32 / desc 4.80 at 16 px inside the 22 px line, *not* the math
font's own 12.90/3.10):
| expression | ascent | descent | scale |
| --- | --- | --- | --- |
| `x^2`, `\alpha x` | 13.27 | 0.18 | 1.000 |
| `\frac{a}{b}` | 10.57 | 5.40 | **0.867** |
| `\frac{x^2}{y}` | 14.91 | 4.75 | 0.986 |
| nesting depth 2 | — | — | 0.872 |
| nesting depth 4 | — | — | 0.613 |
| nesting depth 5 | — | — | **0.540** |
So **B6 holds** — the flagship fraction renders at 0.867 — and the
fallback case is **depth 5**. Rev 3 guessed depth 2; the first
measurement said depth 3 while the fraction gap was still a hardcoded
`2 × thickness` guess; reading the MATH table's real
`FractionNumeratorGapMin` / `FractionDenominatorGapMin` (round-3 F4)
moved the flagship from 0.732 to 0.867 and the boundary to depth 5. The
round-2 hand-arithmetic estimate of ~0.85 was right all along; the 0.732
was inflated by the guessed gap.
Round 2 predicted exactly this trap. Two things worth keeping: depth 2
scores *higher* than depth 1 because the binding constraint flips from
descent to ascent as nesting grows asymmetrically, so "deeper is always
tighter" is false; and the test **searches** for the tripping depth rather
than hardcoding it, so a font or metric change cannot silently leave the
fallback arm unexercised.
13. **Math italic (F7, R2-2)**`$x$` renders the math-italic glyph, not
roman `x`; `$h$` resolves through the U+210E hole rather than the 1D4xx
run; digits in `$x2$` stay upright; **`$\alpha$` renders math-italic Greek
and `$\Gamma$` stays upright**, so `$\alpha x$` is uniformly italic rather
than mixed.
14. **Overlays (Q#MS11)** — a selection endpoint inside a span unsuppresses
it; a selection enclosing a rendered span leaves it rendered and washes
the whole reserved rectangle; a peer caret inside a rendered span draws at
the span start.
15. **Deferred syntax degrades, not corrupts**`$$x$$` renders as ordinary
source text through the empty-span error path, with no panic and no
half-rendered box (F9c).
16. **Font provenance** — the bundled licence file is the GUST Font License
and is distinct from the existing `fonts/OFL.txt`; a build with the MATH
table absent or unparseable falls back to source and surfaces the error
rather than failing silently (Q#MS7).
17. **Feature declaration is differential, not absolute** — the `ttf-parser`
feature set from `cargo tree -e features` is **byte-identical with and
without this crate's dependency line**. Asserting "`std` is absent" would
be wrong and would fail a correct implementation: `std` is *already*
enabled upstream, because `fontdb` declares `std = ["ttf-parser/std"]`.
What the declaration must not do is *widen* the set, which only a
before/after comparison can show.
18. Full gate suite per `CLAUDE.md`, including `PMACS_REQUIRE_GPU=1`.
## 6. Deferred (named)
Display math `$$…$$` and `\[…\]`; big operators; stretchy fences and glyph
variant/assembly (with the `MathItem` glyph-ID variant they require);
radicals; accents; `\text{}`; style overrides; the full ~200-entry symbol map;
the red-squiggle error treatment (parent Q#IM4); the `MathBox` cache (Q#MS9);
sub-expression hit-testing and caret projection inside rendered math (parent
Q#IM6); colour-by-context (parent Q#IM2); tree-sitter injection detection and
any `MathSpans` wire surface; the TUI's distinct-face fallback; Lua-registered
delimiters; **proper TeX style levels** (display/text/script/scriptscript with
per-level MATH constants), for which Q#MS10's uniform fit-to-line scale is a
deliberately cruder stand-in; **sub-range washes** inside a rendered box
(Q#MS11 washes the whole rectangle).
## 7. Interaction with other work
The slice's Tier 4 half edits `pmacs-gpu/src/main.rs`'s render path, which two
other lanes also claim:
- **Bottom panel** — Stage 1 is in review as **#155**; its **Stage 2** takes
this render path *and* the next protocol version.
- **Folding Stage 3 (GPU)** — next ranked, still unframed, and inherits the
`BufferSnapshot` fold-mirror-clear obligation on the same path.
This lane reserves **no protocol version** and adds no wire surface, so it
cannot collide there. For the render path the rule is the one the other two
framings already apply to each other: **whichever lands second re-scouts
against the first.** The parser and layout modules are new files and collide
with nothing; only the `rebuild_code_slice` / chunk / render hunks are
contended, and they are small and localised by design.
Sequencing preference: land after #155's Stage 1, whose merge does not touch
this path, and re-scout if bottom-panel Stage 2 or folding Stage 3 lands
first.
## 8. Prior art in pmacs
`SquiggleRenderer` (`pmacs-gpu/src/main.rs:2825`) for owning a custom pipeline
beside glyphon; the menu/background quad path for filled rectangles; inlay
hints (`ChunkSource::Adornment`) for interleaving non-source content and for
the anchor-snapping hit rule; `headless_diag_face_recolors_band_counter…`
(`:12022`) for asserting a rendering claim on real pixels; #144's query
overlay for the eventual tree-sitter detection upgrade.
## 9. Follow-up outside this lane
The parent framing (`docs/inline-math-framing.md`, rev 2, merged as #154)
carries the same font error F6 found here: its table row reads "Latin Modern
Math | Full | OFL (GUST)". It should be corrected to the GUST Font License,
with the ~717 KiB size, in its own docs change rather than in this branch —
the parent is a merged document and this lane should not quietly edit it.

View File

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

View File

@ -1711,6 +1711,22 @@ half of criterion 14, is dark for the same reason. Stage 2 is fully covered
(6/6). This is not a vterm problem: 264 tests workspace-wide are dark,
including 177 in the library. It has its own lane in `docs/active-work.md`.
**And `a37` is darker still than that count implies: it reports `ok` without
running whenever `pmacs-gpu` is absent from the same target directory**
(measured 2026-07-26 while gating #173). It derives the sibling binary from
`CARGO_BIN_EXE_pmacs` and, finding nothing, prints a skip notice and returns.
A fresh worktree reports the suite 9/9 in 0.17 s having executed the arc's
only real-daemon/real-PTY/real-wgpu path zero times; a genuine run takes
about four seconds. `PMACS_REQUIRE_GPU=1` is the only thing that turns that
skip into a failure, and the standing gate list applies that flag to
`cargo test -p pmacs-gpu`, a different package. So the audit's claim that
"only 3 of 9 Stage 3 tests drive a real daemon" was itself optimistic —
**on a target directory without the frontend binary the honest number is 2**,
and nothing in the gate log says so. It is also load-sensitive: it passed and
then failed at the same commit twenty minutes apart under machine
contention. Criterion 22's unpinned "without thrash" and this are the arc's
two standing verification gaps.
**Not audited:** §11's blanket claim that "deferral means graceful ignore or
documented absence, never escape leakage, panic, unbounded allocation, or
child leak". That covers roughly twenty deferred items and none were

View File

@ -37,6 +37,11 @@ similar_names = "allow"
multiple_crate_versions = "allow"
[dependencies]
# OpenType MATH table reader for inline math layout (Q#MS7). Already in the
# build graph via fontdb -> cosmic-text -> glyphon, so this declares a crate
# the build compiles anyway. The feature set is a SUBSET of fontdb's; a bare
# `ttf-parser = "0.25"` would union `std` in and rebuild the whole font chain.
ttf-parser = { version = "0.25", default-features = false, features = ["opentype-layout"] }
# OS clipboard for cut/copy/paste (Q#CM6). `wayland-data-control` adds
# the zwlr_data_control backend so the clipboard works under Wayland
# without a window handle; the default X11 backend covers X sessions.

View File

@ -0,0 +1,28 @@
% This is version 1.0, dated 22 June 2009, of the GUST Font License.
% (GUST is the Polish TeX Users Group, http://www.gust.org.pl)
%
% For the most recent version of this license see
% http://www.gust.org.pl/fonts/licenses/GUST-FONT-LICENSE.txt
% or
% http://tug.org/fonts/licenses/GUST-FONT-LICENSE.txt
%
% This work may be distributed and/or modified under the conditions
% of the LaTeX Project Public License, either version 1.3c of this
% license or (at your option) any later version.
%
% Please also observe the following clause:
% 1) it is requested, but not legally required, that derived works be
% distributed only after changing the names of the fonts comprising this
% work and given in an accompanying "manifest", and that the
% files comprising the Work, as listed in the manifest, also be given
% new names. Any exceptions to this request are also given in the
% manifest.
%
% We recommend the manifest be given in a separate file named
% MANIFEST-<fontid>.txt, where <fontid> is some unique identification
% of the font family. If a separate "readme" file accompanies the Work,
% we recommend a name of the form README-<fontid>.txt.
%
% The latest version of the LaTeX Project Public License is in
% http://www.latex-project.org/lppl.txt and version 1.3c or later
% is part of all distributions of LaTeX version 2006/05/20 or later.

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,900 @@
//! OpenType MATH metrics and the math-italic mapping (Tier 3, part one).
//!
//! Framing: `docs/inline-math-slice-framing.md` (rev 3), Q#MS6 / Q#MS7.
//!
//! Two consumers read the same bundled font bytes: cosmic-text draws with it,
//! and this module measures with it. cosmic-text does not expose the MATH
//! table, which is why `ttf-parser` is a direct dependency (Q#MS7) — already
//! in the build graph via `fontdb`, declared with a feature subset that
//! widens nothing.
use ttf_parser::Face;
/// Bundled math font (GUST Font License — see `fonts/GUST-FONT-LICENSE.txt`).
///
/// Distinct from `fonts/OFL.txt`, which covers `JetBrains` Mono only: Latin
/// Modern Math is GFL, an LPPL-derived licence, not the SIL OFL (framing F6).
pub const LATIN_MODERN_MATH: &[u8] = include_bytes!("../fonts/latinmodern-math.otf");
/// The MATH constants this slice's subset needs, in font units.
///
/// Deliberately narrow: Q#MS2 covers scripts and fractions, so these are the
/// constants those two require. Reading more would be speculative — the
/// values for deferred constructs are only meaningful once they have a
/// consumer (the Q#LX5 discipline, applied to metrics).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct MathConstants {
/// Units per em, for scaling everything below into pixels.
pub units_per_em: u16,
/// Vertical position of the fraction bar / math axis.
pub axis_height: i16,
/// Percentage (0100) to scale one script level down.
pub script_percent_scale_down: i16,
/// Baseline shift for a superscript.
pub superscript_shift_up: i16,
/// Baseline shift for a subscript.
pub subscript_shift_down: i16,
/// Thickness of the fraction rule.
pub fraction_rule_thickness: i16,
/// Minimum gap between the numerator and the rule.
pub fraction_numerator_gap_min: i16,
/// Minimum gap between the rule and the denominator.
pub fraction_denominator_gap_min: i16,
}
/// Why the bundled font could not supply math metrics.
///
/// Q#MS7: this is a failure of the *math path only* — spans fall back to
/// source and the editor keeps running. It is surfaced rather than swallowed
/// so a bundled-font regression cannot be silent.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MathFontError {
/// The bytes are not a parseable font.
Unparseable,
/// Parsed, but carries no MATH table (e.g. a text-only font).
NoMathTable,
/// MATH table present but missing a constant the subset needs.
MissingConstant(&'static str),
/// The math font cannot draw this codepoint (F3). Q#MS8's rule is
/// "failure is always show the source", so layout REFUSES rather than
/// emitting a zero-width item that would render tofu over its neighbour.
/// Layout is fallible for this reason alone; the draw pass needs a
/// refusal signal, and it must exist before that pass consumes the API.
UncoverableGlyph(char),
}
impl MathConstants {
/// Read the subset's constants from font bytes.
///
/// # Errors
/// [`MathFontError`] when the face, the MATH table, or a needed constant
/// is absent.
pub fn from_font_bytes(bytes: &[u8]) -> Result<Self, MathFontError> {
let face = Face::parse(bytes, 0).map_err(|_| MathFontError::Unparseable)?;
let math = face.tables().math.ok_or(MathFontError::NoMathTable)?;
let constants = math
.constants
.ok_or(MathFontError::MissingConstant("constants"))?;
Ok(Self {
units_per_em: face.units_per_em(),
axis_height: constants.axis_height().value,
script_percent_scale_down: constants.script_percent_scale_down(),
superscript_shift_up: constants.superscript_shift_up().value,
subscript_shift_down: constants.subscript_shift_down().value,
fraction_rule_thickness: constants.fraction_rule_thickness().value,
fraction_numerator_gap_min: constants.fraction_numerator_gap_min().value,
fraction_denominator_gap_min: constants.fraction_denominator_gap_min().value,
})
}
/// Convert a font-unit value to pixels at `font_size_px`.
#[must_use]
pub fn to_px(self, value: i16, font_size_px: f32) -> f32 {
if self.units_per_em == 0 {
return 0.0;
}
f32::from(value) * font_size_px / f32::from(self.units_per_em)
}
/// The per-level script scale, as a fraction (e.g. 0.7).
#[must_use]
pub fn script_scale(self) -> f32 {
let pct = f32::from(self.script_percent_scale_down);
if pct <= 0.0 { 0.7 } else { pct / 100.0 }
}
}
/// Map a resolved codepoint to its math-mode presentation form (Q#MS2).
///
/// TeX's convention, which is why uppercase Greek is deliberately upright:
///
/// | Class | Treatment |
/// |---|---|
/// | ASCII letters | math italic, with the U+210E hole for `h` |
/// | Lowercase Greek | math italic |
/// | Uppercase Greek | upright |
/// | Digits, operators | upright |
///
/// Without this, `$x^2$` draws a roman `x` and `$\alpha x$` draws an upright
/// α beside an italic 𝑥 — mixed styles inside one expression (framing F7,
/// R2-2).
#[must_use]
pub fn math_italic(ch: char) -> char {
// U+210E PLANCK CONSTANT is the italic `h`; the 1D4xx run has a hole
// there, so mapping arithmetically would produce a reserved codepoint.
if ch == 'h' {
return '\u{210E}';
}
let mapped = match ch {
'A'..='Z' => 0x1D434 + (ch as u32 - 'A' as u32),
'a'..='z' => 0x1D44E + (ch as u32 - 'a' as u32),
// Lowercase Greek α..ω → MATHEMATICAL ITALIC SMALL ALPHA..OMEGA.
'\u{3B1}'..='\u{3C9}' => 0x1D6FC + (ch as u32 - 0x3B1),
// The SYMBOL forms TeX's \epsilon and \phi resolve to sit OUTSIDE
// that run, so they need explicit italic mappings — without them the
// seed map's correction would render them upright beside italic
// neighbours, which is the defect it was fixing.
'\u{3F5}' => 0x1D716, // ϵ lunate epsilon
'\u{3D5}' => 0x1D719, // ϕ phi symbol
// Uppercase Greek, digits, operators: upright, per TeX.
_ => return ch,
};
char::from_u32(mapped).unwrap_or(ch)
}
/// A laid-out expression. Baseline at `y = 0`, positive `y` upward.
///
/// Q#MS6: items carry CHARACTERS, not glyph IDs. Layout still resolves glyph
/// ids internally for advances and bounds — the boundary is on the emitted
/// items, so each is drawable by the existing text machinery. Glyph-id items
/// arrive with stretchy fences and big operators, both deferred.
#[derive(Clone, Debug, PartialEq)]
pub struct MathBox {
pub width: f32,
pub ascent: f32,
pub descent: f32,
pub items: Vec<MathItem>,
}
/// One drawable piece of a [`MathBox`].
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum MathItem {
/// A character at its own size, `baseline` relative to the box baseline.
Glyph {
ch: char,
x: f32,
baseline: f32,
size_px: f32,
},
/// The fraction bar. Not a glyph — drawn on the existing quad pipeline.
Rule {
x: f32,
y: f32,
width: f32,
thickness: f32,
},
}
impl MathItem {
fn shifted(self, dx: f32, dy: f32) -> Self {
match self {
Self::Glyph {
ch,
x,
baseline,
size_px,
} => Self::Glyph {
ch,
x: x + dx,
baseline: baseline + dy,
size_px,
},
Self::Rule {
x,
y,
width,
thickness,
} => Self::Rule {
x: x + dx,
y: y + dy,
width,
thickness,
},
}
}
}
impl MathBox {
fn empty() -> Self {
Self {
width: 0.0,
ascent: 0.0,
descent: 0.0,
items: Vec::new(),
}
}
/// Absorb `other` at offset `(dx, dy)`, growing this box's extents.
fn absorb(&mut self, other: &Self, dx: f32, dy: f32) {
self.items
.extend(other.items.iter().map(|item| item.shifted(dx, dy)));
self.ascent = self.ascent.max(other.ascent + dy);
self.descent = self.descent.max(other.descent - dy);
}
/// Uniformly scale every extent and item (Q#MS10 fit-to-line).
#[must_use]
pub fn scaled(&self, factor: f32) -> Self {
Self {
width: self.width * factor,
ascent: self.ascent * factor,
descent: self.descent * factor,
items: self
.items
.iter()
.map(|item| match *item {
MathItem::Glyph {
ch,
x,
baseline,
size_px,
} => MathItem::Glyph {
ch,
x: x * factor,
baseline: baseline * factor,
size_px: size_px * factor,
},
MathItem::Rule {
x,
y,
width,
thickness,
} => MathItem::Rule {
x: x * factor,
y: y * factor,
width: width * factor,
thickness: thickness * factor,
},
})
.collect(),
}
}
}
/// The line-box height budget a math box must fit (Q#MS10), as
/// `(above_baseline, below_baseline)` pixels.
///
/// Extracted rather than left inside a test: the draw pass must compute the
/// SAME split the acceptance test asserts, and a duplicated derivation is
/// exactly how a renderer and its test drift apart while both stay green.
///
/// The baseline is placed by the CODE font, not the math font — using the
/// math font's own metrics understates the descent budget badly enough to
/// make a plain fraction appear not to fit.
#[must_use]
pub fn line_box_budget(code_font: &Face<'_>, font_size_px: f32, line_height_px: f32) -> (f32, f32) {
const MARGIN_PX: f32 = 1.0;
let upem = f32::from(code_font.units_per_em().max(1));
let baseline_from_top = f32::from(code_font.ascender()) * font_size_px / upem;
let above = (baseline_from_top - MARGIN_PX).max(0.0);
let below = (line_height_px - baseline_from_top - MARGIN_PX).max(0.0);
(above, below)
}
/// The smallest uniform scale the slice will apply before giving up (Q#MS10).
pub const MIN_FIT_SCALE: f32 = 0.6;
/// Scale `boxed` to fit `(ascent_budget, descent_budget)`, or `None` when
/// that would fall below [`MIN_FIT_SCALE`] — in which case Q#MS8 shows the
/// raw source rather than overdrawing into the neighbouring line.
#[must_use]
pub fn fit_to_line(boxed: &MathBox, ascent_budget: f32, descent_budget: f32) -> Option<MathBox> {
let need_up = boxed.ascent;
let need_down = boxed.descent;
let up = if need_up <= 0.0 {
1.0
} else {
ascent_budget / need_up
};
let down = if need_down <= 0.0 {
1.0
} else {
descent_budget / need_down
};
let scale = up.min(down).min(1.0);
if scale < MIN_FIT_SCALE {
return None;
}
if scale >= 1.0 {
return Some(boxed.clone());
}
Some(boxed.scaled(scale))
}
/// Lays a [`MathNode`] tree out against the bundled MATH font.
pub struct MathLayout<'a> {
face: Face<'a>,
constants: MathConstants,
}
impl<'a> MathLayout<'a> {
/// Build a layout engine over font bytes.
///
/// # Errors
/// [`MathFontError`] when the face or its MATH table is unusable.
pub fn new(bytes: &'a [u8]) -> Result<Self, MathFontError> {
let face = Face::parse(bytes, 0).map_err(|_| MathFontError::Unparseable)?;
let constants = MathConstants::from_font_bytes(bytes)?;
Ok(Self { face, constants })
}
/// Test-only introspection: the production draw path consumes the
/// constants through `layout`, never raw.
#[cfg(test)]
#[must_use]
pub fn constants(&self) -> MathConstants {
self.constants
}
/// Lay `node` out at `size_px`.
///
/// # Errors
/// [`MathFontError::UncoverableGlyph`] when the math font has no glyph
/// for a character, so the caller can fall back to source (Q#MS8).
pub fn layout(
&self,
node: &crate::math_parse::MathNode,
size_px: f32,
) -> Result<MathBox, MathFontError> {
use crate::math_parse::MathNode;
match node {
MathNode::Char(ch) => self.layout_char(*ch, size_px),
MathNode::Group(children) => {
let mut out = MathBox::empty();
let mut pen = 0.0;
for child in children {
let child_box = self.layout(child, size_px)?;
out.absorb(&child_box, pen, 0.0);
pen += child_box.width;
}
out.width = pen;
Ok(out)
}
MathNode::Script { base, sub, sup } => {
self.layout_script(base, sub.as_deref(), sup.as_deref(), size_px)
}
MathNode::Fraction { num, den } => self.layout_fraction(num, den, size_px),
}
}
fn layout_char(&self, ch: char, size_px: f32) -> Result<MathBox, MathFontError> {
let presented = math_italic(ch);
let upem = f32::from(self.constants.units_per_em.max(1));
let (advance, ascent, descent) = self
.face
.glyph_index(presented)
.map(|gid| {
let adv = self
.face
.glyph_hor_advance(gid)
.map_or(0.0, |a| f32::from(a) * size_px / upem);
// Per-glyph bounds keep boxes tight, which is what makes a
// fraction's extents honest; fall back to face metrics when
// a glyph has no bounding box (e.g. a space).
let (asc, desc) = self.face.glyph_bounding_box(gid).map_or_else(
|| {
(
f32::from(self.face.ascender()) * size_px / upem,
-f32::from(self.face.descender()) * size_px / upem,
)
},
|bb| {
(
f32::from(bb.y_max) * size_px / upem,
-f32::from(bb.y_min) * size_px / upem,
)
},
);
(adv, asc.max(0.0), desc.max(0.0))
})
// F3: no glyph means no honest box. Emitting a zero-width item
// would draw tofu on top of the next character.
.ok_or(MathFontError::UncoverableGlyph(ch))?;
Ok(MathBox {
width: advance,
ascent,
descent,
items: vec![MathItem::Glyph {
ch: presented,
x: 0.0,
baseline: 0.0,
size_px,
}],
})
}
fn layout_script(
&self,
base: &crate::math_parse::MathNode,
sub: Option<&crate::math_parse::MathNode>,
sup: Option<&crate::math_parse::MathNode>,
size_px: f32,
) -> Result<MathBox, MathFontError> {
let base_box = self.layout(base, size_px)?;
let script_px = size_px * self.constants.script_scale();
let mut out = MathBox::empty();
out.absorb(&base_box, 0.0, 0.0);
let mut widest = base_box.width;
if let Some(sup) = sup {
let sup_box = self.layout(sup, script_px)?;
let shift = self
.constants
.to_px(self.constants.superscript_shift_up, size_px);
out.absorb(&sup_box, base_box.width, shift);
widest = widest.max(base_box.width + sup_box.width);
}
if let Some(sub) = sub {
let sub_box = self.layout(sub, script_px)?;
let shift = self
.constants
.to_px(self.constants.subscript_shift_down, size_px);
out.absorb(&sub_box, base_box.width, -shift);
widest = widest.max(base_box.width + sub_box.width);
}
out.width = widest;
Ok(out)
}
fn layout_fraction(
&self,
num: &crate::math_parse::MathNode,
den: &crate::math_parse::MathNode,
size_px: f32,
) -> Result<MathBox, MathFontError> {
// TeX sets an inline \frac's operands one style down, which is also
// what the parent framing's Tier 3 specifies (70%). It is load-bearing
// for Q#MS10: full-size operands would not fit the line at all.
let operand_px = size_px * self.constants.script_scale();
let num_box = self.layout(num, operand_px)?;
let den_box = self.layout(den, operand_px)?;
let axis = self.constants.to_px(self.constants.axis_height, size_px);
let thickness = self
.constants
.to_px(self.constants.fraction_rule_thickness, size_px)
.max(1.0);
// F4: the gaps come from the MATH table, not a guess. An earlier
// revision used `thickness * 2.0`, which made fractions roughly twice
// as airy as the font specifies and inflated the height budget the
// fit-to-line scale is measured against.
let num_gap = self
.constants
.to_px(self.constants.fraction_numerator_gap_min, size_px)
.max(thickness);
let den_gap = self
.constants
.to_px(self.constants.fraction_denominator_gap_min, size_px)
.max(thickness);
let width = num_box.width.max(den_box.width);
let mut out = MathBox::empty();
// Numerator sits above the bar, denominator below it.
let num_baseline = axis + thickness / 2.0 + num_gap + num_box.descent;
let den_baseline = axis - thickness / 2.0 - den_gap - den_box.ascent;
out.absorb(&num_box, (width - num_box.width) / 2.0, num_baseline);
out.absorb(&den_box, (width - den_box.width) / 2.0, den_baseline);
out.items.push(MathItem::Rule {
x: 0.0,
y: axis,
width,
thickness,
});
out.ascent = out.ascent.max(axis + thickness / 2.0);
out.descent = out.descent.max(-(axis - thickness / 2.0));
out.width = width;
Ok(out)
}
}
/// Spacer text reserving `width_px`, quantized UP to whole space advances.
///
/// Q#MS4 / B1': a `RichChunk`'s only width is its text, so a suppressed math
/// span reserves room the way `SourceTab` does — with spaces. Quantizing up
/// is deliberate: it keeps the projection grid-aligned with the surrounding
/// monospace text and keeps hit runs integral, at the cost of up to one
/// advance of slack on the right of the box.
#[must_use]
pub fn spacer_for_width(width_px: f32, space_advance_px: f32) -> String {
if !width_px.is_finite() || width_px <= 0.0 || space_advance_px <= 0.0 {
return String::new();
}
let n = (width_px / space_advance_px).ceil();
// Guard the cast: a pathological advance must not mint a giant string.
let n = n.clamp(0.0, 4096.0) as usize;
" ".repeat(n)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::math_parse::parse;
/// Framing acceptance 16 (provenance half): the GUST licence ships
/// beside the font, names itself, and is not the OFL that covers
/// `JetBrains` Mono.
#[test]
fn bundled_licences_are_distinct_and_name_their_terms() {
let gust = include_str!("../fonts/GUST-FONT-LICENSE.txt");
let ofl = include_str!("../fonts/OFL.txt");
assert!(gust.contains("GUST Font License"));
assert!(gust.contains("LaTeX Project Public License"));
assert!(!ofl.contains("GUST"));
assert_ne!(gust, ofl);
}
#[test]
fn tex_symbol_greek_forms_are_italicised_too() {
// F5's trap: correcting the seed map alone leaves these upright,
// because they sit outside the U+03B1..03C9 run.
assert_eq!(math_italic('\u{3F5}'), '\u{1D716}');
assert_eq!(math_italic('\u{3D5}'), '\u{1D719}');
let face = Face::parse(LATIN_MODERN_MATH, 0).expect("face");
for ch in ['\u{3F5}', '\u{3D5}'] {
assert!(
face.glyph_index(math_italic(ch)).is_some(),
"no glyph for the italic form of U+{:04X}",
ch as u32
);
}
}
#[test]
fn spacer_quantizes_up_to_whole_advances() {
// Exactly two advances stays two; a sliver over rounds up, so the
// box never overlaps the text that follows it.
assert_eq!(spacer_for_width(20.0, 10.0).len(), 2);
assert_eq!(spacer_for_width(20.1, 10.0).len(), 3);
assert_eq!(spacer_for_width(0.1, 10.0).len(), 1);
// Degenerate inputs reserve nothing rather than panicking or
// minting an enormous string.
assert!(spacer_for_width(0.0, 10.0).is_empty());
assert!(spacer_for_width(-5.0, 10.0).is_empty());
assert!(spacer_for_width(10.0, 0.0).is_empty());
assert!(spacer_for_width(f32::NAN, 10.0).is_empty());
assert!(spacer_for_width(f32::INFINITY, 10.0).is_empty());
assert!(spacer_for_width(1e9, 0.001).len() <= 4096);
}
#[test]
fn a_real_box_reserves_at_least_its_own_width() {
let boxed = lay(r"\frac{a}{b}", crate::BASE_CODE_FONT_SIZE);
let advance = 9.6_f32; // a plausible monospace advance at 16 px
let spacer = spacer_for_width(boxed.width, advance);
let reserved = spacer.len() as f32 * advance;
assert!(
reserved >= boxed.width,
"reserved {reserved} must cover box width {}",
boxed.width
);
assert!(
reserved - boxed.width < advance,
"slack stays under one advance"
);
}
fn engine() -> MathLayout<'static> {
MathLayout::new(LATIN_MODERN_MATH).expect("bundled font")
}
fn lay(src: &str, size: f32) -> MathBox {
let node = parse(src).expect("parses");
engine().layout(&node, size).expect("lays out")
}
/// F3 — a codepoint the math font cannot draw REFUSES, so the caller can
/// fall back to source (Q#MS8) instead of drawing tofu at zero advance
/// on top of the next character.
#[test]
fn an_uncoverable_character_refuses_layout_instead_of_emitting_a_void() {
let node = parse("x日").expect("parses — coverage is layout's problem");
assert_eq!(
engine().layout(&node, 16.0),
Err(MathFontError::UncoverableGlyph('日'))
);
// The covered neighbour on its own still lays out.
assert!(engine().layout(&parse("x").unwrap(), 16.0).is_ok());
}
/// Framing acceptance 3, including its bite: the MATH constant must be
/// READ, not hardcoded.
#[test]
fn superscript_is_raised_and_scaled_from_the_math_table() {
let plain = lay("x", 16.0);
let script = lay("x^2", 16.0);
assert!(script.width > plain.width, "the 2 adds width");
assert!(
script.ascent > plain.ascent,
"superscript must raise the box: {} vs {}",
script.ascent,
plain.ascent
);
let two = script
.items
.iter()
.find_map(|i| match *i {
MathItem::Glyph {
ch: '2',
baseline,
size_px,
..
} => Some((baseline, size_px)),
_ => None,
})
.expect("the 2 is emitted");
assert!(two.0 > 0.0, "raised above baseline: {}", two.0);
assert!(two.1 < 16.0, "scaled down: {}", two.1);
// Bite: with the script scale stubbed to 100%, the box changes —
// proving the constant is consulted rather than assumed.
let c = engine().constants();
assert!(
c.script_percent_scale_down < 100,
"font advertises a real script scale ({}%), so 100% is a \
meaningful stub",
c.script_percent_scale_down
);
let stubbed = MathConstants {
script_percent_scale_down: 100,
..c
};
assert!(
(stubbed.script_scale() - c.script_scale()).abs() > 0.01,
"stubbing the constant must change the scale actually used"
);
}
#[test]
fn subscript_drops_below_the_baseline() {
let script = lay("x_i", 16.0);
let i = script
.items
.iter()
.find_map(|item| match *item {
MathItem::Glyph { ch, baseline, .. } if ch == math_italic('i') => Some(baseline),
_ => None,
})
.expect("the i is emitted");
assert!(i < 0.0, "subscript sits below the baseline: {i}");
assert!(script.descent > lay("x", 16.0).descent);
}
/// Framing acceptance 4.
#[test]
fn fraction_stacks_operands_around_a_rule_at_the_axis() {
let frac = lay(r"\frac{a}{b}", 16.0);
let rule = frac
.items
.iter()
.find_map(|item| match *item {
MathItem::Rule {
y,
width,
thickness,
..
} => Some((y, width, thickness)),
MathItem::Glyph { .. } => None,
})
.expect("a fraction draws a rule");
assert!(rule.0 > 0.0, "rule sits at the math axis, above baseline");
assert!(rule.2 > 0.0 && rule.1 > 0.0);
let mut above = 0;
let mut below = 0;
for item in &frac.items {
if let MathItem::Glyph { baseline, .. } = *item {
if baseline > rule.0 {
above += 1;
} else if baseline < rule.0 {
below += 1;
}
}
}
assert_eq!((above, below), (1, 1), "one operand each side of the bar");
assert!(frac.ascent > 0.0 && frac.descent > 0.0);
}
/// F1 / B6 — the height budget, computed rather than guessed.
///
/// The round-2 review warned that acceptance 12's fallback case must be
/// derived by computation or it would "surprise-pass by rendering". It
/// was right, and rev 3's guess was wrong: a doubly-nested fraction still
/// fits. This test derives the budget the way Q#MS10 defines it — from
/// the LINE BOX, whose baseline the CODE font places — and then searches
/// for the depth that actually trips the floor, so the case can never
/// drift out from under the acceptance criterion.
#[test]
fn fit_to_line_admits_real_fractions_and_finds_the_true_fallback_depth() {
// Q#MS10: the budget is the line box less a one-pixel margin, split
// at the text baseline. The baseline is where the CODE font puts it
// (JetBrains Mono at BASE_CODE_FONT_SIZE inside BASE_CODE_LINE_HEIGHT),
// NOT where the math font's own metrics would.
let code = Face::parse(crate::JETBRAINS_MONO, 0).expect("code face");
let (asc_budget, desc_budget) = line_box_budget(
&code,
crate::BASE_CODE_FONT_SIZE,
crate::BASE_CODE_LINE_HEIGHT,
);
assert!(
asc_budget > 0.0 && desc_budget > 0.0,
"budget must be positive: {asc_budget} / {desc_budget}"
);
let scale_of = |src: &str| {
let boxed = lay(src, crate::BASE_CODE_FONT_SIZE);
let up = asc_budget / boxed.ascent.max(f32::EPSILON);
let down = desc_budget / boxed.descent.max(f32::EPSILON);
(up.min(down).min(1.0), boxed)
};
// The flagship cases must RENDER, not fall back (B6).
for src in [r"\frac{a}{b}", r"\frac{x^2}{y}", "x^2", r"\alpha x"] {
let (scale, boxed) = scale_of(src);
eprintln!(
"{src}: asc={:.2} desc={:.2} scale={scale:.3}",
boxed.ascent, boxed.descent
);
assert!(
scale >= MIN_FIT_SCALE,
"{src} must render, not fall back: scale {scale:.3} < {MIN_FIT_SCALE}"
);
assert!(fit_to_line(&boxed, asc_budget, desc_budget).is_some());
}
// Now FIND the depth that trips the floor rather than assuming one.
// Nest fractions until the scale drops below it.
let mut src = String::from(r"\frac{a}{b}");
let mut depth = 1;
let tripped = loop {
let (scale, _) = scale_of(&src);
eprintln!("depth {depth}: scale={scale:.3}");
if scale < MIN_FIT_SCALE {
break Some((depth, src.clone()));
}
// Headroom above the real boundary (5 with the round-3 MATH
// gaps): if a metric shift pushed the boundary past this bound,
// the expect below would fire with a message reading "the floor
// is dead code" when the truth is "the boundary moved past the
// search". Keep the bound comfortably above the boundary.
if depth >= 8 {
break None;
}
src = format!(r"\frac{{{src}}}{{c}}");
depth += 1;
};
let (depth, deep_src) = tripped.expect(
"some nesting depth must exceed the floor, or Q#MS10's fallback \
arm is unreachable and the floor is dead code",
);
assert!(
depth > 2,
"rev 3 guessed a doubly-nested fraction would fall back; the real \
depth is {depth}, so acceptance 12 must use that case"
);
assert!(
fit_to_line(
&lay(&deep_src, crate::BASE_CODE_FONT_SIZE),
asc_budget,
desc_budget
)
.is_none()
);
}
#[test]
fn fitting_scales_extents_and_items_together() {
let boxed = lay(r"\frac{a}{b}", 16.0);
let half = boxed.scaled(0.5);
assert!((half.ascent - boxed.ascent * 0.5).abs() < 0.001);
assert!((half.width - boxed.width * 0.5).abs() < 0.001);
for (before, after) in boxed.items.iter().zip(half.items.iter()) {
if let (MathItem::Glyph { size_px: b, .. }, MathItem::Glyph { size_px: a, .. }) =
(before, after)
{
assert!((a - b * 0.5).abs() < 0.001, "glyph size scales too");
}
}
}
#[test]
fn a_group_advances_the_pen_left_to_right() {
let boxed = lay("abc", 16.0);
let xs: Vec<f32> = boxed
.items
.iter()
.filter_map(|item| match *item {
MathItem::Glyph { x, .. } => Some(x),
MathItem::Rule { .. } => None,
})
.collect();
assert_eq!(xs.len(), 3);
assert!(xs[0] < xs[1] && xs[1] < xs[2], "left to right: {xs:?}");
assert!(boxed.width > xs[2], "width covers the last advance");
}
/// B5 — `ttf-parser` supplies every constant the subset needs, from the
/// bundled font. This is the bet that would sink Tier 3 if false, so it
/// runs against the real embedded bytes rather than a fixture.
#[test]
fn bundled_font_yields_every_math_constant_the_subset_needs() {
let c = MathConstants::from_font_bytes(LATIN_MODERN_MATH)
.expect("bundled Latin Modern Math must expose MATH constants");
assert_eq!(c.units_per_em, 1000, "LM Math is a 1000 upem font");
assert!(c.axis_height > 0, "axis height: {}", c.axis_height);
assert!(
(50..=100).contains(&c.script_percent_scale_down),
"script scale percent out of range: {}",
c.script_percent_scale_down
);
assert!(c.superscript_shift_up > 0);
assert!(c.subscript_shift_down > 0);
assert!(c.fraction_rule_thickness > 0);
}
#[test]
fn a_text_font_without_a_math_table_is_rejected_not_defaulted() {
// Q#MS7: a font with no MATH table must surface, not silently
// produce plausible-looking zeros.
let err = MathConstants::from_font_bytes(crate::JETBRAINS_MONO)
.expect_err("JetBrains Mono has no MATH table");
assert_eq!(err, MathFontError::NoMathTable);
assert_eq!(
MathConstants::from_font_bytes(b"not a font"),
Err(MathFontError::Unparseable)
);
}
#[test]
fn font_units_convert_to_pixels_against_upem() {
let c = MathConstants::from_font_bytes(LATIN_MODERN_MATH).expect("constants");
// Half an em at 16 px is 8 px.
let half_em = i16::try_from(c.units_per_em / 2).expect("fits");
assert!((c.to_px(half_em, 16.0) - 8.0).abs() < 0.01);
let scale = c.script_scale();
assert!((0.5..=1.0).contains(&scale), "script scale: {scale}");
}
#[test]
fn math_italic_follows_tex_convention_including_the_planck_hole() {
// Framing acceptance 13.
assert_eq!(math_italic('x'), '\u{1D465}');
assert_eq!(math_italic('A'), '\u{1D434}');
// The 1D4xx run has a hole at italic `h`; arithmetic would land on a
// reserved codepoint, so `h` maps to U+210E instead.
assert_eq!(math_italic('h'), '\u{210E}');
// Lowercase Greek is italic...
assert_eq!(math_italic('α'), '\u{1D6FC}');
assert_eq!(math_italic('ω'), '\u{1D714}');
// ...uppercase Greek is NOT (TeX convention, deliberate).
assert_eq!(math_italic('Γ'), 'Γ');
assert_eq!(math_italic('Ω'), 'Ω');
// Digits and operators stay upright.
assert_eq!(math_italic('2'), '2');
assert_eq!(math_italic('+'), '+');
}
#[test]
fn every_italic_mapping_lands_on_a_real_glyph_in_the_bundled_font() {
// A mapping that produces codepoints the bundled font cannot draw
// would render tofu — worse than the roman fallback it replaced.
let face = Face::parse(LATIN_MODERN_MATH, 0).expect("parse bundled font");
let sample = "abhxyzABXYZαβωΓΩ0129+=";
for ch in sample.chars() {
let mapped = math_italic(ch);
assert!(
face.glyph_index(mapped).is_some(),
"no glyph for {ch:?} -> {mapped:?} (U+{:04X})",
mapped as u32
);
}
}
}

632
pmacs-gpu/src/math_parse.rs Normal file
View File

@ -0,0 +1,632 @@
//! LaTeX math-mode parser for the first inline-math slice.
//!
//! Framing: `docs/inline-math-slice-framing.md` (rev 3), Q#MS2. This parses
//! the deliberately small subset the slice renders — characters, groups,
//! sub/superscripts and fractions — and nothing else. Every other LaTeX
//! construct is an error, which Q#MS8 turns into "show the raw source".
//!
//! The AST is *semantic*, not presentational: `\alpha` resolves to `'α'`
//! here, but the math-italic mapping (Q#MS2's table) belongs to layout, which
//! is where a codepoint becomes a glyph. Keeping the split here means the AST
//! matches what the user wrote, and a future non-italic style context does
//! not have to unpick a decision the parser baked in.
/// One node of the slice's math subset (Q#MS2).
///
/// Rev 2 of the framing folded `Symbol` into `Char`: both carried a `char`,
/// and after symbol resolution layout cannot act on the difference.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum MathNode {
/// A resolved codepoint: `x`, `2`, `+`, `α`.
Char(char),
/// A braced group, or the top-level expression.
Group(Vec<MathNode>),
/// A base with optional sub- and superscript.
Script {
base: Box<MathNode>,
sub: Option<Box<MathNode>>,
sup: Option<Box<MathNode>>,
},
/// `\frac{num}{den}`.
Fraction {
num: Box<MathNode>,
den: Box<MathNode>,
},
}
/// Why a span could not be parsed. Q#MS8 renders the raw source for all of
/// these; the variants exist so tests can assert *which* rejection fired.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum MathParseError {
/// The span held no math (`$$` after delimiter stripping).
Empty,
/// A `{` with no matching `}`, or a stray `}`.
UnbalancedBrace,
/// A control sequence outside the subset, e.g. `\sqrt`.
UnknownCommand(String),
/// `\frac` without two braced arguments.
MalformedCommand(&'static str),
/// `^` or `_` with nothing to attach to, or given twice for one base.
MalformedScript(&'static str),
/// A `$` inside the span: the delimiters are the caller's business, and
/// a bare one here means detection handed us something it should not
/// have (framing acceptance 15 — `$$x$$` degrades through this path).
UnexpectedDollar,
}
/// Greek seed map (Q#MS2). Deliberately partial — growing it is mechanical.
const GREEK: &[(&str, char)] = &[
("alpha", 'α'),
("beta", 'β'),
("gamma", 'γ'),
("delta", 'δ'),
// TeX's \epsilon is LUNATE (U+03F5); U+03B5 is \varepsilon.
("epsilon", '\u{3F5}'),
("zeta", 'ζ'),
("eta", 'η'),
("theta", 'θ'),
("iota", 'ι'),
("kappa", 'κ'),
("lambda", 'λ'),
("mu", 'μ'),
("nu", 'ν'),
("xi", 'ξ'),
("pi", 'π'),
("rho", 'ρ'),
("sigma", 'σ'),
("tau", 'τ'),
("upsilon", 'υ'),
// TeX's \phi is U+03D5; U+03C6 is \varphi.
("phi", '\u{3D5}'),
("chi", 'χ'),
("psi", 'ψ'),
("omega", 'ω'),
("Gamma", 'Γ'),
("Delta", 'Δ'),
("Theta", 'Θ'),
("Lambda", 'Λ'),
("Xi", 'Ξ'),
("Pi", 'Π'),
("Sigma", 'Σ'),
("Upsilon", 'Υ'),
("Phi", 'Φ'),
("Psi", 'Ψ'),
("Omega", 'Ω'),
];
/// Parse the *interior* of a math span — delimiters already stripped.
///
/// # Errors
/// Returns [`MathParseError`] for anything outside the Q#MS2 subset.
pub fn parse(source: &str) -> Result<MathNode, MathParseError> {
let mut parser = Parser {
chars: source.chars().collect(),
pos: 0,
};
let nodes = parser.parse_sequence(None)?;
if parser.pos < parser.chars.len() {
// Only a stray `}` can stop the top-level sequence early.
return Err(MathParseError::UnbalancedBrace);
}
if nodes.is_empty() {
return Err(MathParseError::Empty);
}
Ok(MathNode::Group(nodes))
}
struct Parser {
chars: Vec<char>,
pos: usize,
}
impl Parser {
fn peek(&self) -> Option<char> {
self.chars.get(self.pos).copied()
}
fn bump(&mut self) -> Option<char> {
let ch = self.peek();
if ch.is_some() {
self.pos += 1;
}
ch
}
/// Parse until `close` (or end of input when `None`).
fn parse_sequence(&mut self, close: Option<char>) -> Result<Vec<MathNode>, MathParseError> {
let mut out: Vec<MathNode> = Vec::new();
loop {
// Whitespace is insignificant in math mode, and it must be
// skipped HERE rather than inside `parse_atom`: the `^`/`_`
// dispatch below happens before atoms are read, so leaving a
// space in front of a marker would make `x ^ 2` parse the caret
// as a literal character.
while self.peek().is_some_and(char::is_whitespace) {
self.pos += 1;
}
match self.peek() {
None => {
if close.is_some() {
return Err(MathParseError::UnbalancedBrace);
}
return Ok(out);
}
Some(ch) if Some(ch) == close => {
self.pos += 1;
return Ok(out);
}
// A `}` we were not asked to stop at is unbalanced.
Some('}') => return Err(MathParseError::UnbalancedBrace),
Some('$') => return Err(MathParseError::UnexpectedDollar),
Some('^' | '_') => {
let base = out.pop().ok_or(MathParseError::MalformedScript(
"sub/superscript with no base",
))?;
out.push(self.parse_scripts(base)?);
}
Some(_) => {
let atom = self.parse_atom()?;
out.push(atom);
}
}
}
}
/// One atom: a group, a command, or a single character.
fn parse_atom(&mut self) -> Result<MathNode, MathParseError> {
match self.bump() {
Some('{') => Ok(MathNode::Group(self.parse_sequence(Some('}'))?)),
Some('\\') => self.parse_command(),
Some(ch) => Ok(MathNode::Char(ch)),
None => Err(MathParseError::MalformedCommand("unexpected end of input")),
}
}
fn parse_command(&mut self) -> Result<MathNode, MathParseError> {
let mut name = String::new();
while let Some(ch) = self.peek() {
if ch.is_ascii_alphabetic() {
name.push(ch);
self.pos += 1;
} else {
break;
}
}
if name.is_empty() {
// `\$`, `\{` … — an escaped literal.
return match self.bump() {
Some(ch) => Ok(MathNode::Char(ch)),
None => Err(MathParseError::MalformedCommand("trailing backslash")),
};
}
if name == "frac" {
let num = self.parse_required_group("\\frac numerator")?;
let den = self.parse_required_group("\\frac denominator")?;
return Ok(MathNode::Fraction {
num: Box::new(num),
den: Box::new(den),
});
}
if let Some((_, ch)) = GREEK.iter().find(|(n, _)| *n == name) {
return Ok(MathNode::Char(*ch));
}
Err(MathParseError::UnknownCommand(name))
}
/// A `{…}` argument, skipping leading whitespace.
fn parse_required_group(&mut self, what: &'static str) -> Result<MathNode, MathParseError> {
while self.peek().is_some_and(char::is_whitespace) {
self.pos += 1;
}
match self.peek() {
Some('{') => {
self.pos += 1;
Ok(MathNode::Group(self.parse_sequence(Some('}'))?))
}
_ => Err(MathParseError::MalformedCommand(what)),
}
}
/// Attach `^`/`_` to `base`, in either order, at most one each.
fn parse_scripts(&mut self, base: MathNode) -> Result<MathNode, MathParseError> {
let mut sub: Option<Box<MathNode>> = None;
let mut sup: Option<Box<MathNode>> = None;
loop {
// Whitespace is insignificant, here too: without this skip
// `x^2 _i` builds a nested Script instead of one merged double
// script (drawing the subscript displaced right by the
// superscript's width), and `x^2 ^3` parses where TeX errors.
let resume = self.pos;
while self.peek().is_some_and(char::is_whitespace) {
self.pos += 1;
}
let Some(marker @ ('^' | '_')) = self.peek() else {
self.pos = resume;
break;
};
self.pos += 1;
let slot = self.parse_script_operand()?;
match marker {
'^' if sup.is_some() => {
return Err(MathParseError::MalformedScript("double superscript"));
}
'_' if sub.is_some() => {
return Err(MathParseError::MalformedScript("double subscript"));
}
'^' => sup = Some(Box::new(slot)),
_ => sub = Some(Box::new(slot)),
}
}
Ok(MathNode::Script {
base: Box::new(base),
sub,
sup,
})
}
/// The operand of `^`/`_`: a braced group, or exactly one atom.
fn parse_script_operand(&mut self) -> Result<MathNode, MathParseError> {
while self.peek().is_some_and(char::is_whitespace) {
self.pos += 1;
}
match self.peek() {
None | Some('^' | '_' | '}') => {
Err(MathParseError::MalformedScript("script with no operand"))
}
Some(_) => self.parse_atom(),
}
}
}
/// One detected inline span, as byte offsets into the scanned line.
///
/// `start`/`end` bracket the WHOLE span including both `$` delimiters, which
/// is what Q#MS4 suppresses; [`Self::interior`] is what the parser sees.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct MathSpan {
pub start: usize,
pub end: usize,
}
impl MathSpan {
/// Byte range of the math source between the delimiters.
#[must_use]
pub fn interior(self) -> std::ops::Range<usize> {
self.start + 1..self.end - 1
}
}
/// Find inline `$…$` spans in ONE line (Q#MS3).
///
/// Spans never cross a newline: chunking is per line and the visible slice is
/// line-ranged, so single-line spans are what keep visible-slice-scoped
/// scanning stable under scroll. Callers pass one line at a time.
///
/// Currency guards are mandatory, not a refinement (framing F5). Without
/// them `prices are $5 and $6 today` pairs the two `$` and renders `5 and `
/// as math — in exactly the grammar-less prose buffers this scanner targets.
/// Pandoc's rule:
///
/// - an opening `$` must be followed by a non-space;
/// - a closing `$` must be preceded by a non-space and not followed by a digit;
/// - `\$` is an escape and neither opens nor closes.
#[must_use]
pub fn detect_math_spans(line: &str) -> Vec<MathSpan> {
let bytes = line.as_bytes();
let mut spans = Vec::new();
let mut i = 0;
let mut open: Option<usize> = None;
while i < bytes.len() {
if bytes[i] == b'\\' {
// Skip the escaped byte: `\$` is literal, so it can neither open
// nor close. Stepping two also stops `\\$` from being read as an
// escape of the dollar.
i += 2;
continue;
}
if bytes[i] != b'$' {
i += 1;
continue;
}
if bytes.get(i + 1) == Some(&b'$') {
// `$$` is display math, which this slice defers. It is NOT two
// inline delimiters: reading it that way makes `$$x$$` match the
// inner `$x$`, which parses, so the span would half-render as
// math with a stray `$` on each side. Acceptance 15 requires it
// to degrade to source, so `$$` is opaque — it neither opens nor
// closes, and abandons any pending opener.
i += 2;
open = None;
continue;
}
match open {
None => {
// Opener: next byte must exist and be a non-space.
let opens = bytes
.get(i + 1)
.is_some_and(|b| !b.is_ascii_whitespace() && *b != b'$');
if opens {
open = Some(i);
}
}
Some(start) => {
let prev_ok = i > start + 1 && !bytes[i - 1].is_ascii_whitespace();
let next_ok = bytes.get(i + 1).is_none_or(|b| !b.is_ascii_digit());
if prev_ok && next_ok {
spans.push(MathSpan { start, end: i + 1 });
open = None;
} else if !prev_ok {
// `$foo $` — the closer is disqualified by the space
// before it. Treat this `$` as a fresh opener candidate
// rather than letting the span run to the next one.
open = bytes
.get(i + 1)
.is_some_and(|b| !b.is_ascii_whitespace() && *b != b'$')
.then_some(i);
}
}
}
i += 1;
}
spans
}
#[cfg(test)]
mod tests {
use super::*;
fn ch(c: char) -> MathNode {
MathNode::Char(c)
}
fn group(nodes: Vec<MathNode>) -> MathNode {
MathNode::Group(nodes)
}
#[test]
fn detection_finds_inline_spans() {
assert_eq!(
detect_math_spans("$x^2$"),
vec![MathSpan { start: 0, end: 5 }]
);
let two = detect_math_spans("$a$ and $b$");
assert_eq!(two.len(), 2, "{two:?}");
let line = "before $x^2$ after";
let span = detect_math_spans(line)[0];
assert_eq!(&line[span.start..span.end], "$x^2$");
assert_eq!(&line[span.interior()], "x^2");
}
/// Framing F5 / acceptance 2 — the case rev 1's rule would have
/// mis-rendered as math over "5 and ".
#[test]
fn currency_guards_reject_prose_dollars() {
assert!(detect_math_spans("Price: $5.00").is_empty());
assert!(
detect_math_spans("prices are $5 and $6 today").is_empty(),
"a digit after the closer disqualifies it"
);
assert!(
detect_math_spans("$ x $").is_empty(),
"space after the opener disqualifies it"
);
assert!(
detect_math_spans("costs $5 or $6").is_empty(),
"both guards together"
);
}
#[test]
fn escaped_dollars_neither_open_nor_close() {
assert!(detect_math_spans(r"\$5 and \$6").is_empty());
// An escaped dollar inside a span does not close it.
let line = r"$a\$b$";
let spans = detect_math_spans(line);
assert_eq!(spans.len(), 1);
assert_eq!(&line[spans[0].start..spans[0].end], r"$a\$b$");
}
#[test]
fn an_unpaired_dollar_yields_nothing() {
assert!(detect_math_spans("$x").is_empty());
assert!(detect_math_spans("x$").is_empty());
// Q#MS3: spans never cross a newline. Callers scan per line, so a
// partner on the next line is simply not visible to this call.
assert!(detect_math_spans("$x").is_empty());
assert!(detect_math_spans("y$").is_empty());
}
#[test]
fn empty_and_display_delimiters_degrade_rather_than_half_match() {
// Acceptance 15. `$$` is opaque, so display math yields NO span and
// falls through to source. Asserting emptiness rather than "any span
// found must fail to parse" matters: the interior of the inner `$x$`
// parses perfectly well, so the weaker form passed vacuously while
// `$$x$$` half-rendered with a stray `$` on each side.
assert!(detect_math_spans("$$").is_empty());
assert!(
detect_math_spans("$$x$$").is_empty(),
"display math must not match the inner $x$"
);
assert!(detect_math_spans(r"$$\frac{a}{b}$$").is_empty());
// A real inline span beside display math is still found.
let mixed = detect_math_spans("$a$ then $$b$$");
assert_eq!(mixed.len(), 1, "{mixed:?}");
}
/// Round-3 F6 — a DOCUMENTED casualty of the `$$`-opaque rule, not a
/// guard failure: in `$a$$b$` the first span's legitimate closer is
/// immediately followed by the second span's opener, the lookahead
/// reads that pair as display-math `$$`, and the pending opener is
/// abandoned. Adjacent inline spans therefore need a separating
/// character. Pandoc finds two spans here; this scanner deliberately
/// finds none, because distinguishing `$a$$b$` from `$$x$$` requires
/// closer-context the framing's opaque-`$$` rule gave away.
#[test]
fn adjacent_inline_spans_are_eaten_by_the_display_guard() {
assert!(detect_math_spans("$a$$b$").is_empty());
assert!(detect_math_spans("$x^2$$y^2$").is_empty());
// One separating character restores both spans.
assert_eq!(detect_math_spans("$a$ $b$").len(), 2);
}
#[test]
fn whitespace_before_a_script_marker_still_merges_the_scripts() {
// F2: without skipping whitespace in `parse_scripts`, `x^2 _i` built
// a NESTED script and drew the subscript displaced right.
assert_eq!(parse("x^2 _i"), parse("x^2_i"));
assert_eq!(parse("x _i ^2"), parse("x_i^2"));
// And a doubled script is still an error with space between.
assert!(matches!(
parse("x^2 ^3"),
Err(MathParseError::MalformedScript(_))
));
}
#[test]
fn the_greek_seed_uses_tex_letter_forms() {
// F5: TeX's \epsilon is lunate and \phi is the symbol form; the
// U+03B5 / U+03C6 glyphs are \varepsilon / \varphi.
assert_eq!(parse(r"\epsilon"), Ok(group(vec![ch('\u{3F5}')])));
assert_eq!(parse(r"\phi"), Ok(group(vec![ch('\u{3D5}')])));
}
#[test]
fn plain_characters_parse_in_order() {
assert_eq!(parse("x+1"), Ok(group(vec![ch('x'), ch('+'), ch('1')])));
}
#[test]
fn superscript_and_subscript_attach_to_the_preceding_atom() {
// Framing acceptance 1.
assert_eq!(
parse("x^2"),
Ok(group(vec![MathNode::Script {
base: Box::new(ch('x')),
sub: None,
sup: Some(Box::new(ch('2'))),
}]))
);
assert_eq!(
parse("x_i"),
Ok(group(vec![MathNode::Script {
base: Box::new(ch('x')),
sub: Some(Box::new(ch('i'))),
sup: None,
}]))
);
}
#[test]
fn both_scripts_parse_in_either_order() {
let expected = MathNode::Script {
base: Box::new(ch('x')),
sub: Some(Box::new(ch('i'))),
sup: Some(Box::new(ch('2'))),
};
assert_eq!(parse("x_i^2"), Ok(group(vec![expected.clone()])));
assert_eq!(parse("x^2_i"), Ok(group(vec![expected])));
}
#[test]
fn braced_script_operands_group() {
assert_eq!(
parse("x^{i+1}"),
Ok(group(vec![MathNode::Script {
base: Box::new(ch('x')),
sub: None,
sup: Some(Box::new(group(vec![ch('i'), ch('+'), ch('1')]))),
}]))
);
}
#[test]
fn fraction_takes_two_braced_arguments() {
assert_eq!(
parse(r"\frac{a}{b}"),
Ok(group(vec![MathNode::Fraction {
num: Box::new(group(vec![ch('a')])),
den: Box::new(group(vec![ch('b')])),
}]))
);
}
#[test]
fn fractions_nest() {
// Framing acceptance 1 and 12's over-tall candidate.
let inner = MathNode::Fraction {
num: Box::new(group(vec![ch('a')])),
den: Box::new(group(vec![ch('b')])),
};
assert_eq!(
parse(r"\frac{\frac{a}{b}}{c}"),
Ok(group(vec![MathNode::Fraction {
num: Box::new(group(vec![inner])),
den: Box::new(group(vec![ch('c')])),
}]))
);
}
#[test]
fn greek_seed_resolves_to_codepoints_not_markup() {
assert_eq!(parse(r"\alpha"), Ok(group(vec![ch('α')])));
assert_eq!(parse(r"\Gamma"), Ok(group(vec![ch('Γ')])));
// The AST stays semantic: no italic mapping here (that is layout's,
// per this module's header and Q#MS2).
assert_eq!(parse(r"\alpha x"), Ok(group(vec![ch('α'), ch('x')])));
}
#[test]
fn whitespace_is_insignificant() {
assert_eq!(parse("x ^ 2"), parse("x^2"));
assert_eq!(parse(r"\frac {a} {b}"), parse(r"\frac{a}{b}"));
}
#[test]
fn subset_violations_are_errors_not_panics() {
// Framing acceptance 1 and 9.
assert_eq!(parse(""), Err(MathParseError::Empty));
assert_eq!(parse(" "), Err(MathParseError::Empty));
assert_eq!(parse("{a"), Err(MathParseError::UnbalancedBrace));
assert_eq!(parse("a}"), Err(MathParseError::UnbalancedBrace));
assert_eq!(
parse(r"\sqrt{2}"),
Err(MathParseError::UnknownCommand("sqrt".to_owned()))
);
assert!(matches!(
parse(r"\frac{a}"),
Err(MathParseError::MalformedCommand(_))
));
assert!(matches!(
parse(r"\frac a b"),
Err(MathParseError::MalformedCommand(_))
));
assert!(matches!(
parse("^2"),
Err(MathParseError::MalformedScript(_))
));
assert!(matches!(
parse("x^"),
Err(MathParseError::MalformedScript(_))
));
assert!(matches!(
parse("x^2^3"),
Err(MathParseError::MalformedScript(_))
));
}
#[test]
fn an_interior_dollar_is_rejected_so_display_math_degrades() {
// Framing acceptance 15: `$$x$$` reaches us as the interior `$x$`
// (outer delimiters stripped), and must degrade to source rather
// than half-render. The empty-span path covers `$$` alone.
assert_eq!(parse("$x$"), Err(MathParseError::UnexpectedDollar));
assert_eq!(parse(""), Err(MathParseError::Empty));
}
#[test]
fn escaped_literals_survive_as_characters() {
assert_eq!(parse(r"\{"), Ok(group(vec![ch('{')])));
assert_eq!(parse(r"\$"), Ok(group(vec![ch('$')])));
}
}

View File

@ -71,8 +71,8 @@ use crossbeam::channel as cb_channel;
use serde::{Deserialize, Serialize};
use crate::fs::{
FsDirEntry, FsError, chmod_blocking, read_dir_blocking, remove_blocking, rename_blocking,
stat_blocking,
FsDirEntry, FsDirListing, FsError, ReadDirTolerance, chmod_blocking, read_dir_blocking,
remove_blocking, rename_blocking, stat_blocking,
};
use crate::message_bus::{BusEnd, MessageBus, SchemaRegistry};
use crate::syntax::{self as syntax_mod, ParseRequest, ParseTreeBundle};
@ -220,9 +220,10 @@ enum ReplyKind {
/// T M4.1.
Parse { duration_ms: u64 },
/// `dispatch_fs_read_dir` completed; payload is the directory
/// listing. The Vec is `Serialize` so it crosses the bus
/// directly --- no side handoff like parse trees need. T M8.1.
ReadDir(Vec<FsDirEntry>),
/// listing. The listing is `Serialize` so it crosses the bus
/// directly --- no side handoff like parse trees need. T M8.1; its
/// per-entry error channel is dired Q#DR6.
ReadDir(FsDirListing),
/// `dispatch_fs_stat` completed; payload is the per-path
/// metadata. T M8.1.
Stat(FsDirEntry),
@ -266,10 +267,11 @@ pub enum JobResult {
duration_ms: u64,
},
/// `dispatch_fs_read_dir` produced a directory listing. The
/// Lua boundary in [`crate::lua_bindings`] turns the Vec into a
/// per-entry table when `_take_result` consumes the result.
/// T M8.1.
ReadDir(Vec<FsDirEntry>),
/// Lua boundary in [`crate::lua_bindings`] turns the entries into
/// per-entry tables when `_take_result` consumes the result, and
/// keys the result *shape* on whether the listing carries a
/// per-entry error channel. T M8.1 / dired Q#DR6.
ReadDir(FsDirListing),
/// `dispatch_fs_stat` produced metadata for a single path. The
/// Lua boundary turns the [`FsDirEntry`] into the same table
/// shape `read_dir` entries use. T M8.1.
@ -832,11 +834,21 @@ impl AsyncRuntime {
/// `lstat`-style metadata. Polls cancel every batch of
/// entries; supersede follows the same rule as the other
/// dispatchers. T M8.1.
pub fn dispatch_fs_read_dir(&self, path: PathBuf, supersede: Option<&str>) -> JobId {
///
/// `tolerance` selects the per-entry contract (dired Q#DR6):
/// [`ReadDirTolerance::Fatal`] is the original all-or-nothing
/// listing, [`ReadDirTolerance::PerEntry`] carries per-entry
/// failures alongside the entries that survived.
pub fn dispatch_fs_read_dir(
&self,
path: PathBuf,
tolerance: ReadDirTolerance,
supersede: Option<&str>,
) -> JobId {
let (id, cancel) = self.allocate(JobKind::FsReadDir, supersede, None);
let bus = self.workers.clone();
self.pool.dispatch(move |_pool| {
let kind = run_fs_read_dir(&cancel, &path);
let kind = run_fs_read_dir(&cancel, &path, tolerance);
let _ = bus.send(ASYNC_REPLY_TOPIC, &WorkerReply { job_id: id, kind });
});
id
@ -1038,8 +1050,8 @@ impl AsyncRuntime {
ReplyKind::Parse { duration_ms } => {
PendingState::Complete(JobResult::Parse { duration_ms })
}
ReplyKind::ReadDir(entries) => {
PendingState::Complete(JobResult::ReadDir(entries))
ReplyKind::ReadDir(listing) => {
PendingState::Complete(JobResult::ReadDir(listing))
}
ReplyKind::Stat(entry) => PendingState::Complete(JobResult::Stat(entry)),
ReplyKind::Json(v) => PendingState::Complete(JobResult::Json(v)),
@ -1295,9 +1307,13 @@ fn run_sleep(cancel: &CancellationToken, total: Duration) -> ReplyKind {
/// [`FsError::Cancelled`] becomes [`ReplyKind::Cancelled`];
/// [`FsError::Io`] becomes [`ReplyKind::Error`] with the
/// human-readable message attached.
fn run_fs_read_dir(cancel: &CancellationToken, path: &Path) -> ReplyKind {
match read_dir_blocking(path, cancel) {
Ok(entries) => ReplyKind::ReadDir(entries),
fn run_fs_read_dir(
cancel: &CancellationToken,
path: &Path,
tolerance: ReadDirTolerance,
) -> ReplyKind {
match read_dir_blocking(path, cancel, tolerance) {
Ok(listing) => ReplyKind::ReadDir(listing),
Err(FsError::Cancelled) => ReplyKind::Cancelled,
Err(e @ (FsError::Io { .. } | FsError::NonUtf8Path { .. })) => {
ReplyKind::Error(e.to_string())

View File

@ -2790,6 +2790,88 @@ mod tests {
}
}
/// Deterministic reduction of a `rope_matches_crdt_projection_
/// after_arbitrary_edits` failure found by raising the case
/// count (`PROPTEST_CASES=2000`) on `main` @ `e745068`.
///
/// **What happens.** Replacing a byte range with *identical*
/// bytes is a textual no-op but a real CRDT operation (a delete
/// plus an insert). Undoing it therefore advances the CRDT
/// version while leaving the materialized text unchanged, so
/// `undo_crdt_mode` derives an EMPTY replacement edit — and
/// still attaches the `crdt_op` that `crdt.undo()` produced.
/// That trips the proptest's `crdt_op` shape invariant, "a
/// no-op edit must have `crdt_op = None`".
///
/// **What was verified about the consequences**, so the next
/// reader does not have to redo it:
///
/// * content stays correct — rope and CRDT projection agree
/// before and after (asserted below);
/// * replicas stay converged — both `crdt_op` consumers
/// (`EditorCore::queue_daemon_origin_crdt_op` and the remote-op
/// path) read `edit.crdt_op` unconditionally and do **not**
/// short-circuit on an empty range, so the op is broadcast;
/// * the cursor does not jump — `EditorCore::undo` only clamps
/// to buffer length and never seeks `edit.range.start`.
///
/// **The open question** is therefore whether the *invariant* is
/// simply mis-scoped rather than the behavior being wrong. It
/// was written for the FORWARD `apply_edit` short-circuit, which
/// returns before ever producing an op; CRDT-mode undo/redo
/// never reach that path. One artifact is genuinely arbitrary
/// either way: `derive_replacement_edit` reports the empty range
/// at the buffer END rather than at the edit site.
///
/// Ignored, not deleted: it documents a real, reproducible
/// asymmetry that nothing else on `main` records, and un-ignoring
/// it is the first step of whichever resolution wins.
#[test]
#[ignore = "known pre-existing main behavior; see the doc comment \
for the verified consequences and the open question"]
fn crdt_undo_of_an_identity_replace_reports_a_no_op_edit_carrying_an_op() {
let mut buffer =
Buffer::new_with_crdt(BufferId::next(), "*identity-undo*", 1).expect("crdt");
buffer
.apply_edit(EditOp::Insert {
pos: 0,
bytes: b"hello",
})
.expect("seed insert");
// Replace one byte with the SAME byte.
let forward = buffer
.apply_edit(EditOp::Replace {
range: Range::new(1, 2),
bytes: b"e",
})
.expect("identity replace");
assert_eq!(forward.range, Range::new(1, 2));
assert_eq!(forward.inserted_len, 1);
let undone = buffer.undo().expect("undo");
assert!(
undone.range.is_empty() && undone.inserted_len == 0,
"the undo produced no textual change: {:?}/{}",
undone.range,
undone.inserted_len
);
assert!(
undone.crdt_op.is_some(),
"…yet it carries a version-advancing CRDT op — the invariant \
the proptest trips on"
);
// Content is unharmed in both projections.
let mut bytes = vec![0u8; buffer.len() as usize];
buffer.snapshot_rope().slice(0, buffer.len(), &mut bytes);
assert_eq!(String::from_utf8(bytes).expect("utf8"), "hello");
assert_eq!(
buffer.crdt_state().expect("crdt").materialize_string(),
"hello"
);
}
proptest! {
// Smaller proptest case count than the default (64) to keep
// CI overhead modest; the per-op invariant check is the

View File

@ -525,6 +525,17 @@ impl EditorState {
include_str!("../builtin/runtime/window.lua"),
)
.expect("load window builtin chunk");
// Dired Stage 1: the directory view. Loaded AFTER window.lua,
// whose `window.panel-height` setting a `display = "panel"`
// listing resolves, and after the pre-runtime tables it drives
// (`pmacs.config` / `command` / `keymap` / `buffer` / `editor` /
// `minibuffer` / `path`, plus `pmacs.fs` from fs.lua above).
lua_host
.eval(
Some("@pmacs/builtin/runtime/dired.lua"),
include_str!("../builtin/runtime/dired.lua"),
)
.expect("load dired builtin chunk");
// Compile-mode (Arc 5 stage 1, Q#CM1) — ORDERING CONTRACT:
// compile.lua must load AFTER lsp.lua. It takes over
// `M-g n` / `M-g p` for the unified error dispatchers, and
@ -978,19 +989,28 @@ impl EditorState {
.get(&frontend_id)
.is_some_and(|state| state.terminal_escape);
if let Some(view_key) = terminal_key {
// Q#TC4: the escape chord is per terminal, resolved through
// `terminal.escape-key` and cached on the session so this
// hot path parses at most once per (terminal, config epoch).
let escape_chord = self.terminal_escape_chord(view_key.buffer_id);
if escaped {
self.dispatchers
.entry(frontend_id)
.or_default()
.terminal_escape = false;
if chord.is_some_and(is_terminal_escape_chord) {
if chord == Some(escape_chord) {
// Q#TC4b: repeating the escape sends THAT chord to the
// child, not a hardcoded ETX. With a configured escape
// of `C-x`, sending Ctrl-C here would both surprise the
// user and make literal Ctrl-X unreachable, since the
// first press is always consumed as the escape.
self.claim_terminal_controller(view_key);
self.send_terminal_bytes(view_key.buffer_id, &[0x03]);
self.send_terminal_escape_literal(view_key, escape_chord);
return;
}
// The post-escape key starts a fresh ordinary sequence below.
} else if !dispatcher_pending {
if chord.is_some_and(is_terminal_escape_chord) {
if chord == Some(escape_chord) {
let state = self.dispatchers.entry(frontend_id).or_default();
state.terminal_escape = true;
state.dispatcher = KeyDispatcher::new();
@ -1106,6 +1126,54 @@ impl EditorState {
.then_some(key)
}
/// This terminal's effective escape chord (Q#TC4).
///
/// Resolution is `get("terminal.escape-key", terminal_buffer)` —
/// buffer-local, then global, then default — because unlike the two
/// open-time settings this one is read while the terminal exists, so
/// a per-terminal escape is expressible and supported (Q#TC2b).
///
/// The parse and the once-per-terminal invalid-value report both live
/// in [`crate::terminal::TerminalManager::escape_chord`]; this method
/// only supplies the resolved spelling and the epoch that keys the
/// cache, and surfaces any report through the status line — the same
/// channel `send_terminal_bytes` uses for terminal failures.
fn terminal_escape_chord(&self, buffer_id: crate::buffer::BufferId) -> Chord {
let lua = self.lua_host.lua();
let (spelling, epoch) = crate::lua_bindings::config_string_and_epoch(
lua,
"terminal.escape-key",
Some(buffer_id),
crate::terminal::DEFAULT_TERMINAL_ESCAPE_KEY,
);
let (chord, report) = self
.terminal_manager
.borrow_mut()
.escape_chord(buffer_id, epoch, &spelling);
if let Some(message) = report {
self.core.borrow_mut().status = message;
}
chord
}
/// Send the configured escape chord to the child as literal input
/// (Q#TC4b), through the same encoder ordinary keys use so it
/// inherits application-cursor and modifier handling.
fn send_terminal_escape_literal(&self, key: TerminalViewKey, chord: Chord) {
let event = KeyEvent::new(chord.code, chord.modifiers);
let Some((terminal_key, modifiers)) = terminal_key_from_crossterm(event) else {
return;
};
let modes = self
.terminal_manager
.borrow()
.modes_for_view(key)
.unwrap_or_default();
if let Some(bytes) = crate::terminal::input::encode_key(terminal_key, modifiers, modes) {
self.send_terminal_bytes(key.buffer_id, &bytes);
}
}
fn claim_terminal_controller(&self, key: TerminalViewKey) {
let mut manager = self.terminal_manager.borrow_mut();
let _ = manager.register_view(key);
@ -4410,10 +4478,6 @@ fn sanitize_single_line(s: &str) -> String {
.collect()
}
fn is_terminal_escape_chord(chord: Chord) -> bool {
chord.code == KeyCode::Char('c') && chord.modifiers == KeyModifiers::CONTROL
}
fn terminal_key_from_crossterm(key: KeyEvent) -> Option<(TerminalKey, TerminalModifiers)> {
let modifiers = crate::protocol::crossterm_translate::mods_from_crossterm(key.modifiers);
let key = crate::protocol::crossterm_translate::keycode_from_crossterm(key.code);
@ -5706,17 +5770,25 @@ mod tests {
// ---- T M2.11 acceptance --------------------------------------------------
/// Every chord in the default global keymap must round-trip through
/// Every chord in the default keymap must round-trip through
/// `pmacs.describe.key`: returning a non-nil table whose `command`
/// matches the binding the keymap stack stores.
///
/// `describe.key` resolves against the **effective context**
/// (buffer-local → mode → global), so a mode-scoped default is
/// asserted with a buffer that carries that mode rather than
/// context-free. Dired is the first builtin to bind mode-scoped keys
/// (#129's first non-detection consumer), and without the mode in
/// place its `n` / `p` / `g` correctly resolve to nothing.
#[test]
fn describe_key_identifies_every_default_binding() {
use crate::keymap_stack::Scope;
let s = EditorState::new();
let kms = s.lua_host.keymaps().borrow();
let bindings: Vec<(String, String)> = kms
let bindings: Vec<(Scope, String, String)> = kms
.iter_all()
.into_iter()
.map(|(_, seq, b)| (crate::key::display_sequence(&seq), b.command))
.map(|(scope, seq, b)| (scope, crate::key::display_sequence(&seq), b.command))
.collect();
drop(kms);
// Sanity floor: the default keymap binds at least the M1 surface.
@ -5725,18 +5797,50 @@ mod tests {
"default keymap unexpectedly small: {} bindings",
bindings.len()
);
let modes: usize = bindings
.iter()
.filter(|(scope, _, _)| matches!(scope, Scope::Mode(_)))
.count();
assert!(
modes >= 1,
"a mode-scoped default is expected since dired Stage 1; \
found none, so the mode arm below asserts nothing"
);
for (seq, expected_command) in &bindings {
for (scope, seq, expected_command) in &bindings {
let mode = match scope {
Scope::Mode(name) => Some(name.clone()),
// No buffer-scoped defaults exist; a future one would
// need its own buffer context here.
Scope::Buffer(_) => continue,
Scope::Global => None,
};
// Set the context explicitly on EVERY iteration, including
// the global one: a mode left over from a previous iteration
// legitimately shadows a global binding of the same chord
// (dired's mode-scoped `RET` shadows
// `edit.newline-and-indent`, which is the point of the
// mode), so a leaked mode would make this assert the wrong
// thing.
let context = match &mode {
Some(name) => {
format!("pmacs.buffer.set_major_mode(pmacs.window.buffer(), {name:?}); ")
}
None => "pmacs.buffer.set_major_mode(pmacs.window.buffer(), nil); ".to_owned(),
};
let script = format!(
"local r = pmacs.describe.key({seq:?}); \
"{context}local r = pmacs.describe.key({seq:?}); \
if r == nil then return 'nil' else return r.command end"
);
let got: String = s.lua_host.lua().load(&script).eval().unwrap_or_else(|e| {
panic!("describe.key({seq}) raised: {e}");
});
assert_eq!(
&got, expected_command,
"describe.key for {seq:?} returned {got:?}, expected {expected_command:?}"
&got,
expected_command,
"describe.key for {seq:?} (scope {}) returned {got:?}, \
expected {expected_command:?}",
scope.render()
);
}
}

View File

@ -4787,7 +4787,14 @@ fn backward_word(buf: &Buffer, mut pos: Position) -> Position {
/// path's on-disk identity. Every step is best-effort — if `$HOME`
/// or the cwd is unavailable the path is returned as far as it could
/// be resolved rather than panicking.
fn normalize_buffer_path(path: PathBuf) -> PathBuf {
///
/// Public because dired needs the *same* canonical form the buffer
/// registry keys on (Q#DR2): its buffer-per-directory naming and
/// `find_buffer_for_path`'s dedup have to agree, and a Lua-side mirror
/// of this function would be a second implementation of a canonical
/// form — the tab-width-constants class in miniature. `pmacs.path
/// .canonicalize` is this function, not a copy of it.
pub fn normalize_buffer_path(path: PathBuf) -> PathBuf {
let path = expand_tilde(path);
let abs = if path.is_absolute() {
path

329
src/fs.rs
View File

@ -42,6 +42,28 @@ use crate::worker::CancellationToken;
/// directories.
const READDIR_CANCEL_POLL_EVERY: usize = 32;
/// How many *consecutive* `readdir` iterator errors a tolerant listing
/// records before giving up and failing (dired Q#DR6).
///
/// [`std::fs::ReadDir`] is not obliged to terminate after yielding an
/// `Err`: a directory pulled out from under a stalled network mount can
/// keep producing them. Tolerant mode records-and-continues, so without
/// a bound that is an unbounded error vector on a worker thread.
///
/// Cancellation is **not** an adequate backstop here, which is the
/// reason this constant exists rather than a comment saying it is: a
/// dired listing carries no supersede key and nothing cancels it, so the
/// only thing that would stop the loop is the directory itself. A
/// directory whose iterator produces nothing but errors has no partial
/// answer worth rendering, so the listing fails with the last error the
/// way an unopenable directory does.
///
/// Deliberately untested: forcing a real `readdir` to yield errors
/// repeatedly is not portable, and faking it would need the walk to be
/// generic over its iterator — a refactor with no other consumer. The
/// counter resets on any entry that materializes.
const READDIR_MAX_CONSECUTIVE_ENTRY_ERRORS: usize = 1024;
/// One directory entry as returned by [`read_dir_blocking`].
///
/// The shape is what `dired` / `magit-class` / `outline-class`
@ -116,6 +138,59 @@ impl FsEntryKind {
}
}
/// Per-entry tolerance for [`read_dir_blocking`] (dired Q#DR6).
///
/// The M8.1 primitive was all-or-nothing: five per-entry conditions
/// failed the *entire* listing, which makes a plain refresh of a busy
/// directory (`/tmp`, a build tree) fail outright. The module doc used
/// to say a per-entry-tolerant wrapper was "the package's job" --- it
/// cannot be: the primitive hands Lua one structured error and no
/// partial vec, so there is nothing to be tolerant *with*.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ReadDirTolerance {
/// Any per-entry failure fails the whole listing. The original
/// M8.1 contract, and still the default at every Lua call site
/// that does not opt in.
Fatal,
/// Per-entry failures are recorded in [`FsDirListing::errors`] and
/// enumeration continues. A failure on the *parent* `read_dir`
/// stays fatal (a directory you cannot open has no partial
/// answer), and so does a non-UTF-8 entry **name** --- see
/// [`FsError::NonUtf8Path`].
PerEntry,
}
/// One per-entry failure recorded by a tolerant [`read_dir_blocking`].
///
/// `name` is optional because a per-entry `readdir` *iterator* error
/// has no filename to report: the entry never materialized, and the
/// underlying error is about the parent directory. Every other arm has
/// an entry in hand and names it.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct FsDirEntryError {
/// Basename of the entry that failed, when one is known.
pub name: Option<String>,
/// Rendered failure, already formatted for display.
pub message: String,
}
/// What [`read_dir_blocking`] returns: the entries it could read, plus
/// the per-entry failures when the caller asked to tolerate them.
///
/// `errors` is `None` under [`ReadDirTolerance::Fatal`] and `Some`
/// (possibly empty) under [`ReadDirTolerance::PerEntry`]. The
/// distinction is load-bearing at the Lua boundary: it is what selects
/// the bare-array result shape the M8.1 surface promises from the
/// `{ entries = …, errors = … }` shape the tolerant opt returns, so the
/// conversion never has to look the job back up.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct FsDirListing {
/// One entry per readable child, in filesystem iteration order.
pub entries: Vec<FsDirEntry>,
/// Per-entry failures; `None` in [`ReadDirTolerance::Fatal`] mode.
pub errors: Option<Vec<FsDirEntryError>>,
}
/// Errors produced by [`read_dir_blocking`] / [`stat_blocking`] /
/// [`rename_blocking`] / [`chmod_blocking`] / [`remove_blocking`].
///
@ -192,50 +267,100 @@ pub enum FsError {
/// `to_string_lossy` would have mangled dired/wdired round-trips).
///
/// Errors on the *parent* `read_dir` call surface as
/// [`FsError::Io`]. Errors on individual entries (a single broken
/// symlink, a permission-denied stat) currently propagate the same
/// way --- the cleanest behavior at this primitive layer is "fail
/// fast and let the caller decide whether a partial listing is
/// acceptable"; dired-class will likely want a per-entry-tolerant
/// wrapper but that's the package's job, not the primitive's.
/// [`FsError::Io`] regardless of `tolerance`: a directory you cannot
/// open has no partial answer.
///
/// Errors on individual entries (a permission-denied `lstat`, a child
/// unlinked between `readdir` and `lstat`, a `readlink` failure, a
/// non-UTF-8 symlink target) are governed by `tolerance`. Under
/// [`ReadDirTolerance::Fatal`] they fail the whole listing, which is
/// the M8.1 contract every existing caller relies on; under
/// [`ReadDirTolerance::PerEntry`] they land in
/// [`FsDirListing::errors`] and enumeration continues (dired Q#DR6).
///
/// A non-UTF-8 entry **name** is fatal in both modes. That is not a
/// listing problem but a path-representation one: [`FsDirEntry::name`]
/// is a `String` and every `pmacs.fs` op takes a `String` path, so a
/// tolerantly-rendered non-UTF-8 name would be a name the caller could
/// not pass back through `rename`. Byte-preserving paths are the named
/// deferral (see [`FsError::NonUtf8Path`]). A non-UTF-8 *target*
/// differs in kind --- the entry's own name is fine and nothing needs
/// to round-trip the target --- so it joins the per-entry channel.
pub fn read_dir_blocking(
path: &Path,
cancel: &CancellationToken,
) -> Result<Vec<FsDirEntry>, FsError> {
tolerance: ReadDirTolerance,
) -> Result<FsDirListing, FsError> {
let iter = std::fs::read_dir(path).map_err(|source| FsError::Io {
path: path.display().to_string(),
source,
})?;
let mut out: Vec<FsDirEntry> = Vec::new();
let mut errors: Option<Vec<FsDirEntryError>> =
matches!(tolerance, ReadDirTolerance::PerEntry).then(Vec::new);
let parent_str = path.display().to_string();
let mut consecutive_entry_errors = 0usize;
for (i, entry_result) in iter.enumerate() {
if i % READDIR_CANCEL_POLL_EVERY == 0 && cancel.is_cancelled() {
return Err(FsError::Cancelled);
}
let entry = entry_result.map_err(|source| FsError::Io {
path: parent_str.clone(),
source,
})?;
let entry = match entry_result {
Ok(entry) => entry,
Err(source) => {
// R2-2: the entry never materialized, so there is no
// name to report and the error names the parent.
let error = FsError::Io {
path: parent_str.clone(),
source,
};
consecutive_entry_errors += 1;
if consecutive_entry_errors > READDIR_MAX_CONSECUTIVE_ENTRY_ERRORS {
return Err(error);
}
record_entry_error(&mut errors, None, error)?;
continue;
}
};
consecutive_entry_errors = 0;
let entry_path = entry.path();
let metadata = std::fs::symlink_metadata(&entry_path).map_err(|source| FsError::Io {
path: entry_path.display().to_string(),
source,
})?;
let kind = classify(&metadata);
let symlink_target = if matches!(kind, FsEntryKind::Symlink) {
match std::fs::read_link(&entry_path) {
Ok(t) => Some(path_to_utf8_string(t.as_os_str(), &parent_str)?),
Err(source) => {
return Err(FsError::Io {
// Resolved first so a later per-entry failure can name it.
let name = path_to_utf8_string(&entry.file_name(), &parent_str)?;
let metadata = match std::fs::symlink_metadata(&entry_path) {
Ok(metadata) => metadata,
Err(source) => {
record_entry_error(
&mut errors,
Some(&name),
FsError::Io {
path: entry_path.display().to_string(),
source,
});
}
},
)?;
continue;
}
} else {
None
};
let name = path_to_utf8_string(&entry.file_name(), &parent_str)?;
let kind = classify(&metadata);
let mut symlink_target = None;
if matches!(kind, FsEntryKind::Symlink) {
match std::fs::read_link(&entry_path) {
// A target we cannot represent leaves the entry in the
// listing with its target unknown, not the entry out of
// it: one weird symlink in `/tmp` used to take the
// whole directory down.
Ok(target) => match path_to_utf8_string(target.as_os_str(), &parent_str) {
Ok(target) => symlink_target = Some(target),
Err(error) => record_entry_error(&mut errors, Some(&name), error)?,
},
Err(source) => record_entry_error(
&mut errors,
Some(&name),
FsError::Io {
path: entry_path.display().to_string(),
source,
},
)?,
}
}
out.push(FsDirEntry {
name,
kind,
@ -246,7 +371,33 @@ pub fn read_dir_blocking(
symlink_target,
});
}
Ok(out)
Ok(FsDirListing {
entries: out,
errors,
})
}
/// Route one per-entry failure: append it to the tolerant channel, or
/// propagate it when the caller asked for the fatal contract.
///
/// `errors.is_none()` *is* [`ReadDirTolerance::Fatal`] --- keeping the
/// mode in the accumulator rather than passing it separately makes the
/// two impossible to disagree.
fn record_entry_error(
errors: &mut Option<Vec<FsDirEntryError>>,
name: Option<&str>,
error: FsError,
) -> Result<(), FsError> {
match errors {
Some(list) => {
list.push(FsDirEntryError {
name: name.map(ToOwned::to_owned),
message: error.to_string(),
});
Ok(())
}
None => Err(error),
}
}
/// Convert an [`std::ffi::OsStr`] to `String` strictly. Returns
@ -495,12 +646,23 @@ mod tests {
CancellationToken::new()
}
/// The fatal-mode shorthand every pre-Q#DR6 test used.
fn read_dir_fatal(path: &Path, cancel: &CancellationToken) -> Result<Vec<FsDirEntry>, FsError> {
read_dir_blocking(path, cancel, ReadDirTolerance::Fatal).map(|listing| {
assert!(
listing.errors.is_none(),
"fatal mode must not open a per-entry channel"
);
listing.entries
})
}
#[test]
fn read_dir_returns_entries_with_lstat_metadata() {
let td = tempfile::tempdir().expect("tempdir");
std::fs::write(td.path().join("a.txt"), b"hello").expect("write");
std::fs::create_dir(td.path().join("subdir")).expect("mkdir");
let entries = read_dir_blocking(td.path(), &token()).expect("read_dir");
let entries = read_dir_fatal(td.path(), &token()).expect("read_dir");
let mut names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
names.sort_unstable();
assert_eq!(names, vec!["a.txt", "subdir"]);
@ -516,7 +678,7 @@ mod tests {
let td = tempfile::tempdir().expect("tempdir");
std::fs::write(td.path().join("real.txt"), b"x").expect("write");
symlink("real.txt", td.path().join("link")).expect("symlink");
let entries = read_dir_blocking(td.path(), &token()).expect("read_dir");
let entries = read_dir_fatal(td.path(), &token()).expect("read_dir");
let link = entries.iter().find(|e| e.name == "link").unwrap();
assert_eq!(link.kind, FsEntryKind::Symlink);
assert_eq!(link.symlink_target.as_deref(), Some("real.txt"));
@ -535,7 +697,7 @@ mod tests {
}
let cancel = token();
cancel.cancel();
let err = read_dir_blocking(td.path(), &cancel).expect_err("must observe cancel");
let err = read_dir_fatal(td.path(), &cancel).expect_err("must observe cancel");
assert!(matches!(err, FsError::Cancelled), "got {err:?}");
}
@ -627,7 +789,7 @@ mod tests {
fn read_dir_on_missing_path_reports_io_error() {
let td = tempfile::tempdir().expect("tempdir");
let missing = td.path().join("does-not-exist");
let err = read_dir_blocking(&missing, &token()).expect_err("must error");
let err = read_dir_fatal(&missing, &token()).expect_err("must error");
match err {
FsError::Io { path, .. } => {
assert!(
@ -640,6 +802,109 @@ mod tests {
}
}
#[test]
fn read_dir_tolerant_opens_an_empty_error_channel_on_a_clean_directory() {
// `Some(vec![])` rather than `None` is the whole shape
// contract: the Lua boundary keys the bare-array-vs-table
// result on `errors.is_some()`, so a clean tolerant listing
// must still carry the channel.
let td = tempfile::tempdir().expect("tempdir");
std::fs::write(td.path().join("a.txt"), b"x").expect("write");
let listing = read_dir_blocking(td.path(), &token(), ReadDirTolerance::PerEntry)
.expect("tolerant read_dir");
assert_eq!(listing.entries.len(), 1);
assert_eq!(listing.errors.as_deref(), Some(&[][..]));
}
#[cfg(not(target_os = "macos"))]
#[test]
fn read_dir_tolerant_keeps_an_entry_whose_symlink_target_is_not_utf8() {
use std::os::unix::ffi::OsStrExt;
let td = tempfile::tempdir().expect("tempdir");
std::fs::write(td.path().join("real.txt"), b"x").expect("write");
// A legal Unix symlink target that is not representable as a
// Rust `String`. Before Q#DR6 this single entry took the whole
// listing down.
symlink(
std::ffi::OsStr::from_bytes(b"tgt-\xff"),
td.path().join("weird"),
)
.expect("symlink");
let listing = read_dir_blocking(td.path(), &token(), ReadDirTolerance::PerEntry)
.expect("tolerant read_dir must survive a non-UTF-8 target");
let weird = listing
.entries
.iter()
.find(|e| e.name == "weird")
.expect("the entry itself must be listed");
assert_eq!(weird.kind, FsEntryKind::Symlink);
assert!(
weird.symlink_target.is_none(),
"an unrepresentable target reports as unknown"
);
assert!(
listing.entries.iter().any(|e| e.name == "real.txt"),
"the readable sibling must survive too"
);
let errors = listing.errors.expect("tolerant mode opens the channel");
assert_eq!(errors.len(), 1, "one per-entry failure: {errors:?}");
assert_eq!(errors[0].name.as_deref(), Some("weird"));
// The same directory under the fatal contract still fails
// whole-listing --- the opt is what changes behavior, not the
// walk.
let err = read_dir_fatal(td.path(), &token()).expect_err("fatal mode must still fail");
assert!(
matches!(err, FsError::NonUtf8Path { .. }),
"expected NonUtf8Path, got {err:?}"
);
}
#[test]
fn read_dir_tolerant_records_a_failed_lstat_and_lists_nothing_else_wrong() {
use std::os::unix::fs::PermissionsExt;
// Failure mode 1 from the framing: a directory readable but not
// searchable. `readdir` yields the names; every child `lstat`
// fails with EACCES.
let td = tempfile::tempdir().expect("tempdir");
let dir = td.path().join("no-search");
std::fs::create_dir(&dir).expect("mkdir");
std::fs::write(dir.join("child"), b"x").expect("write child");
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o400)).expect("chmod 400");
let searchable = std::fs::symlink_metadata(dir.join("child")).is_ok();
if searchable {
// Running as root (or on a filesystem that ignores the
// bits): the premise cannot be established, so assert
// nothing rather than pass vacuously.
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))
.expect("restore perms");
eprintln!("lstat still succeeds without search permission; skipping");
return;
}
let tolerant = read_dir_blocking(&dir, &token(), ReadDirTolerance::PerEntry);
let fatal = read_dir_fatal(&dir, &token());
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))
.expect("restore perms");
let listing = tolerant.expect("tolerant read_dir must not fail the listing");
assert!(
listing.entries.is_empty(),
"the unreadable child cannot be described: {:?}",
listing.entries
);
let errors = listing.errors.expect("tolerant mode opens the channel");
assert_eq!(errors.len(), 1, "one per-entry failure: {errors:?}");
assert_eq!(
errors[0].name.as_deref(),
Some("child"),
"an lstat failure has an entry in hand and must name it"
);
let err = fatal.expect_err("fatal mode must still fail the whole listing");
assert!(matches!(err, FsError::Io { .. }), "got {err:?}");
}
#[cfg(not(target_os = "macos"))]
#[test]
fn read_dir_on_non_utf8_entry_name_reports_structured_error() {
@ -650,7 +915,7 @@ mod tests {
// Rust `String`.
let bad_name = std::ffi::OsStr::from_bytes(b"bad-\xff-name");
std::fs::write(td.path().join(bad_name), b"").expect("write entry");
let err = read_dir_blocking(td.path(), &token()).expect_err("must error on non-UTF-8");
let err = read_dir_fatal(td.path(), &token()).expect_err("must error on non-UTF-8");
match err {
FsError::NonUtf8Path { parent, bytes } => {
assert!(

View File

@ -668,6 +668,33 @@ pub fn config_u32(lua: &Lua, name: &str, buffer_id: Option<BufferId>, fallback:
}
}
/// Read a `String` setting plus the registry epoch that keys any cache
/// built from it (Q#TC4c).
///
/// The epoch is returned WITH the value deliberately: a caller caching a
/// parsed form needs both, and reading them in two calls would let a
/// `set` land between them and produce a cache stamped with the wrong
/// epoch. `fallback` covers a bare core whose runtime never defined the
/// setting, matching [`config_u32`].
#[must_use]
pub fn config_string_and_epoch(
lua: &Lua,
name: &str,
buffer_id: Option<BufferId>,
fallback: &str,
) -> (String, u64) {
let Some(registry) = lua.app_data_ref::<config::SharedConfigRegistry>() else {
return (fallback.to_owned(), 0);
};
let borrowed = registry.borrow();
let epoch = borrowed.value_epoch();
let value = match borrowed.get(name, buffer_id) {
Ok(crate::config_registry::ConfigValue::Str(v)) => v.clone(),
_ => fallback.to_owned(),
};
(value, epoch)
}
/// Short-circuit a binding when the init phase has completed.
///
/// Lifecycle-affecting Lua APIs (currently just `pmacs.attach`; M5.6d+)
@ -2439,6 +2466,7 @@ pub fn install(
)?;
pmacs.set("instance", install_instance_module(lua, registry)?)?;
pmacs.set("ansi", install_ansi_module(lua)?)?;
pmacs.set("path", install_path_module(lua)?)?;
pmacs.set("packages", install_packages_module(lua)?)?;
pmacs.set("state", install_state_module(lua)?)?;
pmacs.set("session", install_session_module(lua)?)?;
@ -3555,6 +3583,46 @@ impl UserData for AnsiParserLua {
}
}
/// Build the `pmacs.path.*` table: pure path arithmetic, no
/// filesystem access and no editor state.
///
/// `canonicalize(path)` is [`crate::editor_core::normalize_buffer_path`]
/// itself — the function the buffer registry's path keys already go
/// through on write and that `find_buffer_for_path` looks up with. It
/// expands a leading `~`, absolutizes against the process cwd, folds
/// `.` / `..` lexically, and drops redundant separators (so a trailing
/// slash disappears everywhere except at root). Symlinks are
/// deliberately **not** resolved: dired's `..` must return where the
/// user navigated from, and a not-yet-created "[new file]" path has
/// nothing to resolve.
///
/// Exposed rather than mirrored in Lua because dired keys one buffer per
/// directory on this form (Q#DR2). Two implementations that disagree on
/// an edge (`//tmp`, `~` with `HOME` unset, a `..` that would escape
/// root) would mint two buffers for one directory with no error
/// anywhere.
///
/// The result crosses the boundary through `to_string_lossy`, so a
/// non-UTF-8 `$HOME` (or a non-UTF-8 argument) can yield a Lua string
/// that no longer names the `PathBuf` the registry keys on. That is the
/// same limit `pmacs.fs` already documents — byte-preserving paths are
/// post-v0.1 work that widens every path in the API — and it is recorded
/// here so this binding is not read as an exception to it.
fn install_path_module(lua: &Lua) -> mlua::Result<Table> {
let path = lua.create_table()?;
path.set(
"canonicalize",
lua.create_function(|_, raw: String| {
Ok(
crate::editor_core::normalize_buffer_path(std::path::PathBuf::from(raw))
.to_string_lossy()
.into_owned(),
)
})?,
)?;
Ok(path)
}
/// Build the `pmacs.ansi.*` table. The only entry today is
/// `parser()`; future additions (e.g. an event-table-validator
/// helper) live alongside it.
@ -6479,6 +6547,40 @@ fn fs_dir_entry_to_lua(lua: &Lua, entry: &crate::fs::FsDirEntry) -> mlua::Result
Ok(t)
}
/// Convert a settled `read_dir` listing to its Lua result value.
///
/// The shape is chosen by the listing itself (dired Q#DR6): a fatal-mode
/// listing carries no error channel and stays the **bare array** the
/// M8.1 surface documents --- the frozen M8.2 fixture consumes it with
/// `ipairs` --- while a tolerant listing becomes
/// `{ entries = { … }, errors = { { name = …?, message = … }, … } }`.
/// Keying on the payload rather than on the job keeps the additive
/// promise checkable in one place.
fn fs_dir_listing_to_lua(lua: &Lua, listing: crate::fs::FsDirListing) -> mlua::Result<mlua::Value> {
let entries = lua.create_table_with_capacity(listing.entries.len(), 0)?;
for (i, entry) in listing.entries.iter().enumerate() {
entries.set(i + 1, fs_dir_entry_to_lua(lua, entry)?)?;
}
let Some(errors) = listing.errors else {
return Ok(mlua::Value::Table(entries));
};
let rows = lua.create_table_with_capacity(errors.len(), 0)?;
for (i, error) in errors.iter().enumerate() {
let row = lua.create_table_with_capacity(0, 2)?;
// `name` is absent for a per-entry `readdir` iterator error:
// the entry never materialized, so there is nothing to name.
if let Some(name) = &error.name {
row.set("name", name.as_str())?;
}
row.set("message", error.message.as_str())?;
rows.set(i + 1, row)?;
}
let out = lua.create_table_with_capacity(0, 2)?;
out.set("entries", entries)?;
out.set("errors", rows)?;
Ok(mlua::Value::Table(out))
}
fn stream_payload_to_lua(lua: &Lua, payload: StreamPayload) -> mlua::Result<mlua::Value> {
match payload {
StreamPayload::U64(v) => Ok(mlua::Value::Integer(i64::try_from(v).unwrap_or(i64::MAX))),
@ -6570,9 +6672,23 @@ pub fn install_async(
let rt = runtime.clone();
async_mod.set(
"_dispatch_fs_read_dir",
lua.create_function(move |_, (path, key): (String, Option<String>)| {
Ok(rt.dispatch_fs_read_dir(std::path::PathBuf::from(path), key.as_deref()))
})?,
lua.create_function(
move |_, (path, key, tolerant): (String, Option<String>, Option<bool>)| {
// dired Q#DR6: the tolerance is decided at dispatch
// and travels in the settled payload, so the result
// conversion below never has to look the job back up.
let tolerance = if tolerant == Some(true) {
crate::fs::ReadDirTolerance::PerEntry
} else {
crate::fs::ReadDirTolerance::Fatal
};
Ok(rt.dispatch_fs_read_dir(
std::path::PathBuf::from(path),
tolerance,
key.as_deref(),
))
},
)?,
)?;
}
@ -6777,16 +6893,15 @@ pub fn install_async(
i64::try_from(duration_ms).unwrap_or(i64::MAX),
));
}
Some(JobOutcome::Complete(JobResult::ReadDir(entries))) => {
Some(JobOutcome::Complete(JobResult::ReadDir(listing))) => {
// Lua surface for fs.read_dir settle:
// status "ok", value = array of per-entry
// tables. T M8.1.
// tables (T M8.1), or the
// `{ entries = …, errors = … }` table when the
// caller opted into per-entry tolerance
// (dired Q#DR6).
out.push_back(mlua::Value::String(lua.create_string("ok")?));
let t = lua.create_table_with_capacity(entries.len(), 0)?;
for (i, entry) in entries.into_iter().enumerate() {
t.set(i + 1, fs_dir_entry_to_lua(lua, &entry)?)?;
}
out.push_back(mlua::Value::Table(t));
out.push_back(fs_dir_listing_to_lua(lua, listing)?);
}
Some(JobOutcome::Complete(JobResult::Stat(entry))) => {
// Lua surface for fs.stat settle: status
@ -6933,9 +7048,9 @@ fn workers_snapshot_to_lua(lua: &Lua, runtime: &SharedAsyncRuntime) -> mlua::Res
"ok",
mlua::Value::Integer(i64::try_from(*duration_ms).unwrap_or(i64::MAX)),
),
JobOutcome::Complete(JobResult::ReadDir(entries)) => (
JobOutcome::Complete(JobResult::ReadDir(listing)) => (
"ok",
mlua::Value::Integer(i64::try_from(entries.len()).unwrap_or(i64::MAX)),
mlua::Value::Integer(i64::try_from(listing.entries.len()).unwrap_or(i64::MAX)),
),
JobOutcome::Complete(JobResult::Stat(entry)) => {
("ok", mlua::Value::String(lua.create_string(&entry.name)?))

View File

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

View File

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

View File

@ -202,8 +202,18 @@ fn format_outcome(outcome: &JobOutcome) -> String {
JobOutcome::Complete(JobResult::Parse { duration_ms }) => {
format!("ok (parse {duration_ms}ms)")
}
JobOutcome::Complete(JobResult::ReadDir(entries)) => {
format!("ok ({} entries)", entries.len())
JobOutcome::Complete(JobResult::ReadDir(listing)) => {
// Per-entry failures (dired Q#DR6) are counted here too: a
// tolerant listing that dropped half a directory is not the
// same observable outcome as a clean one.
match listing.errors.as_deref() {
Some(errors @ [_, ..]) => format!(
"ok ({} entries, {} unreadable)",
listing.entries.len(),
errors.len()
),
_ => format!("ok ({} entries)", listing.entries.len()),
}
}
JobOutcome::Complete(JobResult::Stat(entry)) => {
format!("ok (stat {:?})", entry.name)

1656
tests/dired_acceptance.rs Normal file

File diff suppressed because it is too large Load Diff

View File

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