pmacs/docs/terminal-config-and-copy-mo...

37 KiB

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 scalarsBool, Int, Num, Str (src/config_registry.rs:312) — and its own doc comment says they "are never stored --- only these four scalars (Q#CR3)". ConfigKind adds Enum, which is physically a string validated against choices fixed at define time (src/config_registry.rs:115-145). There is no table, list, or map kind.

A terminal profile is inherently a table: { command, args, cwd, env } per name. Table-valued settings are an existing named deferral of the config registry arc — the same gap that keeps pmacs.lsp.config, pmacs.pair.sets, pmacs.comment.strings, and the pmacs.parse.* proxies as raw Lua. Profiles join that list rather than forcing that deferral open here.

2. Search cannot reuse isearch in place over a terminal

SearchStore::set(buffer_id, query, matches: Vec<ByteRange>) (src/search.rs:99) keys matches by buffer and addresses them as byte ranges into that buffer's rope; the painting path materializes the source with buf.snapshot_rope().slice(0, buf.len(), ..) (src/search.rs:435).

A terminal identity buffer is empty and read-only by construction. Its content lives in TerminalScreen as cells addressed by (row, col) across history plus visible rows — there are no rope bytes to range over. Searching a terminal in place therefore means a second, parallel search facility with its own match store and its own highlight path, because terminal painting consumes owned cells and not document style spans.

3. An in-place copy mode would be the seventh dispatch shadow

dispatch_key's terminal-transport arm intercepts every key before ordinary keymap dispatch whenever active_terminal_key is Some, which keys purely on is_terminal(window.buffer_id) (src/editor.rs:1098-1107, 973-1016). A mode that keeps the terminal buffer focused while rebinding keys to motion/selection must therefore add a new precedence rung.

COHERENCE.md §6 grades that ladder weak, "and growing by one island per modal feature", records that no transient-keymap mechanism exists to migrate to (KeymapStack has exactly three fixed scopes, no layer stack, no push/pop, no lifetime), and notes that describe-key already lies while a shadow is active. It also names the counter-example: the entire picker/panel family uses ordinary buffer-local keymaps and is inspectable and rebindable.

4. What already exists and is reusable

  • retained_rows(projection) (src/terminal/view.rs:539) iterates history plus visible rows; copy_selection_bytes(rows, selection) (src/terminal/view.rs:849) serializes a range with the fidelity Stage 2 criterion 21 already pins — soft wraps joined, hard rows separated, trailing default blanks trimmed, wide glyphs and combining clusters copied once.
  • ConfigRegistry::value_epoch() (src/config_registry.rs:1127) is public and monotonic — cheap invalidation for a hot-path cache.
  • The Lua surface is define / get / set / set_local / on_change with a disposable handle (src/lua_bindings/config.rs).
  • pmacs.terminal.open already accepts command, args, cwd, env, name, rows, cols, scrollback_rows, display, window. display = "panel" already works (bottom-panel Stage 1) — the panel terminal is blocked on rendering, not on this surface.
  • Terminal buffers already carry buffer-local bindings (M-w, M-v, C-v, M-<, M->) installed by terminal.open in builtin/runtime/terminal.lua.

Stage 1 — configuration

Q#TC1 — Profiles are a raw Lua table, not a setting. pmacs.terminal.profiles maps a name to a spec table, exactly following the pmacs.lsp.config precedent. The registry holds only scalars. Rejected alternative: widening ConfigValue with a table kind — that is the config arc's own named deferral, it is cross-cutting (persistence, describe-setting rendering, the custom-file question all key on the scalar assumption), and smuggling it into a terminal PR would be the wrong place to decide it.

Q#TC2 — terminal.default-profile is String, not Enum. Enum choices are frozen at define time; profiles are user-extensible from init.lua and later. Validation happens at open time, and an unknown name must produce a pointed error that names the known profiles, not a bare "unknown profile".

Q#TC2a — the exact settings, defaults, and bounds. All three are Live (see Q#TC2b), and every default reproduces today's behavior exactly, so a tree with no settings written behaves identically (acceptance 12).

name kind default bounds
terminal.default-profile String { allow_empty: true } ""
terminal.scrollback-rows Integer 10_000 (DEFAULT_TERMINAL_SCROLLBACK_ROWS) 0 ..= 4_000_000 (MAX_TERMINAL_HISTORY_CELLS)
terminal.escape-key String { allow_empty: false } "C-c" parsed as a chord

Zero is a legal scrollback value meaning "retain no history". The core's own validation rejects only values above MAX_TERMINAL_HISTORY_CELLS (src/terminal/session.rs:114), so scrollback_rows = 0 is accepted through terminal.open today. A 1 minimum here would invent an asymmetry between the setting and the per-open field for no reason.

"" is the "no default profile" sentinel: an empty string means "fall through to $SHELL", not "a profile named empty". allow_empty: true exists precisely to express it, and the open path treats empty and unset identically.

Q#TC2b — the settings are Live, and the registry therefore accepts buffer-local overrides. That is specified rather than accidental. ConfigRegistry::set_local refuses only StartupOnly definitions (src/config_registry.rs:949); a Live setting can be pinned per buffer by anyone. Declaring these global-only is not currently expressible — a scope = "global" define flag is one of the config registry's own named deferrals, and autosave.interval-ms already has the same latent problem.

Making them StartupOnly instead would buy enforcement at the cost of the feature: the escape key could never be changed mid-session, which kills Q#TC4's whole point. So they stay Live, and resolution is defined per setting, because the three are not read at the same moment:

setting read when resolution
terminal.escape-key every keystroke in a terminal (cached) get(name, terminal_buffer)buffer-local → global → default
terminal.default-profile once, before the terminal exists get(name)global chain only
terminal.scrollback-rows once, before the terminal exists get(name)global chain only

The split is forced, not stylistic. The two open-time settings are consumed by _open before it creates the identity buffer, so there is no terminal buffer to resolve against — and no caller could have pinned a local override on a buffer that does not yet exist. pmacs.config.get(name) with no buffer argument already means exactly "the global chain, never an ambient buffer", so this is the registry's existing semantic rather than a new rule.

Consequences, stated so they are not discovered later:

  • a per-terminal escape key is a supported feature, not a bug;
  • set_local on terminal.default-profile or terminal.scrollback-rows is always inert, for any buffer, because the open path never consults a buffer chain. This is deliberate; the alternative — resolving against whichever buffer happened to be current at open time — would make a terminal's scrollback depend on what the user was looking at when they pressed the key.

Rejected alternative: resolving the open-time settings against the target window's pre-open buffer. It is expressible, but it makes an ambient buffer load-bearing for a value the user set globally, which is the trap pmacs.config's two-argument/one-argument split exists to avoid.

Q#TC3 — terminal.scrollback-rows is Integer with bounds, and an explicit per-open scrollback_rows still wins. The precedence is explicit argument over global setting — there is no ambient buffer in this chain at all (Q#TC2b resolves it through get(name)), so the rule is simply that what a caller passes to terminal.open beats what the user configured globally. The bounds above come from the existing validation, so the setting cannot express a value the core will reject.

Q#TC3a — profile resolution order, field by field. profile is accepted by pmacs.terminal.open as well as the command, so a Lua caller is not forced through the command to use one. For each field, the first source that supplies it wins:

  1. an explicit pmacs.terminal.open field;
  2. the named profile's field — profile argument, else terminal.default-profile when non-empty;
  3. the scalar setting, where one exists (scrollback_rows only);
  4. the built-in fallback (command = $SHELL, else /bin/sh).

env is the one field where "first wins" is ambiguous, so it is stated: profile env and explicit env are merged, with explicit entries overriding profile entries of the same name. Any other reading silently drops half a user's environment.

An explicitly passed profile that does not exist is an error even when terminal.default-profile is valid — a typo must not silently fall back to the default.

Q#TC4 — terminal.escape-key is a String chord spelling, parsed once and cached by (buffer_id, value_epoch). is_terminal_escape_chord (src/editor.rs:4413) currently compares against a literal C-c. Reading and parsing a setting on every keystroke in a terminal is not acceptable in that path.

The cache key must include the buffer. value_epoch() advances only on set / set_local / removal (src/config_registry.rs:918, 970, 1011, 1029) — it does not move when the focused terminal changes. An epoch-only cache therefore serves terminal A's escape chord to terminal B for as long as no setting is written, which is exactly the case where nothing looks wrong. Keying on (buffer_id, value_epoch) is the minimum correct identity.

Q#TC4c — the cache lives on TerminalSession, so its lifecycle is the terminal's. Revision 3 named the key (buffer_id, value_epoch) but not the storage, and the two obvious storages behave differently on A→B→A:

  • a single last-entry cache reparses on every switch between two terminals, and re-reports an invalid value each time — a status line that scolds you for a setting you already know about, forever;
  • an editor-side map preserves "parsed and reported once" but leaks an entry per terminal unless something purges it, and that purge is a second thing to get wrong.

TerminalSession (src/terminal/session.rs:215) is created in TerminalManager::open and dropped on kill/prune, so putting the cache there gets the lifecycle for free with no purge hook to forget. It carries the parsed chord, the value_epoch it was parsed at, and whether the current invalid value has already been reported.

"Reports once" means once per terminal, per effective invalid value. A→B→A must not re-report. Changing the setting from one invalid value to a different invalid value does re-report, because that is new information about a new mistake.

The reporting channel is EditorCore::status — the same channel send_terminal_bytes already uses for terminal failures (src/editor.rs:1122). Explicitly not pmacs.error: it is not installed as a module anywhere in src/lua_bindings, so its call sites across the runtime are dead, and a report sent there would be a report nobody sees.

Q#TC4a — an unparseable escape key must not brick terminal input. A bad value falls back to C-c and reports once. The failure mode this avoids is severe: with no escape chord, every key goes to the child and the user cannot reach any editor binding to fix the setting that broke it.

Q#TC4b — repeating the configured escape sends THAT chord to the child, not Ctrl-C. The double-escape arm currently writes a hardcoded &[0x03] (src/editor.rs:988). With terminal.escape-key = "C-x", C-x C-x would send Ctrl-C — and literal Ctrl-X would become unreachable, since the first C-x is always consumed as the escape. The repeat arm must encode the configured chord through the existing crate::terminal::input::encode_key path, which is also how it inherits application-cursor and modifier handling rather than growing a second encoder.

Corollary worth pinning: after changing the escape away from C-c, an ordinary C-c must reach the child as 0x03 like any other unescaped key.

Q#TC5 — the terminal command gains an optional profile argument and otherwise keeps its current behavior; $SHELL remains the fallback when no profile is configured. No existing invocation changes meaning.

Q#TC10 — the terminal opening keybinding is pulled forward into Stage 1. COHERENCE.md Priority 1 names "a terminal keybinding" as part of protecting the golden journey, §2 step 8 grades the terminal "works but undiscoverable", and this stage already edits terminal.lua. Panel rendering imposes no dependency on binding a command that already exists. Close/kill semantics stay with the panel work, where the entry and exit points get designed together.

The chord is decided and scouted, not deferred: C-c t, global. See Q#TC8a for the collision evidence and for why binding under the existing C-c prefix is a new leaf rather than a shadow.

Q#TC6 — copy mode MATERIALIZES into an ordinary buffer. It does not add a dispatch shadow.

M-x terminal.copy-mode snapshots the retained rows into a read-only, path-less buffer (*terminal-copy: NAME*) and displays it. That buffer is an ordinary document buffer, so:

  • isearch works, with no new search substrate — it is a rope, so SearchStore and the existing match-painting path apply unchanged. Ground truth 2 is answered by not fighting it.
  • motion, selection, M-w, the kill ring, even M-x occur-style consumers work — everything that operates on a buffer.
  • The "keys must not reach the child" problem dissolves structurally. active_terminal_key keys on is_terminal(window.buffer_id); the snapshot buffer is not a terminal, so the transport arm never fires. No new guard, no new precedence rung, and ground truth 3's coherence cost is avoided rather than paid.
  • describe-key stays truthful, because the bindings are buffer-local and inspectable — the idiom COHERENCE.md §6 identifies as the right side of the line.

Q#TC6a — the snapshot is BOTH intercept-read-only AND round-trip-marked, and set_round_trip_input is the ONLY thing standing between a replica frontend and unauthorized mutation.

The established idiom is two calls: listview.lua:106 and compile.lua:272 each pair pmacs.buffer.add_intercept with pmacs.buffer.set_round_trip_input(buf, true). Revision 2 described the intercept as the guard and round-trip as defence in depth. That was wrong, and the correction matters:

  • A Lua intercept guards the dispatch/edit path only. It does not set Buffer::read_only, which is "deliberately independent of edit intercepts" (src/buffer.rs:493-500) — that flag is what makes terminal identity buffers reject rope, undo/redo, and remote-CRDT mutation alike.
  • No Lua binding sets read_only at all. The whole src/lua_bindings tree only ever reads it (fold.rs:313). A Lua-created "read-only" buffer is therefore read-only against dispatch and nothing else.
  • So an optimistic CrdtOp from a semantic frontend bypasses the intercept and passes ensure_writable(). It is applied. The daemon buffer mutates in lockstep with the mirror — the user silently edits a buffer the editor told them is read-only. There is no divergence to notice, which is worse than divergence.

set_round_trip_input prevents this at the only point it can be prevented: it makes dispatch_idle_for report false while the buffer is focused, so the frontend never applies optimistically and never emits the op. It is not hardening — it is the guard.

Two things follow, and both are recorded rather than fixed here:

  • The same exposure exists today for every Lua-created read-only buffer — listview panels and *compilation* included. They are correct only because they call set_round_trip_input. This arc must not be the place that unilaterally changes that substrate.
  • Exposing Buffer::set_read_only to Lua would make these buffers genuinely immutable at the rope/CRDT boundary the way terminal identity buffers are, turning round-trip back into real defence in depth. That is a substrate change affecting listview and compile as much as this snapshot, so it is named in Deferred with its own lane.

Q#TC7 — the materializer reuses the existing serializer. A whole-range variant of copy_selection_bytes over retained_rows inherits the criterion 21 fidelity rather than re-deriving soft-wrap, wide-glyph, and trailing-blank behavior. Writing a second serializer would guarantee the two drift.

Q#TC8 — one snapshot buffer per terminal, reused on re-invoke. Re-running the command against the same terminal replaces the contents in place rather than accumulating buffers. It is killed with its terminal; killing the snapshot alone leaves the terminal untouched.

Q#TC8a — the chords, decided and collision-scouted.

Worth stating first because it is easy to get backwards: in a terminal window every unescaped key goes to the child, so terminal-local bindings are reached as <escape> <key>. The existing M-w copy is physically C-c M-w. The escape consumes itself and the next key starts a fresh ordinary sequence, which is also why C-c-leading bindings are structurally unreachable inside a terminal.

action scope binding physically typed
open a terminal (Q#TC10) global C-c t C-c t
enter copy mode terminal buffer C-t C-c C-t
refresh snapshot snapshot buffer g g
return to terminal snapshot buffer q q

Scouted against the real keymaps:

  • C-c t is free. No bare global C-c binding exists; C-c is already a live global prefix from fold.lua:48-52 (C-c @ …), and C-c C-k is buffer-scoped in compile/async. C-c t is a new leaf under an existing prefix, not a shadow.
  • C-t is globally edit.transpose-chars (editops.lua:909), and binding it buffer-locally is legitimate: keymap.bind's strictness rejects binding a prefix of an existing sequence within a scope (keymap_bind_conflict_surfaces_at_bind_time — "would shadow"), not cross-scope shadowing, which is what scopes are for. Listview already binds n/p/g/q/RET/SPC buffer-locally. Transpose-chars is meaningless in a read-only terminal buffer.
  • C-c C-t matches emacs-libvterm's own vterm-copy-mode chord, so the muscle memory transfers.
  • g / q in the snapshot follow listview's precedent exactly.

Named limitation: C-c t cannot open a terminal from inside a terminal, because C-c is consumed as the escape there. M-x terminal still works. This is the documented consequence of Stage 2 criterion 19, not a new defect.

These are what make acceptance 21's describe-key claim testable: named bindings, in named buffers, that introspection must report truthfully.

Q#TC9 — the live-terminal keys stay. M-w, M-v, C-v, M-<, M-> on the terminal buffer are the live affordances and do not change. Copy mode is additive, on its own binding, and does not replace scroll-and-select.

Bets

  • B1. Materializing gives search for free: no second match store, no second highlight path, no terminal-specific search UI. Scored by Stage 2 landing with zero changes under src/search.rs.
  • B2. Point-in-time is sufficient for read-back/search/copy. Scored by use; if false, the live frozen mode in Deferred becomes the real feature and this becomes its snapshot fallback.
  • B3. No protocol change. The snapshot is an ordinary buffer, so both frontends render it with existing machinery. Scored by the diff.
  • B4. The escape-key cache keyed by (buffer_id, value_epoch) never becomes stale in a way a user can observe. Scored by two acceptances, not one: changing the setting mid-session (8) and two terminals with different buffer-local values and no write between them (7). Revision 1's epoch-only cache would pass the first and fail the second, which is why the bet now names both.
  • B5. Buffer-local escape keys are a feature rather than a hazard. Unscored and honestly so: the registry cannot express global-only, so this is what we get either way. If per-terminal escapes turn out to confuse more than they help, the fix is the config registry's scope = "global" deferral, not a terminal change.

Deferred (named)

  • Live frozen copy mode (true vterm-copy-mode semantics: freeze the terminal in place, navigate it, resume). Strictly larger; needs either the transient-keymap primitive COHERENCE.md §6 specifies or a deliberate seventh shadow.

  • Shell integration — cwd tracking, prompt marks, command zones, and the VS Code cluster downstream of it (command decorations, exit-code markers, rerun, sticky scroll, terminal IntelliSense). Its own arc, with a security framing.

  • Table-valued settings — the config registry's own deferral. This arc adds a second blocked adopter (after pmacs.lsp.config / pmacs.pair.sets); worth recording as evidence when that deferral is ranked.

  • A scope = "global" define flag — also the config registry's own deferral, and this arc is its second live case after autosave.interval-ms. Until it exists, set_local on any Live setting is accepted whether or not the owner wants it, so Q#TC2b specifies the behavior instead of pretending it is prevented.

  • Panel terminal — blocked on bottom-panel Stage 2 (semantic frontends are not panel_capable). display = "panel" already exists and works on the grid frontend.

  • OSC 8 hyperlinks, images (sixel/kitty), faint/blink/conceal/ strikethrough (needs a shared Style widening, so a protocol bump), cursor shape/blink, kitty keyboard protocol.

  • Terminal session persistence/reconnect across editor restart.

  • A terminal close/kill command — the remaining half of COHERENCE.md §2 step 8's discoverability gap. It belongs with the panel-terminal work, where entry and exit points get designed together. The opening keybinding is no longer deferred: Stage 1 carries it as Q#TC10.

  • Genuine immutability for generated buffers — and it is bigger than a Lua setter. Today no Lua binding sets read_only (src/lua_bindings only reads it, fold.rs:313), so every Lua-created "read-only" buffer — listview panels, *compilation*, and this snapshot — is read-only against dispatch alone and relies entirely on set_round_trip_input (Q#TC6a).

    Merely exposing set_read_only would break all three. The intercept-bypass path is ensure_writable-guarded too: apply_edit_skip_intercepts calls it first (src/buffer.rs:994), and that is exactly the primitive an owner uses to rewrite its own generated buffer. Flipping the flag would stop listview refreshing, *compilation* streaming, and this snapshot refreshing — the very operations those buffers exist for.

    So the lane needs two things, not one: genuine immutability at the rope/CRDT boundary, and an owner-authorized update path that is not simply "skip the intercepts". Naming only the setter would have made it look like a one-line follow-up.

Acceptance

Stage 1 — terminal-config

  1. pmacs.terminal.profiles accepts a strict spec table per name and rejects unknown fields before anything is spawned, matching terminal.open's existing transactional contract.
  2. terminal.default-profile naming an unknown profile fails at open with an error that lists the known profile names, and creates no buffer, session, or process. An explicitly passed unknown profile fails the same way even when terminal.default-profile is valid (Q#TC3a). 2a. That diagnostic is total over a malformed profiles table (review round 1). pmacs.terminal.profiles is a raw user table, so listing its names must not assume its keys are comparable and rendering a requested name must not assume it is a string: a table holding both a string and a numeric key made table.sort raise attempt to compare number with string on the unknown-profile path, replacing the exact error being asked for, and %q raises on a non-string profile argument. Both are partial functions applied to user input on a diagnostic path — the failure class is "the error reporter is the thing that fails".
  3. Field-by-field resolution follows Q#TC3a: explicit open field beats profile field beats scalar setting beats $SHELL. env merges, with explicit entries overriding profile entries of the same name.
  4. "" in terminal.default-profile means "no profile" and is indistinguishable from unset (Q#TC2a).
  5. terminal.scrollback-rows takes effect for a terminal opened without an explicit scrollback_rows; an explicit per-open value overrides it; values outside 0 ..= 4_000_000 are rejected by the registry rather than by the core, and 0 is accepted as "retain no history".
  6. terminal.escape-key changes which chord escapes to the editor, observed through the real dispatch path, not by calling the predicate directly.
  7. Two terminals with different buffer-local escape keys each honor their own, with no setting written in between (Q#TC4/Q#TC2b). Driven as A→B→A, asserting both directions. This is the pin an epoch-only cache fails.
  8. Across that same A→B→A switch with no setting written, the parse count does not increase after each terminal's first keystroke (Q#TC4c) — pinned by counting parses, not by timing. This is the pin a single last-entry cache fails while still satisfying 7. 8a. A terminal's cache does not outlive it: killing a terminal and opening a new one does not serve the dead terminal's chord, and no per-terminal cache entry survives its session (Q#TC4c). This is the pin an unpurged editor-side map fails.
  9. With terminal.escape-key = "C-x": C-x C-x sends Ctrl-X to the child, and an ordinary C-c reaches the child as 0x03 like any other unescaped key (Q#TC4b). Bite: against the hardcoded &[0x03], the first assertion fails.
  10. An unparseable terminal.escape-key falls back to C-c, reports through EditorCore::status, and leaves the terminal usable (Q#TC4a). Bite: with the fallback removed, the terminal becomes unescapable. 10a. "Reports once" is once per terminal per effective invalid value (Q#TC4c): an A→B→A switch with the same invalid value reports once, while changing it to a different invalid value reports again. The report count is asserted, not the message text.
  11. The terminal opening keybinding invokes the existing command, and is verified to have shadowed nothing (Q#TC10).
  12. Existing terminal invocations and every existing terminal test behave identically with no settings defined and no profiles registered.

Stage 2 — terminal-copy-mode

  1. 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.
  2. 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.
  3. isearch over the snapshot finds content that is only in scrollback (scrolled off the visible screen), with no change to src/search.rs (B1).
  4. 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.
  5. 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.
  6. 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.
  7. 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).
  8. The live terminal's own keys are unchanged while a snapshot exists (Q#TC9), and the terminal keeps following its tail.
  9. 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 settingsterminal.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.