diff --git a/COHERENCE.md b/COHERENCE.md index dfe9cfd..4e7361c 100644 --- a/COHERENCE.md +++ b/COHERENCE.md @@ -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. diff --git a/Cargo.lock b/Cargo.lock index 498f267..b80dbae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2630,6 +2630,7 @@ dependencies = [ "pollster", "sys-locale", "tempfile", + "ttf-parser", "unicode-width", "wgpu", "winit", diff --git a/builtin/runtime/dired.lua b/builtin/runtime/dired.lua new file mode 100644 index 0000000..9c6bc92 --- /dev/null +++ b/builtin/runtime/dired.lua @@ -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 = "" -- 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 ( / 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:*` +-- (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("", "cursor.down") +bind("p", "cursor.up") +bind("", "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, +} diff --git a/builtin/runtime/fs.lua b/builtin/runtime/fs.lua index 02ca064..49c006e 100644 --- a/builtin/runtime/fs.lua +++ b/builtin/runtime/fs.lua @@ -12,7 +12,10 @@ -- `symlink_target` is present only on symlink entries. -- `opts` may contain `supersede = ""` 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) -> { , ... } +-- read_dir(path, { tolerant = true }) -> { entries = { , ... }, +-- 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 diff --git a/builtin/runtime/terminal.lua b/builtin/runtime/terminal.lua index 6be0987..143a663 100644 --- a/builtin/runtime/terminal.lua +++ b/builtin/runtime/terminal.lua @@ -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.", diff --git a/docs/active-work.md b/docs/active-work.md index 5f2f142..7e0542a 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -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:*` 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 1–16, + 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 37–55**. +- 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 diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index 165f06a..fb1f708 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -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` 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 37–55 + 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 2–3, + 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 || diff --git a/docs/bottom-panel-stage2-framing.md b/docs/bottom-panel-stage2-framing.md new file mode 100644 index 0000000..38d94ed --- /dev/null +++ b/docs/bottom-panel-stage2-framing.md @@ -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 37–55**. + +## 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 37–55. §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 7–10 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` 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 v6–v20 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 1–2 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 7–10** + (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 37–55 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. diff --git a/docs/dired-framing.md b/docs/dired-framing.md index 87b4573..ada853e 100644 --- a/docs/dired-framing.md +++ b/docs/dired-framing.md @@ -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 diff --git a/docs/inline-math-framing.md b/docs/inline-math-framing.md index 0b6bfb4..a2ce9c8 100644 --- a/docs/inline-math-framing.md +++ b/docs/inline-math-framing.md @@ -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 | diff --git a/docs/inline-math-slice-framing.md b/docs/inline-math-slice-framing.md new file mode 100644 index 0000000..2c9f626 --- /dev/null +++ b/docs/inline-math-slice-framing.md @@ -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 (F1–F9); 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), + Script { base: Box, sub: Option>, sup: Option> }, + Fraction { num: Box, den: Box }, +} +``` + +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+1D434–U+1D467, **with the U+210E hole for `h`** (Letterlike Symbols, not in the 1D4xx run) | +| Lowercase Greek | math italic | U+1D6FC–U+1D714 | +| Uppercase Greek | **upright** | left at U+0391–U+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 ~12–14 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 } +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. diff --git a/docs/terminal-config-and-copy-mode-framing.md b/docs/terminal-config-and-copy-mode-framing.md new file mode 100644 index 0000000..48b75d8 --- /dev/null +++ b/docs/terminal-config-and-copy-mode-framing.md @@ -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)` +(`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 ` `. 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. diff --git a/docs/vterm-framing.md b/docs/vterm-framing.md index b86bcd0..7d4ca02 100644 --- a/docs/vterm-framing.md +++ b/docs/vterm-framing.md @@ -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 diff --git a/pmacs-gpu/Cargo.toml b/pmacs-gpu/Cargo.toml index 40bc3ee..2715d57 100644 --- a/pmacs-gpu/Cargo.toml +++ b/pmacs-gpu/Cargo.toml @@ -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. diff --git a/pmacs-gpu/fonts/GUST-FONT-LICENSE.txt b/pmacs-gpu/fonts/GUST-FONT-LICENSE.txt new file mode 100644 index 0000000..604bf75 --- /dev/null +++ b/pmacs-gpu/fonts/GUST-FONT-LICENSE.txt @@ -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-.txt, where is some unique identification +% of the font family. If a separate "readme" file accompanies the Work, +% we recommend a name of the form README-.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. diff --git a/pmacs-gpu/fonts/latinmodern-math.otf b/pmacs-gpu/fonts/latinmodern-math.otf new file mode 100644 index 0000000..0e4642e Binary files /dev/null and b/pmacs-gpu/fonts/latinmodern-math.otf differ diff --git a/pmacs-gpu/src/main.rs b/pmacs-gpu/src/main.rs index 665194c..a26f340 100644 --- a/pmacs-gpu/src/main.rs +++ b/pmacs-gpu/src/main.rs @@ -23,6 +23,8 @@ //! the SIL Open Font License 1.1 (see `fonts/OFL.txt`). mod attach; +mod math_layout; +mod math_parse; mod terminal; use std::collections::HashMap; @@ -222,6 +224,13 @@ fn build_font_system(extra_sources: &[&'static [u8]]) -> (FontSystem, FontDefaul let bundled_id = *bundled_ids .first() .expect("bundled JetBrains Mono contains one face"); + // Inline-math slice (Q#MS7): the math glyphs draw through + // cosmic-text, so the same bytes the layout engine measures must + // resolve as a family here — the F8b pin. Proportional, so the + // same-family monospace filter below cannot touch it. + db.load_font_source(fontdb::Source::Binary(std::sync::Arc::new( + math_layout::LATIN_MODERN_MATH, + ))); let extra_ids: Vec = extra_sources .iter() .flat_map(|bytes| db.load_font_source(fontdb::Source::Binary(std::sync::Arc::new(*bytes)))) @@ -275,6 +284,22 @@ fn query_normal_face(db: &fontdb::Database, family: &str) -> Option }) } +/// The family name the bundled math font resolves to in fontdb; the +/// draw pass pins `Attrs` to it so drawn advances come from the same +/// face layout measured (framing F8b). +const MATH_FONT_FAMILY: &str = "Latin Modern Math"; + +/// The Q#MS10 fit budget at the given code metrics, derived from the +/// bundled code face's baseline placement (the framing's pinned rule). +/// A custom `set_font` family shifts the painted baseline slightly; v0 +/// accepts that — boxes draw against the real shaped baseline, so only +/// the fit margin is approximate. +fn math_code_budget(fm: FontMetrics) -> (f32, f32) { + ttf_parser::Face::parse(JETBRAINS_MONO, 0).map_or((0.0, 0.0), |face| { + math_layout::line_box_budget(&face, fm.code_font_size(), fm.code_line_height()) + }) +} + /// The fixed ASCII advance probe (framing Q#F6). The measurement uses /// its total shaped width divided by this logical cell count; it does /// not assume one glyph per digit because a valid monospace face may @@ -341,6 +366,19 @@ const TERMINAL_CURSOR_RGBA: [f32; 4] = [0.85, 0.85, 0.9, 0.55]; /// B1. const CARET_WIDTH: f32 = 2.0; const CARET_COLOR: [f32; 4] = [0.90, 0.90, 0.96, 0.90]; + +/// Math ink (Q#MS6): the plain code text color, as glyph color for +/// the mini-buffers and quad rgba for the fraction rule. Colour-by- +/// context is the parent arc's deferred Q#IM2. +const MATH_INK_COLOR: Color = Color::rgb(230, 230, 235); +const MATH_INK_RGBA: [f32; 4] = [230.0 / 255.0, 230.0 / 255.0, 235.0 / 255.0, 1.0]; + +/// Line-height factor for a math glyph's mini-buffer: roomy enough +/// that a lone glyph's ascender/descender never clips against the +/// buffer's own line box. Positioning ignores it — the `TextArea` top +/// is set from the mini-buffer's SHAPED `line_y`, so the glyph's +/// baseline lands exactly where layout put it. +const MATH_GLYPH_LINE_FACTOR: f32 = 2.0; /// Extra source lines shaped beyond the visible window so a 1-line /// scroll doesn't always re-slice and the bottom partial line renders /// (Q#S3). Kept small — overscan is wasted shaping. @@ -1517,6 +1555,21 @@ struct State { /// frames re-shape ONLY lines whose styling actually changed, and /// lets scroll reuse retained lines wholesale. line_chunk_cache: Vec>, + /// Per-shaped-line math suppression state, in lockstep with + /// `line_chunk_cache`: the detected spans with the Q#MS5 gate bit + /// each was built under (the line-reuse predicate's third input + /// beside content and styling), and the placed boxes the draw + /// pass paints into the reserved spacer rectangles. + line_math_cache: Vec, + /// The MATH layout engine over the bundled font, or `None` when + /// the font failed to yield math metrics at startup — a hard + /// error in the math path only (Q#MS7): spans render as source + /// and the editor keeps running. + math_engine: Option>, + /// `(above_baseline, below_baseline)` budget a math box must fit + /// (Q#MS10), derived by `math_layout::line_box_budget` from the + /// CODE font's baseline placement. Recomputed when metrics change. + math_budget: (f32, f32), /// Absolute source-line index of `buffer.lines[0]`. shaped_top: usize, bg_vertex_buffer: ReusableVertexBuffer, @@ -1567,6 +1620,12 @@ struct State { /// Dedicated text renderer for the minibuffer dropdown (its own /// layer over the buffer, like the menu's). mb_text_renderer: TextRenderer, + /// Dedicated text renderer for inline-math glyphs (Q#MS6): each + /// `MathItem::Glyph` draws from its own mini-buffer positioned at + /// layout's exact x/baseline, so an accumulated shaping advance + /// can never move a glyph off its measured origin — the same + /// per-run argument the terminal renderer made. + math_text_renderer: TextRenderer, /// Minibuffer dropdown background + selection quads (Q#MB1). mb_bg_vertex_buffer: ReusableVertexBuffer, /// Arc 1a Q#C5 — the live in-buffer completion popup (protocol @@ -3046,6 +3105,17 @@ impl State { // filter, generic defaults, THEN FontSystem construction so // its monospace-ID set sees the final database. let (mut font_system, font_defaults) = build_font_system(extra_font_sources); + // Inline-math slice (Q#MS7): the layout engine over the bundled + // Latin Modern Math. Failure is surfaced once and disables only + // the math path — spans keep rendering as source. + let math_engine = match math_layout::MathLayout::new(math_layout::LATIN_MODERN_MATH) { + Ok(engine) => Some(engine), + Err(e) => { + eprintln!("pmacs-gpu: bundled math font unusable ({e:?}); inline math disabled"); + None + } + }; + let math_budget = math_code_budget(fm); let swash_cache = SwashCache::new(); let cache = Cache::new(&device); let mut viewport = Viewport::new(&device, &cache); @@ -3065,6 +3135,9 @@ impl State { // Q#MB1 — a third renderer for the minibuffer dropdown layer. let mb_text_renderer = TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None); + // Inline-math slice (Q#MS6) — a renderer for the math glyph layer. + let math_text_renderer = + TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None); // Arc 1a Q#C5 — a renderer for the completion dropdown layer. let completion_text_renderer = TextRenderer::new(&mut atlas, &device, MultisampleState::default(), None); @@ -3219,6 +3292,9 @@ impl State { styled_redraw_deadline: None, hit_map_dirty: false, line_chunk_cache: Vec::new(), + line_math_cache: Vec::new(), + math_engine, + math_budget, shaped_top: 0, bg_vertex_buffer: ReusableVertexBuffer::new(), squiggle_vertex_buffer: ReusableVertexBuffer::new(), @@ -3239,6 +3315,7 @@ impl State { menu_bg_vertex_buffer: ReusableVertexBuffer::new(), mb_buffer, mb_text_renderer, + math_text_renderer, mb_bg_vertex_buffer: ReusableVertexBuffer::new(), completion: None, completion_buffer, @@ -3463,6 +3540,12 @@ impl State { // `moved == false`, so deferring wrapped-run repair here // would leave a newly wrapped caret off-screen indefinitely. self.ensure_caret_painted(); + // Q#MS5/F4: the text applied above re-chunked under the OLD + // caret; the effective caret only just moved. Suppression keys + // on `own_cursor`, so re-run the compare — without this, a + // typed char that lands the caret against a span boundary + // renders one keystroke stale. + self.refresh_math_suppression(); let viewport = self.viewport_send_if_changed(predicted.buffer_id); CrdtOpSend { buffer_id: predicted.buffer_id, @@ -3961,7 +4044,13 @@ impl State { // in `render()`. No decoration change needs a // reshape, so none triggers one — diagnostic // publishes no longer pay set_rich_text + - // shape_until_scroll. + // shape_until_scroll... with ONE exception since the + // inline-math slice: a Selection endpoint is a Q#MS11 + // suppression gate, so a selection change re-runs the + // per-line compare when the slice could hold math + // (no-op for every other decoration kind and for + // math-free text). + self.refresh_math_suppression(); self.request_redraw(); None } @@ -4156,6 +4245,13 @@ impl State { // the band never scrolled into view. if moved { self.ensure_caret_painted(); + // Q#MS5: the caret is a suppression input now. A + // move that crosses a math-span boundary must + // re-chunk the affected lines even when no scroll + // or edit follows — the follow above only reshapes + // on scroll, and a retained line under the stale + // suppression state is the #120 edge. + self.refresh_math_suppression(); if let Some(vp) = self.viewport_send_if_changed(buffer_id) { return Some(vp); } @@ -4928,18 +5024,65 @@ impl State { } } + /// The shaped slice's math substitutions, read back from the + /// per-line chunk caches and rebased to slice-relative offsets — + /// the spacer text and suppressed range are both in the cached + /// `MathBox` chunks, so the hit map reproduces the shaped state + /// exactly instead of re-planning under a possibly-newer caret. + fn cached_math_subs_for_slice(&self, vstart: u64, vend: u64) -> Vec { + let mut subs = Vec::new(); + if self.math_engine.is_none() { + return subs; + } + let (top, ranges) = self.slice_line_ranges(vstart, vend); + if top != self.shaped_top || ranges.len() != self.line_chunk_cache.len() { + // The caches describe a different slice; a math-blind map + // (boxes unclickable at worst) beats a wrong one. + return subs; + } + for (i, &(ls, _)) in ranges.iter().enumerate() { + let base = ls - vstart; + for chunk in &self.line_chunk_cache[i] { + if let ChunkSource::MathBox { start, end } = chunk.source { + subs.push(MathSubstitution { + span: math_parse::MathSpan { + start: (base + start) as usize, + end: (base + end) as usize, + }, + spacer: chunk.text.clone(), + boxed: math_layout::MathBox { + width: 0.0, + ascent: 0.0, + descent: 0.0, + items: Vec::new(), + }, + }); + } + } + } + subs + } + fn hit_test_source_byte(&mut self, x: f64, y: f64) -> Option { self.current_buffer_id?; if self.hit_map_dirty { // Q#R2 — a per-line reshape deferred this; rebuild from // the same chunk source the shaped buffer was built from. let (vstart, vend) = self.view_range; + // B1': the hit map must see the SAME math suppressions the + // shaped lines carry, so the slice-wide substitution list + // is read back from the per-line caches (never recomputed + // — a caret that moved since the last reshape must not + // make the map disagree with the glyphs), rebased from + // line-relative to slice-relative offsets. + let subs = self.cached_math_subs_for_slice(vstart, vend); let rich = clipped_chunks_for_range( &self.current_text, &self.current_spans, &self.current_adornments, vstart, vend, + &subs, ); let (hit_runs, projected_line_starts) = build_hit_runs(&rich); self.current_hit_runs = hit_runs; @@ -5079,15 +5222,19 @@ impl State { let Some(shaped_idx) = line_idx.checked_sub(self.shaped_top) else { return false; }; - if shaped_idx >= self.buffer.lines.len() || shaped_idx >= self.line_chunk_cache.len() { + if shaped_idx >= self.buffer.lines.len() + || shaped_idx >= self.line_chunk_cache.len() + || shaped_idx >= self.line_math_cache.len() + { // E.g. typing on the phantom empty line after a trailing // newline — no BufferLine exists for it; full reshape // handles those shapes correctly. return false; } - let chunks = self.chunks_for_line(line_start, content_end); + let (chunks, math) = self.chunks_for_line(line_start, content_end); self.buffer.lines[shaped_idx] = line_from_chunks(&chunks, &self.resolved_family); self.line_chunk_cache[shaped_idx] = chunks; + self.line_math_cache[shaped_idx] = math; self.view_range = (vstart, vend); self.buffer.shape_until_scroll(&mut self.font_system, false); // The edited line's wrap count can shrink under a retained @@ -5128,14 +5275,148 @@ impl State { (top, ranges) } - fn chunks_for_line(&self, line_start: u64, content_end: u64) -> Vec { - clipped_chunks_for_range( + fn chunks_for_line( + &self, + line_start: u64, + content_end: u64, + ) -> (Vec, MathLineState) { + let (subs, mut state) = self.math_plan_for_line(line_start, content_end); + let chunks = clipped_chunks_for_range( &self.current_text, &self.current_spans, &self.current_adornments, line_start, content_end, - ) + &subs, + ); + state.placed = placed_math_boxes(&chunks, &subs); + (chunks, state) + } + + /// The line's math suppression plan (Q#MS3/Q#MS4). Detection runs + /// here — the chunk-build path, never the edit path — and the gate + /// reads the EFFECTIVE caret (`own_cursor`, which optimistic edits + /// predict forward) plus the own-selection endpoints, so + /// suppression cannot flap during an unconfirmed edit (framing + /// Q#MS5/F4). Any failure — parse, layout, fit, degenerate spacer — + /// leaves that span as source (Q#MS8). + fn math_plan_for_line( + &self, + line_start: u64, + content_end: u64, + ) -> (Vec, MathLineState) { + let mut subs = Vec::new(); + let mut state = MathLineState::default(); + let Some(engine) = self.math_engine.as_ref() else { + return (subs, state); + }; + let Some(line) = self + .current_text + .get(line_start as usize..content_end as usize) + else { + return (subs, state); + }; + if !line.as_bytes().contains(&b'$') { + return (subs, state); + } + let spans = math_parse::detect_math_spans(line); + if spans.is_empty() { + return (subs, state); + } + let gates = self.math_gate_positions(line_start, content_end); + let advance = self.mono_advance(); + for span in spans { + let gated = gates + .iter() + .any(|&p| span.start as u64 <= p && p <= span.end as u64); + state.gates.push((span, gated)); + if gated { + continue; + } + let Ok(node) = math_parse::parse(&line[span.interior()]) else { + continue; + }; + let Ok(boxed) = engine.layout(&node, self.fm.code_font_size()) else { + continue; + }; + let Some(fitted) = + math_layout::fit_to_line(&boxed, self.math_budget.0, self.math_budget.1) + else { + continue; + }; + let spacer = math_layout::spacer_for_width(fitted.width, advance); + if spacer.is_empty() { + continue; + } + subs.push(MathSubstitution { + span, + spacer, + boxed: fitted, + }); + } + (subs, state) + } + + /// Line-relative byte positions whose presence inside a span + /// unsuppresses it: the effective caret and both own-selection + /// endpoints (Q#MS5 generalised by Q#MS11 — "you are addressing + /// this text" and "you see this text" are the same condition). + fn math_gate_positions(&self, line_start: u64, content_end: u64) -> Vec { + let mut gates = Vec::new(); + let mut push = |byte: u64| { + if byte >= line_start && byte <= content_end { + gates.push(byte - line_start); + } + }; + if let Some(own) = self.own_cursor + && Some(own.buffer_id) == self.current_buffer_id + { + push(own.byte); + } + for d in &self.current_decorations { + if d.kind == DecorationKind::Selection { + push(d.range.start); + push(d.range.end); + } + } + gates + } + + /// Whether a retained line's cached gate bits match the current + /// effective caret/selection — the suppression input to the + /// line-reuse predicate beside content and styling (Q#MS5; a + /// retained line shaped under the opposite suppression state is + /// the #120 stale-mirror failure). Content is unchanged on every + /// reuse path, so the cached span set is authoritative and only + /// the gate bits can differ. + fn math_gates_match(&self, line_start: u64, content_end: u64, state: &MathLineState) -> bool { + if state.gates.is_empty() { + return true; + } + let gates = self.math_gate_positions(line_start, content_end); + state.gates.iter().all(|&(span, was_gated)| { + let now = gates + .iter() + .any(|&p| span.start as u64 <= p && p <= span.end as u64); + now == was_gated + }) + } + + /// Caret or selection motion can flip a span's Q#MS5 gate without + /// any content change, which the frame-driven refresh paths never + /// see; re-run the per-line chunk compare when the visible slice + /// could hold math at all. Cheap when it cannot (one `$` scan). + fn refresh_math_suppression(&mut self) { + if self.math_engine.is_none() || self.terminal.is_some() { + return; + } + let (vstart, vend) = self.view_range; + let Some(slice) = self.current_text.get(vstart as usize..vend as usize) else { + return; + }; + if slice.as_bytes().contains(&b'$') { + self.refresh_changed_lines(); + } } /// Rebuild the shaped slice, reusing any retained line whose @@ -5158,30 +5439,52 @@ impl State { .into_iter() .map(Some) .collect(); + let mut old_math: Vec> = std::mem::take(&mut self.line_math_cache) + .into_iter() + .map(Some) + .collect(); let mut lines = Vec::with_capacity(ranges.len()); let mut cache = Vec::with_capacity(ranges.len()); + let mut math = Vec::with_capacity(ranges.len()); let mut any_reused = false; for (i, &(ls, ce)) in ranges.iter().enumerate() { let abs = new_top + i; let reused = abs.checked_sub(old_top).and_then(|j| { - if j < old_lines.len() && j < old_cache.len() { - old_lines[j].take().zip(old_cache[j].take()) + if j < old_lines.len() && j < old_cache.len() && j < old_math.len() { + // Reuse is sound only when suppression state is a + // third invariant beside content and styling + // (Q#MS5): a caret-follow scroll lands here with a + // caret that may have crossed a span boundary, and + // a retained line shaped under the opposite + // suppression state is the #120 stale-mirror + // failure (framing acceptance 11). + if !self.math_gates_match(ls, ce, old_math[j].as_ref()?) { + return None; + } + Some(( + old_lines[j].take()?, + old_cache[j].take()?, + old_math[j].take()?, + )) } else { None } }); - if let Some((line, chunks)) = reused { + if let Some((line, chunks, m)) = reused { any_reused = true; lines.push(line); cache.push(chunks); + math.push(m); } else { - let chunks = self.chunks_for_line(ls, ce); + let (chunks, m) = self.chunks_for_line(ls, ce); lines.push(line_from_chunks(&chunks, &self.resolved_family)); cache.push(chunks); + math.push(m); } } self.buffer.lines = lines; self.line_chunk_cache = cache; + self.line_math_cache = math; self.shaped_top = new_top; self.buffer .set_scroll(Scroll::new(0, self.code_scroll_residual, 0.0)); @@ -5212,6 +5515,7 @@ impl State { if (vstart, vend) != self.view_range || top != self.shaped_top || ranges.len() != self.line_chunk_cache.len() + || ranges.len() != self.line_math_cache.len() || ranges.len() != self.buffer.lines.len() { self.reshape(); @@ -5219,12 +5523,17 @@ impl State { } let mut any = false; for (i, &(ls, ce)) in ranges.iter().enumerate() { - let chunks = self.chunks_for_line(ls, ce); + let (chunks, m) = self.chunks_for_line(ls, ce); if chunks != self.line_chunk_cache[i] { self.buffer.lines[i] = line_from_chunks(&chunks, &self.resolved_family); self.line_chunk_cache[i] = chunks; any = true; } + // Always current, even when the chunks are unchanged: an + // unsuppressible span's gate bit can flip with no chunk + // difference, and a stale bit would defeat the reuse + // comparison later. + self.line_math_cache[i] = m; } if any { self.buffer.shape_until_scroll(&mut self.font_system, false); @@ -6176,13 +6485,16 @@ impl State { let (top, ranges) = self.slice_line_ranges(vstart, vend); let mut lines = Vec::with_capacity(ranges.len()); let mut cache = Vec::with_capacity(ranges.len()); + let mut math = Vec::with_capacity(ranges.len()); for &(ls, ce) in &ranges { - let chunks = self.chunks_for_line(ls, ce); + let (chunks, m) = self.chunks_for_line(ls, ce); lines.push(line_from_chunks(&chunks, &self.resolved_family)); cache.push(chunks); + math.push(m); } self.buffer.lines = lines; self.line_chunk_cache = cache; + self.line_math_cache = math; self.shaped_top = top; self.buffer .set_scroll(Scroll::new(0, self.code_scroll_residual, 0.0)); @@ -6445,6 +6757,9 @@ impl State { scale, advance_ratio, }; + // The Q#MS10 fit budget follows the code metrics; the reshape + // below rebuilds every line's math plan against it. + self.math_budget = math_code_budget(self.fm); // The default family already has an exact, pre-preference // geometry path: a shaped glyph when present, otherwise the // ratio-scaled baseline constant. Keep using it so resetting @@ -6704,6 +7019,16 @@ impl State { // status band and the popup layers above it stay, because they // are buffer-independent chrome the daemon still drives. let terminal_mode = self.terminal.is_some(); + // Inline-math ink (Q#MS6): glyph mini-buffers plus fraction-rule + // quads. The rules ride the bg quad batch AFTER the decoration + // washes, so a selection/search wash under a rendered box never + // paints over its fraction bar; the glyphs get their own layer + // in the code z-slot below. + let (math_buffers, math_rules) = if terminal_mode { + (Vec::new(), Vec::new()) + } else { + self.build_math_paint() + }; // The band's strip rides the bg quad batch so it draws under // the band text (text renders after the first quad draw). let mut bg_vertices = if terminal_mode { @@ -6711,6 +7036,11 @@ impl State { } else { self.decoration_background_vertex_bytes() }; + bg_vertices.extend(rects_to_vertex_bytes( + &math_rules, + self.config.width, + self.config.height, + )); bg_vertices.extend(self.status_band_vertex_bytes()); let bg_vertex_count = (bg_vertices.len() / QUAD_VERTEX_STRIDE as usize) as u32; let bg_buffer = self @@ -6896,6 +7226,37 @@ impl State { ) .expect("text_renderer prepare"); + // Inline-math glyph layer (Q#MS6): one TextArea per glyph, + // clipped by the same code-area bounds as the code layer. + let math_areas: Vec