From 1b1e5990709842706d7b1b70054dd08d0095ff47 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 26 Jul 2026 10:10:27 -0400 Subject: [PATCH] feat(terminal): copy mode over retained scrollback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_016gGQC6eqHJVbZJ5Hg7aLer --- COHERENCE.md | 34 +- builtin/runtime/terminal.lua | 186 +++++++ docs/active-work.md | 49 +- docs/terminal-config-and-copy-mode-framing.md | 35 +- src/lua_bindings/mod.rs | 19 + src/terminal/view.rs | 143 +++++ tests/terminal_copy_mode_acceptance.rs | 516 ++++++++++++++++++ 7 files changed, 970 insertions(+), 12 deletions(-) create mode 100644 tests/terminal_copy_mode_acceptance.rs diff --git a/COHERENCE.md b/COHERENCE.md index 4e7361c..f707234 100644 --- a/COHERENCE.md +++ b/COHERENCE.md @@ -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 / diff --git a/builtin/runtime/terminal.lua b/builtin/runtime/terminal.lua index 143a663..b44e824 100644 --- a/builtin/runtime/terminal.lua +++ b/builtin/runtime/terminal.lua @@ -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 = , buffer = } +-- +-- 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.", diff --git a/docs/active-work.md b/docs/active-work.md index 88e70df..bfdb7ff 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -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`; diff --git a/docs/terminal-config-and-copy-mode-framing.md b/docs/terminal-config-and-copy-mode-framing.md index 48b75d8..68a88dd 100644 --- a/docs/terminal-config-and-copy-mode-framing.md +++ b/docs/terminal-config-and-copy-mode-framing.md @@ -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. diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index 1e7ac04..37a1307 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -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) } diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 1c0957d..b787dee 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -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> { + 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> { + 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 { + 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 { diff --git a/tests/terminal_copy_mode_acceptance.rs b/tests/terminal_copy_mode_acceptance.rs new file mode 100644 index 0000000..5b62ad8 --- /dev/null +++ b/tests/terminal_copy_mode_acceptance.rs @@ -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(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 = 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 { + 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 { + 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 = 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 = 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 = 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}" + ); +}