feat(terminal): copy mode over retained scrollback

Stage 2 of docs/terminal-config-and-copy-mode-framing.md (rev 4,
approved). `M-x terminal.copy-mode`, or `C-t` in a terminal buffer —
physically `C-c C-t`, since every unescaped key goes to the child —
materializes the retained scrollback into an ordinary read-only,
path-less buffer, with `g` to re-snapshot and `q` to return.

No protocol change.

Materializing is the whole design. isearch, motion, selection and the
kill ring work with no new substrate because the snapshot is a rope, so
SearchStore and the existing match painting apply unchanged. And "keys
must not reach the child" dissolves structurally rather than being
guarded: the transport arm keys on is_terminal(buffer_id), and a
snapshot is not a terminal, so the arm never fires. The
dispatch-shadow count stays at six and describe-key keeps telling the
truth — asserted directly, since that is the observable difference
between the buffer-local idiom and a shadow.

One serializer, not two (Q#TC7). `copy_retained` builds a whole-range
selection and hands it to `copy_selection_bytes`; a second walk would
re-derive soft-wrap joining, wide-glyph continuation, cluster bytes and
per-row trailing-blank trimming, and the two would drift. Four unit
pins in view.rs assert exact bytes against the same projection fixtures
that pin the serializer itself.

Q#TC6a is implemented as two calls, and the second is the load-bearing
one: an intercept guards dispatch only, and no Lua binding sets
Buffer::read_only, so set_round_trip_input is what keeps a replica
frontend from applying optimistically and emitting an op that would
pass ensure_writable and mutate both sides. Acceptance 16 pins that
UNGATED, because CI never compiles the crdt feature.

Eight of nine criteria. Criterion 17's semantic-frontend end-to-end pin
is deliberately absent: the optimistic apply lives only in
pmacs-gpu/src/main.rs and the headless SemanticClient has no optimistic
path, so a faithful test needs the real GPU binary — the a37
foundation, which CI never compiles, silently returns ok when the
binary is unbuilt, and is load-sensitive. Both halves of the mechanism
are pinned ungated instead (16, and 16b for the hazard); the wire-level
half stays an explicit obligation of the CI crdt-coverage lane.

Substrate fact found while wiring lifecycle: TerminalManager::prune
REACTS to a buffer already gone from the registry rather than removing
one, so a child exiting leaves both the terminal and its snapshot
alive. That is why on_removed is a sound teardown hook, and why a
finished command's output stays readable.

Five bites, five different wrong implementations, each failing exactly
one test: removing set_round_trip_input fails acceptance 16 in the
DEFAULT configuration; a naive independent serializer fails all four
unit pins, with the diffs naming each drift mode; making re-invoke
create a fresh buffer fails 18; dropping the kill-with-terminal
teardown fails 18; removing the intercept fails 16b.

COHERENCE.md: §6 gains this as the worked example that a modal-looking
feature need not become a shadow; §11 records the scope="global"
deferral's second live case, making the argument for both registry
deferrals cumulative; §2 step 8 gains copy mode and keeps the
still-missing close command named.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016gGQC6eqHJVbZJ5Hg7aLer
This commit is contained in:
Levi Neuwirth 2026-07-26 10:10:27 -04:00
parent cf54270173
commit 1b1e599070
7 changed files with 970 additions and 12 deletions

View File

@ -368,7 +368,7 @@ Full verdict table:
| 5 | Edit | **Works** | Full CUA + Emacs keymap in 161 lines (`builtin/keymaps/default.lua`); isearch, query-replace, kill ring, undo/redo, auto-indent/pair/comment, atomic save. Genuinely excellent zero-config |
| 6 | Language intelligence | **Partial** | Rust grammar bundled and auto-attaches; rust-analyzer preconfigured (`builtin/runtime/lsp.lua:44-52`) — but a missing binary fails silently (§1.2) and highlighting masks it. No LSP status command exists to diagnose |
| 7 | Find symbol / file | **File: fixed (open by path merged #162; browsing PR #165). Symbol: works but undiscoverable** | No find-file/dired/picker existed at audit. Now `C-x C-f` opens a known path and `C-x d` / `C-x C-j` browse (flat listing, `dired` mode keymap); `M-.`/`M-?`/`C-c o` still bound but advertised nowhere and server-gated; no workspace-symbol command; `pmacs.index.*` has no UI |
| 8 | Open terminal | **Works** | 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.* |
| 8 | Open terminal | **Works** | Full PTY with scrollback + modeline segment, bound to `C-c t` and configurable through three registered settings (`terminal.default-profile`, `terminal.scrollback-rows`, `terminal.escape-key`) plus named `pmacs.terminal.profiles` (PR #173), and searchable through `M-x terminal.copy-mode` / `C-c C-t`, which materializes the retained scrollback into an ordinary read-only buffer (Stage 2). Named limitations: `C-c t` is unreachable from *inside* a terminal window, where `C-c` is consumed as the escape — `M-x terminal` still works there; and there is still **no close/kill command**, which is the remaining half of this step's discoverability gap. *Was broken outright on the GPU frontend until the double terminal-layout sync was fixed: the child took a `SIGWINCH` storm at tick cadence, so typing into it was impossible while output still flowed.* |
| 9 | Build / test | **Partial** | `M-x compile.run` works, defaults cwd to detected project root, parses Rust `-->` errors — but no keybinding, an **empty first prompt** (`initial = last and last.cmdline or ""`, `builtin/runtime/compile.lua:1134-1138`), and no `cargo build`/`cargo test` suggestion despite `ProjectKind::Cargo` existing (`src/project.rs:77`) |
| 10 | Inspect error | **Partial (good once reached)** | `E:n W:n` modeline counts, underlines, `M-g n/p` + ``C-x ` `` walking a unified compile/grep/diag source, message echo, `RET` visits. Gated entirely on step 6 or 9 succeeding first |
| 11 | See background work | **Works but undiscoverable** | `*workers*` view via `M-x editor.list-workers`; `C-c C-k` cancel-at-point. No keybinding, no statusline spinner/progress indicator anywhere (§9) |
@ -658,6 +658,25 @@ Facts that define the gap:
a terminal buffer. Since #173 that chord is `terminal.escape-key`
rather than a hardcoded `C-c`, so a user can *move* which prefix is
eaten; they cannot make the shadow stop eating one.
- **A worked example that a modal-*looking* feature need not become a
shadow.** Terminal copy mode (Stage 2 of the terminal-config arc) is
the case that most invited a seventh rung: it wants motion, search and
its own `g`/`q` inside a surface where every unescaped key otherwise
goes to a child process. It resolves to the buffer-local keymap idiom
instead, by **materializing** the retained scrollback into an ordinary
read-only document buffer. The keys-must-not-reach-the-child problem
then dissolves structurally rather than being guarded: the transport
arm keys on `is_terminal(buffer_id)`, and a snapshot buffer is not a
terminal, so the arm never fires. No new precedence rung, no new
hand-synced guard-list entry, and `describe-key` keeps reporting the
truth — pinned by asserting exactly that for the snapshot's `g` and
`q`, which is the observable difference between the idiom and a
shadow. **The count stays at six.**
The transferable rule: when a feature wants a keymap over *content*,
ask whether the content can become a buffer. The shadows that exist
are the cases where it genuinely cannot (a minibuffer prompt, a
live search prompt) — not the cases where nobody tried.
- **No transient-keymap mechanism exists to migrate to.** `KeymapStack`
has exactly three fixed scopes — `Buffer(BufferId)`, `Mode(String)`,
`Global` (`src/keymap_stack.rs:37-44`); resolution order buffer →
@ -1041,6 +1060,19 @@ layering, provenance, and adoption have not followed.**
scalars. It is the clearest evidence yet that table-valued settings
are the blocking prerequisite: the terminal is now half-registered,
and no settings UI can render the half that matters most.
- **The missing `scope = "global"` flag has its second live case.** After
`autosave.interval-ms`, the terminal's two *open-time* settings —
`terminal.default-profile` and `terminal.scrollback-rows` — are read
before their terminal's identity buffer exists, so a buffer-local
override can never be consulted. The registry accepts `set_local` on
them anyway, because `Live` mutability is all it can express. Nothing
breaks; the setting simply has no effect, which is the worst shape a
configuration surface can take. `terminal.escape-key` is the contrast
that shows this is a real distinction rather than a blanket wish: it
*deliberately* supports buffer-locals, and per-terminal escapes are a
feature. So the argument for both deferrals is now **cumulative and
concrete** rather than hypothetical — two adopters, two distinct
missing primitives, one feature.
- **No persistence**: settings changed at runtime do not survive
restart (the `custom-file` split-brain question is a named deferral).
- The three-level separation holds in principle today (registry /

View File

@ -49,6 +49,18 @@ local function bind_terminal_keys(buffer)
bind("C-v", "terminal.page-down")
bind("M-<", "terminal.scroll-oldest")
bind("M->", "terminal.scroll-bottom")
-- Q#TC8a/Q#TC9: copy mode is ADDITIVE. The live keys above are
-- unchanged; this is one more leaf beside them. `C-t` is globally
-- `edit.transpose-chars`, which is meaningless in a read-only
-- terminal buffer, and binding it buffer-locally is the scoped
-- idiom rather than a shadow — `keymap.bind`'s strictness rejects
-- binding a PREFIX of an existing sequence within a scope, not
-- cross-scope shadowing.
--
-- Physically typed as `C-c C-t`: in a terminal every unescaped key
-- goes to the child, so terminal-local bindings are reached through
-- the escape. That also matches emacs-libvterm's own chord.
bind("C-t", "terminal.copy-mode")
end
-- Q#TC1: profiles are a raw Lua table, not a config setting. The
@ -190,6 +202,180 @@ pmacs.command.define {
-- `C-c` is consumed as the escape. `M-x terminal` still works there.
pmacs.keymap.bind { scope = "global", sequence = "C-c t", command = "terminal" }
-- === Copy mode (Stage 2, Q#TC6) =========================================
--
-- `terminal.copy-mode` MATERIALIZES the retained rows into an ordinary
-- read-only document buffer instead of adding a modal state to the
-- terminal. That choice is the whole design:
--
-- * isearch, motion, selection, `M-w` and the kill ring all work with no
-- new substrate — the snapshot is a rope, so `SearchStore` and the
-- existing match painting apply unchanged;
-- * "keys must not reach the child" dissolves structurally rather than
-- being guarded: the transport arm keys on `is_terminal(buffer)`, and
-- a snapshot buffer is not a terminal, so it never fires;
-- * the dispatch-shadow count stays at SIX (`COHERENCE.md` §6) and
-- `describe-key` keeps telling the truth, because the bindings are
-- buffer-local and inspectable.
local raw_copy_retained = assert(terminal._copy_retained,
"pmacs.terminal._copy_retained is required")
-- snapshot buffer name -> { terminal = <buf>, buffer = <buf> }
--
-- Keyed by NAME, not by buffer handle: handles are not stable table keys,
-- and a name survives the user killing the snapshot (listview precedent).
local snapshots = {}
local function buffer_name(buf)
local ok, described = pcall(pmacs.describe.buffer, buf)
if ok and described then return described.name end
return nil
end
local function find_buffer_by_name(name)
for _, id in ipairs(pmacs.buffer.list()) do
local ok, described = pcall(pmacs.describe.buffer, id)
if ok and described and described.name == name then return id end
end
return nil
end
-- `*terminal:bash*` -> `*terminal-copy: terminal:bash*`. The surrounding
-- asterisks are stripped before nesting so the result reads as one
-- generated-buffer name rather than two.
local function snapshot_name_for(term_buf)
local name = buffer_name(term_buf) or "terminal"
return string.format("*terminal-copy: %s*", (name:gsub("^%*", ""):gsub("%*$", "")))
end
-- Q#TC7: the snapshot text comes from the SAME serializer selection-copy
-- uses, so soft wraps, wide glyphs, clusters and trailing blanks cannot
-- drift between the two.
local function render_snapshot(record)
local text = raw_copy_retained(record.terminal) or ""
local buf = record.buffer
local len = buf:len()
-- Snapshot writes bypass the read-only intercept; everything else is
-- rejected by it.
if len > 0 then buf:delete(0, len, { bypass_intercept = true }) end
if #text > 0 then buf:insert(0, text, { bypass_intercept = true }) end
end
local function ensure_snapshot(term_buf)
local name = snapshot_name_for(term_buf)
local record = snapshots[name]
if record and record.buffer:is_valid() then
-- Q#TC8: re-invoking refreshes IN PLACE. Retarget the terminal too,
-- in case a terminal buffer was recreated under the same name.
record.terminal = term_buf
return record
end
local buf = find_buffer_by_name(name) or pmacs.buffer.create(name)
record = { terminal = term_buf, buffer = buf }
snapshots[name] = record
-- Q#TC6a — BOTH calls, and the second is the load-bearing one.
--
-- An intercept guards the dispatch/edit path only. It does NOT set
-- `Buffer::read_only` (deliberately independent), and no Lua binding
-- sets that flag at all, so an optimistic CRDT op from a semantic
-- frontend bypasses the intercept AND passes `ensure_writable()` —
-- mutating the daemon buffer in lockstep with the mirror, with no
-- divergence to notice. `set_round_trip_input` prevents that at the
-- only point it can be prevented: `dispatch_idle_for` reports false
-- while this buffer is focused, so the frontend never applies
-- optimistically and never emits the op. It is the guard, not
-- hardening.
pmacs.buffer.add_intercept(buf, function()
error(name .. " is read-only")
end)
pmacs.buffer.set_round_trip_input(buf, true)
pmacs.keymap.bind { scope = "buffer", buffer = buf,
sequence = "g", command = "terminal.copy-refresh" }
pmacs.keymap.bind { scope = "buffer", buffer = buf,
sequence = "q", command = "terminal.copy-quit" }
-- Q#TC8 lifecycle, both directions. Killing the terminal takes its
-- snapshot with it; killing the snapshot alone leaves the terminal
-- running and merely forgets the record, so a later invoke rebuilds.
--
-- `on_removed` is sound here because every user-facing kill path
-- routes through `pmacs.buffer.kill`, which fires the callbacks. The
-- terminal manager's own `prune` does not — but it never removes a
-- buffer either; it REACTS to one already gone from the registry. A
-- child exiting therefore leaves both the terminal and its snapshot
-- alive, which is what makes reading back a finished command's output
-- work at all.
pcall(pmacs.buffer.on_removed, term_buf, function()
local current = snapshots[name]
if current and current.buffer:is_valid() then
pcall(pmacs.buffer.kill, current.buffer)
end
snapshots[name] = nil
end)
pcall(pmacs.buffer.on_removed, buf, function()
snapshots[name] = nil
end)
return record
end
-- The snapshot record whose buffer the active window shows, or nil.
local function snapshot_for_current_buffer()
local buf = pmacs.window.buffer()
if not buf then return nil end
local name = buffer_name(buf)
if not name then return nil end
return snapshots[name]
end
function terminal.copy_mode(term_buf)
term_buf = term_buf or pmacs.window.buffer()
assert(term_buf, "terminal.copy-mode: no active buffer")
if not terminal.is_terminal(term_buf) then
error("terminal.copy-mode: the current buffer is not a terminal", 0)
end
local record = ensure_snapshot(term_buf)
render_snapshot(record)
pmacs.window.switch_buffer(record.buffer)
return record.buffer
end
pmacs.command.define {
name = "terminal.copy-mode",
description = "Open a searchable read-only snapshot of this terminal's scrollback.",
fn = function() return terminal.copy_mode() end,
}
pmacs.command.define {
name = "terminal.copy-refresh",
description = "Re-snapshot the source terminal into this copy buffer.",
fn = function()
local record = snapshot_for_current_buffer()
if not record then return end
if not record.terminal:is_valid() then
pmacs.editor.set_status("terminal.copy-refresh: the source terminal is gone")
return
end
render_snapshot(record)
end,
}
pmacs.command.define {
name = "terminal.copy-quit",
description = "Return to the terminal this copy buffer was taken from.",
fn = function()
local record = snapshot_for_current_buffer()
if not record then return end
if record.terminal:is_valid() then
pmacs.window.switch_buffer(record.terminal)
end
end,
}
pmacs.command.define {
name = "terminal.copy-selection",
description = "Copy the active terminal selection.",

View File

@ -389,19 +389,52 @@ If it does not, stop and repair the remote/fetch configuration.
**isolated-config workspace sweep 3,177 across 92 suites, zero failures**;
`git diff --check` clean. Gates were run against the committed tree.
## Terminal config + copy mode arc — Stage 1 IN REVIEW
## Terminal config + copy mode arc — Stage 1 MERGED; Stage 2 IN REVIEW
- 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 = `githubsucks/terminal-config`**, worktree
`../pmacs-terminal-config`, based on `githubsucks/main` @ `d152120`
and merged up to `c93f9ee` during review round 1. Profiles,
scrollback, escape key, and the `C-c t` opening binding.
- **Stage 2 = `terminal-copy-mode`, not started.** Branch it off `main`
after Stage 1 merges: no dependency, but both edit
`builtin/runtime/terminal.lua`.
- **Stage 1 MERGED as #173** (`main` @ `cf54270`, 2026-07-26, one review
round, twelve checks green). Branch `githubsucks/terminal-config` and
worktree `../pmacs-terminal-config` retained.
- **Stage 2 = `githubsucks/terminal-copy-mode`**, worktree
`../pmacs-terminal-copy-mode`, based on `githubsucks/main` @
`cf54270`. Copy mode: `M-x terminal.copy-mode` / `C-c C-t`.
- **Stage 2 ships eight of nine criteria, and the missing one is named.**
Criterion 17 (a real semantic frontend proving neither daemon buffer
nor mirror mutates) is **not pinned**: the optimistic apply exists only
in `pmacs-gpu/src/main.rs`, and the headless `SemanticClient` every
other semantic test uses has no optimistic path, so a faithful test
must drive the real GPU binary — the `a37` foundation, which CI never
compiles, silently skips without the binary, and is load-sensitive. A
second test on that footing buys the appearance of coverage. Both
halves of the mechanism are pinned **ungated** instead: acceptance 16
(the guard is armed — `dispatch_idle` false while the snapshot is
focused) and 16b (the hazard is real — the snapshot's `is_read_only()`
is **false** despite the intercept, so nothing at the rope/CRDT
boundary would stop an op that did arrive). The wire-level half is an
explicit obligation of the CI `crdt`-coverage lane.
- Load-bearing Stage 2 decisions:
- **The snapshot MATERIALIZES into an ordinary buffer**, so isearch,
motion, selection and the kill ring work with no new substrate, and
"keys must not reach the child" dissolves structurally — the
transport arm keys on `is_terminal(buffer_id)` and a snapshot is not
a terminal. **The dispatch-shadow count stays at six.**
- **One serializer, not two** (Q#TC7): `copy_retained` builds a
whole-range *selection* and hands it to `copy_selection_bytes`.
- **`prune` reacts to removal rather than causing it** — it filters on
`!registry.contains(buffer_id)`, so a child exiting does NOT remove
the terminal buffer. That is why `on_removed` is a sound teardown
hook, and why a finished command's output stays readable.
- **Five bites, five different wrong implementations.** Removing
`set_round_trip_input` fails acceptance 16 **in the default
configuration** (the whole reason that pin is ungated); a naive
independently-written serializer fails all four unit pins, with the
diffs naming each drift mode (broken soft wrap, untrimmed blanks,
trailing newline); making re-invoke create a fresh buffer fails 18;
dropping the kill-with-terminal teardown fails 18; removing the
intercept fails 16b. Each failed exactly one test.
- 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`;

View File

@ -1,9 +1,16 @@
# 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.**
2026-07-25. APPROVED after four review rounds. Stage 1 MERGED as #173
(`main` @ `cf54270`, 2026-07-26). Stage 2 implemented on branch
`terminal-copy-mode` off `main` @ `cf54270`; no protocol change.**
**Stage 2 ships eight of its nine criteria.** Criterion 17's semantic-frontend
end-to-end pin is deliberately absent — see the note under it — because a
faithful version requires the real `pmacs-gpu` optimistic path, and therefore
the `a37` foundation, which CI never compiles and which skips silently. Both
halves of the *mechanism* it guards are pinned ungated instead (16, 16b). No
other criterion is partial.
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
@ -576,6 +583,28 @@ additive, on its own binding, and does not replace scroll-and-select.
emitted, bypasses the Lua intercept, passes `ensure_writable()`, and
mutates **both sides** — a buffer the editor calls read-only silently
accepts an edit.
**NOT PINNED as specified, deliberately, and this is the one gap in
Stage 2.** A faithful test has to drive the *real* `pmacs-gpu` binary:
the optimistic apply lives only in `pmacs-gpu/src/main.rs`
(`optimistic_crdt_insert` / `optimistic_insert_text`), and the headless
`SemanticClient` the other semantic tests use has no optimistic path at
all, so it cannot produce the op whose absence is the claim. That means
building on the `a37` foundation — which is `crdt`-gated so CI never
compiles it, **returns `ok` without running** when `pmacs-gpu` is absent
from the target directory, and is load-sensitive enough to pass and fail
at the same commit twenty minutes apart. A second test on that footing
would add the appearance of coverage without the substance.
What IS pinned instead, ungated and in CI: acceptance 16 asserts the
guard is armed (`dispatch_idle` false while the snapshot is focused, so
no replica can apply optimistically or emit), and acceptance 16b asserts
the hazard is real by showing the snapshot buffer's `is_read_only()` is
**false** despite the intercept — i.e. nothing at the rope/CRDT boundary
would stop such an op if one arrived. Together those cover both halves of
Q#TC6a's *mechanism*. What remains unproven is only the end-to-end wire
behaviour of a real GPU frontend, and it stays an explicit obligation of
the CI `crdt`-coverage lane rather than being quietly dropped.
18. Re-invoking against the same terminal refreshes in place; the buffer count
does not grow (Q#TC8). Killing the snapshot leaves the terminal running;
killing the terminal removes the snapshot.

View File

@ -8836,6 +8836,25 @@ fn install_terminal(
)?;
}
{
let manager = manager.clone();
terminal.set(
"_copy_retained",
// Q#TC7: returns the whole retained range as a string, through
// the same serializer selection-copy uses. Takes an explicit
// buffer rather than resolving the active view, because copy
// mode reads a terminal that may not be displayed — and
// because the caller already holds the handle it keyed its
// snapshot on.
lua.create_function(move |lua, buffer: BufferIdLua| {
let Some(bytes) = manager.borrow().copy_retained(buffer.0) else {
return Ok(None);
};
Ok(Some(lua.create_string(&bytes)?))
})?,
)?;
}
pmacs.set("terminal", terminal)
}

View File

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

View File

@ -0,0 +1,516 @@
//! Terminal copy-mode acceptance (Stage 2 of
//! `docs/terminal-config-and-copy-mode-framing.md`, criteria 13-21).
//!
//! **Deliberately NOT `#[cfg(feature = "crdt")]`.** CI never enables that
//! feature, so a gated suite is written and then never run — 264 tests are
//! dark workspace-wide for exactly that reason. Criterion 16, the
//! round-trip gate Q#TC6a's entire safety argument rests on, needs no CRDT
//! and must be caught by the default configuration.
use std::thread;
use std::time::{Duration, Instant};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use mlua::Value;
use pmacs::cell::{CellSize, Glyph};
use pmacs::editor::EditorState;
use pmacs::protocol::FrontendId;
use pmacs::terminal::TerminalViewKey;
const SNAPSHOT_NAME: &str = "*terminal-copy: terminal:sh*";
fn exec(state: &EditorState, src: &str) {
state
.lua_host
.lua()
.load(src)
.exec()
.unwrap_or_else(|e| panic!("lua failed: {src}\n{e}"));
}
fn eval<T: mlua::FromLuaMulti>(state: &EditorState, src: &str) -> T {
state
.lua_host
.lua()
.load(src)
.eval()
.unwrap_or_else(|e| panic!("lua eval failed: {src}\n{e}"))
}
fn eval_err(state: &EditorState, src: &str) -> String {
let result: mlua::Result<Value> = state.lua_host.lua().load(src).eval();
match result {
Ok(_) => panic!("expected an error from: {src}"),
Err(e) => e.to_string(),
}
}
fn press(state: &mut EditorState, code: KeyCode, mods: KeyModifiers) {
state.dispatch_key(FrontendId::LOCAL, KeyEvent::new(code, mods));
}
/// The live terminal screen's text, used only to wait for the child.
fn screen_text(state: &EditorState, buffer: pmacs::buffer::BufferId) -> String {
let manager = state.terminal_manager.borrow();
let Some(snapshot) = manager.snapshot(buffer) else {
return String::new();
};
let mut text = String::new();
for cell in &snapshot.cells {
match &cell.glyph {
Glyph::Char(c) => text.push(*c),
Glyph::Cluster(b) => text.push_str(&String::from_utf8_lossy(b)),
Glyph::Continuation => {}
}
}
text
}
fn tick_until(state: &mut EditorState, needle: &str, buffer: pmacs::buffer::BufferId) -> bool {
let deadline = Instant::now() + Duration::from_secs(5);
loop {
state.tick_processes();
if screen_text(state, buffer).contains(needle) {
return true;
}
if Instant::now() >= deadline {
return false;
}
thread::sleep(Duration::from_millis(20));
}
}
fn terminal_buffers(state: &EditorState) -> Vec<pmacs::buffer::BufferId> {
let manager = state.terminal_manager.borrow();
state
.core
.borrow()
.registry
.borrow()
.ids()
.iter()
.copied()
.filter(|id| manager.is_terminal(*id))
.collect()
}
/// A child that overflows the 24-row screen and then goes quiet, so its
/// early lines exist ONLY in scrollback — which is what makes criterion
/// 15's "content only in scrollback" claim meaningful.
const FILL_PROFILE: &str = r#"
pmacs.terminal.profiles.fill = {
command = "/bin/sh",
args = { "-c",
"printf 'NEEDLE-IN-SCROLLBACK\r\n'; i=1; while [ $i -le 200 ]; do printf 'LINE%03d\r\n' $i; i=$((i+1)); done; printf 'DONE\r\n'; exec cat" },
}
"#;
/// Open the fill terminal, wait for the child to finish, and return its id.
fn open_fill_terminal(state: &mut EditorState) -> pmacs::buffer::BufferId {
exec(state, FILL_PROFILE);
let before = terminal_buffers(state);
exec(
state,
r#"TERM_BUF = pmacs.terminal.open { profile = "fill" }"#,
);
let fresh: Vec<_> = terminal_buffers(state)
.into_iter()
.filter(|id| !before.contains(id))
.collect();
assert_eq!(fresh.len(), 1, "exactly one terminal must have opened");
let buffer = fresh[0];
assert!(tick_until(state, "DONE", buffer), "the child must finish");
buffer
}
/// Give LOCAL a window on the terminal and register/claim its view, which
/// is what makes `dispatch_key`'s terminal transport arm reachable.
fn focus_terminal(state: &EditorState, buffer: pmacs::buffer::BufferId) {
state.core.borrow_mut().switch_active_buffer(buffer).ok();
let window = state.core.borrow().active_window_id();
let key = TerminalViewKey::new(FrontendId::LOCAL, window, buffer);
let mut manager = state.terminal_manager.borrow_mut();
manager.register_view(key);
manager.claim_controller(key);
let _ = manager.snapshot_for_view(key, CellSize::new(10, 40));
}
fn buffer_text_by_name(state: &EditorState, name: &str) -> Option<String> {
eval(
state,
&format!(
r"
for _, id in ipairs(pmacs.buffer.list()) do
local ok, d = pcall(pmacs.describe.buffer, id)
if ok and d and d.name == {name:?} then
return id:slice(0, id:len())
end
end
return nil
"
),
)
}
fn active_buffer_name(state: &EditorState) -> String {
eval(
state,
r"local b = pmacs.window.buffer(); return (pmacs.describe.buffer(b)).name",
)
}
fn buffer_count(state: &EditorState) -> usize {
state.core.borrow().registry.borrow().ids().len()
}
/// Acceptance 13: the snapshot's text is exactly the whole retained range
/// as the existing copy path serializes it.
///
/// Compared against `_copy_retained` rather than a literal, so this cannot
/// pass by both sides drifting the same way; the exact-bytes fidelity
/// claims (criterion 14) are pinned at the unit level in
/// `src/terminal/view.rs`, against the same projection fixtures that pin
/// `copy_selection_bytes` itself.
#[test]
fn acc13_snapshot_is_the_whole_retained_range_through_the_shared_serializer() {
let mut state = EditorState::new();
let terminal = open_fill_terminal(&mut state);
focus_terminal(&state, terminal);
exec(&state, "SNAP = pmacs.terminal.copy_mode(TERM_BUF)");
let snapshot_text = buffer_text_by_name(&state, SNAPSHOT_NAME).expect("snapshot buffer exists");
let serialized: String = eval(
&state,
r"return pmacs.terminal._copy_retained(TERM_BUF) or ''",
);
assert_eq!(
snapshot_text, serialized,
"the snapshot must be byte-identical to the shared serializer's output"
);
assert!(
snapshot_text.contains("NEEDLE-IN-SCROLLBACK") && snapshot_text.contains("LINE200"),
"the range must span scrollback AND the visible screen"
);
state.process_supervisor.borrow_mut().shutdown();
}
/// Acceptance 14 (end-to-end half): the snapshot really is a rope-backed
/// document buffer and not a terminal, which is what makes every
/// buffer-shaped consumer work and what removes the transport arm.
#[test]
fn acc14_the_snapshot_is_an_ordinary_non_terminal_buffer() {
let mut state = EditorState::new();
let terminal = open_fill_terminal(&mut state);
focus_terminal(&state, terminal);
exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)");
let is_terminal: bool = eval(
&state,
r"local b = pmacs.window.buffer(); return pmacs.terminal.is_terminal(b)",
);
assert!(
!is_terminal,
"the snapshot must NOT be a terminal — that is what structurally \
removes the transport arm rather than guarding it"
);
assert_eq!(active_buffer_name(&state), SNAPSHOT_NAME);
state.process_supervisor.borrow_mut().shutdown();
}
/// Acceptance 15: isearch finds content that exists ONLY in scrollback,
/// with no change to `src/search.rs` (B1).
#[test]
fn acc15_isearch_finds_content_only_in_scrollback() {
let mut state = EditorState::new();
let terminal = open_fill_terminal(&mut state);
focus_terminal(&state, terminal);
// The needle is off the visible screen: the live terminal cannot see it.
assert!(
!screen_text(&state, terminal).contains("NEEDLE-IN-SCROLLBACK"),
"precondition: the needle must have scrolled off the live screen"
);
exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)");
state.core.borrow_mut().set_cursor_byte(0);
// Drive real isearch: C-s then the needle.
press(&mut state, KeyCode::Char('s'), KeyModifiers::CONTROL);
for ch in "NEEDLE-IN-SCROLLBACK".chars() {
press(&mut state, KeyCode::Char(ch), KeyModifiers::NONE);
}
let cursor = state.core.borrow().cursor();
press(&mut state, KeyCode::Enter, KeyModifiers::NONE);
let text = buffer_text_by_name(&state, SNAPSHOT_NAME).expect("snapshot");
let expected = text
.find("NEEDLE-IN-SCROLLBACK")
.expect("the needle is in the snapshot") as u64;
assert_eq!(
cursor,
expected,
"isearch must land on the scrollback-only match; text was {:?}",
&text[..text.len().min(80)]
);
state.process_supervisor.borrow_mut().shutdown();
}
/// Acceptance 16 — the load-bearing pin, and the reason this suite is
/// ungated. `set_round_trip_input` is the ONLY thing standing between a
/// replica frontend and unauthorized mutation (Q#TC6a), so its regression
/// must be caught in the configuration CI actually compiles.
#[test]
fn acc16_dispatch_idle_is_false_while_the_snapshot_is_focused() {
let mut state = EditorState::new();
let terminal = open_fill_terminal(&mut state);
focus_terminal(&state, terminal);
exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)");
assert!(
!state.dispatch_idle(),
"a focused snapshot must round-trip keys, so no replica applies \
optimistically and none emits a CRDT op"
);
// ...and it is the SNAPSHOT that does it, not merely "some terminal
// buffer is around": switching to an ordinary buffer restores idle.
exec(
&state,
r#"pmacs.window.switch_buffer(pmacs.buffer.create("*plain*"))"#,
);
assert!(state.dispatch_idle(), "an ordinary buffer is idle again");
state.process_supervisor.borrow_mut().shutdown();
}
/// Acceptance 16 (the other half): the intercept rejects ordinary edits,
/// and — the fact that makes round-trip load-bearing rather than defence
/// in depth — the buffer is **not** `read_only` at the rope boundary.
#[test]
fn acc16b_the_intercept_rejects_edits_but_is_not_rope_level_protection() {
let mut state = EditorState::new();
let terminal = open_fill_terminal(&mut state);
focus_terminal(&state, terminal);
exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)");
let before = buffer_text_by_name(&state, SNAPSHOT_NAME).expect("snapshot");
press(&mut state, KeyCode::Char('z'), KeyModifiers::NONE);
let after = buffer_text_by_name(&state, SNAPSHOT_NAME).expect("snapshot");
assert_eq!(before, after, "the read-only intercept rejects self-insert");
// Q#TC6a, stated as a test so the next reader does not mistake the
// intercept for real immutability: no Lua binding sets
// `Buffer::read_only`, so this buffer accepts rope/CRDT mutation and
// only the round-trip mark above keeps a replica from producing one.
let core = state.core.borrow();
let registry = core.registry.borrow();
let ids = registry.ids();
let snapshot = ids
.iter()
.copied()
.find(|id| {
registry
.get(*id)
.is_ok_and(|buf| buf.name() == SNAPSHOT_NAME)
})
.expect("snapshot buffer id");
assert!(
!registry
.get(snapshot)
.expect("snapshot buffer")
.is_read_only(),
"the Lua intercept does NOT set Buffer::read_only — this is why \
set_round_trip_input is the guard and not hardening"
);
drop(registry);
drop(core);
state.process_supervisor.borrow_mut().shutdown();
}
/// Acceptance 18: re-invoking refreshes in place, and the lifecycle runs
/// both directions.
#[test]
fn acc18_reinvoke_refreshes_in_place_and_lifecycle_runs_both_ways() {
let mut state = EditorState::new();
let terminal = open_fill_terminal(&mut state);
focus_terminal(&state, terminal);
exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)");
let count_after_first = buffer_count(&state);
exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)");
exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)");
assert_eq!(
buffer_count(&state),
count_after_first,
"re-invoking must refresh in place, not accumulate buffers"
);
// Killing the snapshot alone leaves the terminal running.
exec(
&state,
&format!(
r"
for _, id in ipairs(pmacs.buffer.list()) do
local ok, d = pcall(pmacs.describe.buffer, id)
if ok and d and d.name == {SNAPSHOT_NAME:?} then pmacs.buffer.kill(id) end
end
"
),
);
assert!(
state.terminal_manager.borrow().is_terminal(terminal),
"killing the snapshot must leave the terminal untouched"
);
assert!(
buffer_text_by_name(&state, SNAPSHOT_NAME).is_none(),
"the snapshot buffer is gone"
);
// ...and it can be rebuilt afterwards.
exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)");
assert!(
buffer_text_by_name(&state, SNAPSHOT_NAME).is_some(),
"a later invoke rebuilds the snapshot"
);
// Killing the terminal takes its snapshot with it.
exec(&state, "pmacs.terminal.terminate(TERM_BUF)");
exec(&state, "pmacs.buffer.kill(TERM_BUF)");
assert!(
buffer_text_by_name(&state, SNAPSHOT_NAME).is_none(),
"killing the terminal must remove its snapshot"
);
state.process_supervisor.borrow_mut().shutdown();
}
/// Acceptance 19: `C-t` in a terminal — physically `C-c C-t`, because every
/// unescaped key goes to the child — enters copy mode; `g` refreshes and
/// `q` returns to the source terminal.
#[test]
fn acc19_escape_c_t_enters_copy_mode_and_g_and_q_work() {
let mut state = EditorState::new();
let terminal = open_fill_terminal(&mut state);
focus_terminal(&state, terminal);
let terminal_name = active_buffer_name(&state);
// The escape, then the terminal-local binding.
press(&mut state, KeyCode::Char('c'), KeyModifiers::CONTROL);
press(&mut state, KeyCode::Char('t'), KeyModifiers::CONTROL);
assert_eq!(
active_buffer_name(&state),
SNAPSHOT_NAME,
"C-c C-t must enter copy mode"
);
// `g` re-snapshots in place.
let before = buffer_text_by_name(&state, SNAPSHOT_NAME).expect("snapshot");
press(&mut state, KeyCode::Char('g'), KeyModifiers::NONE);
let after = buffer_text_by_name(&state, SNAPSHOT_NAME).expect("snapshot");
assert_eq!(before, after, "a quiet terminal re-snapshots identically");
assert_eq!(
active_buffer_name(&state),
SNAPSHOT_NAME,
"g must not move us"
);
// `q` returns to the source terminal.
press(&mut state, KeyCode::Char('q'), KeyModifiers::NONE);
assert_eq!(
active_buffer_name(&state),
terminal_name,
"q must return to the terminal the snapshot was taken from"
);
state.process_supervisor.borrow_mut().shutdown();
}
/// Acceptance 20: copy mode is additive — the live terminal's own keys are
/// unchanged while a snapshot exists, and the terminal still follows its
/// tail.
#[test]
fn acc20_live_terminal_keys_are_unchanged_while_a_snapshot_exists() {
let mut state = EditorState::new();
let terminal = open_fill_terminal(&mut state);
focus_terminal(&state, terminal);
exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)");
// Back to the terminal; its five live bindings must still resolve.
exec(&state, "pmacs.window.switch_buffer(TERM_BUF)");
for (sequence, command) in [
("M-w", "terminal.copy-selection"),
("M-v", "terminal.page-up"),
("C-v", "terminal.page-down"),
("M-<", "terminal.scroll-oldest"),
("M->", "terminal.scroll-bottom"),
] {
let resolved: Option<String> = eval(
&state,
&format!(r"local d = pmacs.describe.key({sequence:?}); return d and d.command"),
);
assert_eq!(
resolved.as_deref(),
Some(command),
"{sequence} must still be the live terminal binding"
);
}
// The terminal is still following its tail: the child's last output is
// visible without scrolling.
assert!(
screen_text(&state, terminal).contains("DONE"),
"the live terminal keeps following its tail"
);
state.process_supervisor.borrow_mut().shutdown();
}
/// Acceptance 21: the dispatch-shadow count is unchanged at six, pinned by
/// the observable difference between a buffer-local keymap and a shadow —
/// `describe-key` telling the truth about `g` and `q` in the snapshot.
///
/// A seventh shadow would decode these keys before `KeymapStack::resolve`
/// ever ran, so introspection would report whatever the global binding is
/// (or nothing) while the keys behaved differently.
#[test]
fn acc21_describe_key_reports_the_truth_for_the_snapshot_bindings() {
let mut state = EditorState::new();
let terminal = open_fill_terminal(&mut state);
focus_terminal(&state, terminal);
exec(&state, "pmacs.terminal.copy_mode(TERM_BUF)");
for (sequence, command) in [("g", "terminal.copy-refresh"), ("q", "terminal.copy-quit")] {
let resolved: Option<String> = eval(
&state,
&format!(r"local d = pmacs.describe.key({sequence:?}); return d and d.command"),
);
assert_eq!(
resolved.as_deref(),
Some(command),
"describe-key must report the buffer-local {sequence} binding"
);
}
// And the binding really is scoped: back in the terminal, `q` is not
// the copy-mode command.
exec(&state, "pmacs.window.switch_buffer(TERM_BUF)");
let resolved: Option<String> = eval(
&state,
r#"local d = pmacs.describe.key("q"); return d and d.command"#,
);
assert_ne!(
resolved.as_deref(),
Some("terminal.copy-quit"),
"the snapshot's q must not leak into the terminal buffer"
);
state.process_supervisor.borrow_mut().shutdown();
}
/// Copy mode refuses a non-terminal buffer rather than producing an empty
/// snapshot of nothing.
#[test]
fn copy_mode_refuses_a_non_terminal_buffer() {
let state = EditorState::new();
let err = eval_err(&state, "return pmacs.terminal.copy_mode()");
assert!(
err.contains("not a terminal"),
"the refusal must say why: {err}"
);
}