From 9dafdd4c89e15584c5e2c36f27cb9e491c7b895a Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 21 Jul 2026 18:47:02 -0400 Subject: [PATCH 01/15] docs(vterm): approve Stage 2 TUI contract Carry the reviewed Revision 7 framing onto the Stage 2 implementation branch as its first commit. The contract defines durable per-view state, authenticated input authority, TUI composition, strict Lua operations, and acceptance 15-27. Co-Authored-By: Claude --- docs/vterm-framing.md | 625 +++++++++++++++++++++++++++++------------- 1 file changed, 430 insertions(+), 195 deletions(-) diff --git a/docs/vterm-framing.md b/docs/vterm-framing.md index 995a216..4c676c3 100644 --- a/docs/vterm-framing.md +++ b/docs/vterm-framing.md @@ -1,19 +1,22 @@ # Vterm — framing (Arc 5 stage 2, three-PR delivery) -**Revision 5 — 2026-07-21. Status: Stage 1 review round 2 fixes implemented -and fully gated on `vterm-core`; awaiting merge authorization, not merged. -Stages 2 and 3 are not implemented.** +**Revision 7 — 2026-07-21. Status: Stage 1 landed on `main` as PR #126 +at merge `643d1e1`; Stages 2 and 3 are not implemented. Stage 2 framing review +is complete and the contract is ready for implementation.** -Revision 5 establishes the renderer-facing cell invariant before Stage 2: -terminal text discards C0/C1 controls rather than storing host-terminal control -bytes in grapheme cells. SGR mouse release preserves the released button code; -review cleanups remove dead screen paths and stale round-trip state; and the -remaining VT-fidelity and allocation nits are explicit deferrals. Architecture -is unchanged: `C-c` is the terminal editor escape (`C-c C-c` sends interrupt); -main-screen resize reflows while alternate screen clips/pads; exited buffers -remain with an Emacs-style process message; protocol v19 is additive with -complete frames; shared `Style` stays unchanged; and one `BufferId` owns one -shared process/screen whose most recently active frontend controls size. +Revision 7 closes the final three precision findings: `at_bottom` is geometric +and distinct from live-tail following; the fixed `C-c` transport escape +deliberately makes ordinary `C-c`-leading bindings unreachable in terminal +windows; and context-implicit Lua view operations require an authenticated +interactive origin with exact boolean/error results. Revision 6's durable +controller and per-view identities, logical-cell anchors, frontend-explicit +grid rendering, per-frontend dispatch, authenticated v18 input, local +clipboard/BEL drainage, default-name uniquification, mouse override, and +resize ordering remain unchanged. Main-screen resize reflows while alternate +screen clips/pads; exited buffers remain with an Emacs-style process message; +protocol v19 is additive with complete frames; shared `Style` stays unchanged; +and one `BufferId` owns one shared process/screen whose most recently accepted +frontend/window context controls size. This framing follows the compile-mode terminal substrate that landed in PR #113. `src/process.rs` already owns PTY creation, process groups, bounded @@ -35,15 +38,14 @@ Arc 5 stage 2 ships as three separately reviewed PRs: There is no single mega-PR. Each stage is useful and testable by itself, and a later stage starts only after the preceding stage lands on `main`. -## 0. Revision 5 — Stage 1 implementation and review record +## 0. Revision 7 — landed Stage 1 and reviewed Stage 2 contract -The first of the three vterm PRs is implemented, reviewed, and fully gated on -`vterm-core`, awaiting merge authorization. Initial feature commit `bbc1f33`, -first-review fixes through `bf972a7`, and second-review hardening `9797ada` are -published as open, non-draft PR #126, -, targeting `main`. -It is deliberately headless: there is no `pmacs.terminal` Lua module, -interactive terminal command, TUI paint branch, or GPU/protocol surface yet. +The first of the three vterm PRs landed on `main` at merge `643d1e1`. +Implementation commits `bbc1f33` and `962944b`, first-review fixes through +`bf972a7`, and second-review hardening through `9797ada` shipped in PR #126, +. The landed stage is +deliberately headless: there is no `pmacs.terminal` Lua module, interactive +terminal command, TUI paint branch, or GPU/protocol surface yet. ### 0.1 Public seam and ownership @@ -147,26 +149,54 @@ terminal_cells_reject_child_control_characters` returned `bite: OK` with a clean behavioral failure: the pre-hardening screen stored control bytes in a grapheme cluster rather than preserving the blank snapshot. -### 0.4 Downstream review findings (not implemented) +### 0.4 Stage 2 re-scout resolutions -Stage 2 must derive PTY resize ownership from a durable accepted-input/focus -owner before render fan-out, never transient `EditorCore::active_frontend`. -Because `KeyDispatcher` pending state is global, a terminal `C-c` continuation -must carry its owning `FrontendId`. Terminal copy should use the existing core -kill-ring/clipboard setter, while the local run loop must drain/present -clipboard signals; active-terminal BEL likewise uses the out-of-band frontend -signal path. +The post-Stage 1 review and a fresh read of landed `main` found six +load-bearing Stage 2 seams. This revision resolves them before code: + +- PTY resize ownership is stored durably per terminal session as an + authenticated `(FrontendId, WindowId)` controller. Render-time + `EditorCore::active_frontend` is not an ownership signal. +- The single global `KeyDispatcher` cannot safely carry a terminal `C-c` + continuation in a multi-frontend daemon. Pending dispatch and terminal + escape state become per-`FrontendId`; dispatch-idle publication becomes + frontend-specific too. +- Terminal copy reuses the existing kill-ring/clipboard setter. The + in-process run loop must drain that signal and feed it through + `Frontend::present_messages`; the TUI must implement `InstanceSignal::Bell` + rather than drop it. +- `RenderState::render_frame` and `paint_frame` need the target + `FrontendId` explicitly. A transient mutable active frontend may still + attribute commands, but it may not select another frontend's layout, + terminal view state, statusline context, or resize owner during fan-out. +- Current v18 daemon key/mouse payload IDs are client supplied. Stage 2 routes + key, mouse, paste, focus, and resize through the authenticated connection + source before any terminal ownership or PTY effect. +- The Stage 1 screen already preserves logical-line IDs and row cell offsets + through main-screen reflow. Stage 2 selection and scroll anchors use those + coordinates; numeric distance from the moving tail is derived metadata, + never stored ownership state. + +The final framing review pinned three precision contracts: + +- `at_bottom` reports whether the live tail is currently visible; it is not + the separate internal “follow future output” predicate. +- `C-c` is a consumed transport escape, not an ordinary dispatcher prefix + map; `C-c`-leading user bindings are deliberately unavailable in terminal + windows for Stage 2. +- Context-implicit Lua view operations require an authenticated interactive + command origin and fail closed rather than borrowing a stale + `active_frontend`. Stage 3 additionally owns `pmacs-gpu/src/attach.rs` for gated terminal -resize/pointer sending and coalescing. Daemon handlers must authenticate source -frontend/buffer ownership before input, resize, or pointer routing. Wire-facing -terminal state, selection, and limits must live in or be re-exported from -`pmacs-protocol`. The current 16 MiB transport frame cap cannot hold the legal -worst complete terminal frame (up to roughly 64 MiB of cluster bytes before -encoding overhead): Stage 3 must either raise and test a measured cap at least -as large as the legal worst case (review estimate at least 80 MiB), or add a -shared aggregate payload bound. It must never silently chunk the locked -complete-frame protocol. +resize/pointer sending and coalescing. New daemon event variants must apply the +same authenticated-source rule. Wire-facing terminal state, selection, and +limits live in or are re-exported from `pmacs-protocol`. The current 16 MiB +transport frame cap cannot hold the legal worst complete terminal frame (up to +roughly 64 MiB of cluster bytes before encoding overhead): Stage 3 must either +raise and test a measured cap at least as large as the legal worst case +(review estimate at least 80 MiB), or add a shared aggregate payload bound. It +must never silently chunk the locked complete-frame protocol. ### 0.5 Stage 1 review round 1 @@ -542,8 +572,11 @@ cursor rewrites, erase operations, alternate-screen swaps, and resize. ### 4.3 Lua API -Stage 2 installs `pmacs.terminal` before user config, loads -`builtin/runtime/terminal.lua`, and registers the interactive command: +Stage 2 constructs the shared `TerminalManager` immediately after the process +supervisor, installs strict Rust primitives, then loads +`builtin/runtime/terminal.lua`; all happen before LSP/MCP builtins and before +user config. The Lua chunk owns friendly wrappers, the interactive command, +buffer-local bindings, and the built-in statusline provider. ```lua local buffer = pmacs.terminal.open { @@ -558,138 +591,287 @@ local buffer = pmacs.terminal.open { } pmacs.terminal.is_terminal(buffer) -- boolean -pmacs.terminal.state(buffer) -- fresh plain metadata table -pmacs.terminal.send(buffer, bytes) -- explicit raw bytes -pmacs.terminal.resize(buffer, rows, cols) +pmacs.terminal.state(buffer) -- fresh global metadata +pmacs.terminal.view_state(ctx) -- fresh per-window metadata +pmacs.terminal.send(buffer, bytes) -- explicit trusted raw bytes pmacs.terminal.terminate(buffer) -- SIGTERM; buffer remains -pmacs.terminal.scroll(lines) -- active terminal window -pmacs.terminal.scroll_to_bottom() -pmacs.terminal.copy_selection() -- active terminal window +pmacs.terminal.scroll(lines) -- boolean; interactive origin required +pmacs.terminal.scroll_to_bottom() -- boolean; interactive origin required +pmacs.terminal.copy_selection() -- boolean; interactive origin required ``` -`open` validates exact raw table fields before side effects. Unknown fields, -metatable-provided fields, holes in `args`, non-string env keys/values, -embedded NUL, non-integer dimensions, and out-of-range scrollback reject with -the field named. The copied spec is immune to caller mutation. Returned and -accepted identity is `BufferIdLua`, following the rest of the editor API. +`open` accepts exactly the fields shown. It validates raw table fields before +side effects: unknown fields, metatable-provided fields, holes in `args`, +non-string environment keys/values, embedded NUL, non-integer dimensions, and +out-of-range scrollback reject with the field named. The copied specification +is immune to caller mutation. Returned and accepted identity is `BufferIdLua`, +following the rest of the editor API. -The built-in chunk registers `terminal` as an interactive command. It opens -`$SHELL` without a shell-command interpolation layer. There is no command -string split and no implicit `sh -c`. -It also installs terminal-buffer-local commands used after the escape prefix: -`M-w` copies the terminal selection, `M-v`/`C-v` page scrollback up/down, and -`M-<`/`M->` move to the oldest retained row/bottom. These shadow ordinary -document commands only during the one-key editor escape; normal terminal input -still sends those keys to the child. +Generated names are reserved against the live buffer registry before insert: +`*terminal:sh*`, `*terminal:sh*<2>`, `*terminal:sh*<3>`, and so on. The lowest +available suffix wins; failed creation consumes no suffix. An explicit `name` +is preserved exactly after ordinary `TerminalSpec` validation. + +`state(buffer)` returns a fresh plain table: + +```lua +{ + buffer = buffer, pid = 123, rows = 24, cols = 80, + title = nil, screen_generation = 7, + process = { kind = "running" }, + -- or { kind="exited", code=0 }, + -- or { kind="signaled", signal="TERM" }, + -- or { kind="crashed", message="..." } +} +``` + +`view_state(ctx)` accepts the raw statusline context fields +`{frontend, window, buffer}` and returns +`{at_bottom=boolean, scroll_offset=integer, selection=boolean}`, or `nil` when +that exact context is not a live terminal view. `state`/`view_state` expose no +stored Lua tables, callbacks, or mutable manager state. + +The three context-implicit view operations require a live authenticated +interactive command origin. A plain programmatic `pmacs.command.invoke` +outside such a dispatch does not synthesize one and raises a Lua error; so does +an origin whose active window is not the addressed terminal context. Errors +name the operation and leave view, selection, clipboard, and controller state +unchanged. `scroll(lines)` requires an integer: positive moves toward older +rows, negative toward the live tail, and zero returns `false`; otherwise it +returns whether `top` changed. `scroll_to_bottom()` returns whether selection +or `top` was cleared. `copy_selection()` returns `true` only when bytes were +published and `false` for a valid terminal context with no selection. + +There is deliberately no public Lua `resize`: the active controller's computed +window content rectangle is the only geometry authority. `send` remains an +explicit trusted escape hatch for packages and tests; ordinary user paste and +keys use the mode-aware input path. + +The built-in `terminal` command calls the public wrapper with `$SHELL` or +`/bin/sh`, no shell-command interpolation, no string split, and no implicit +`sh -c`. The wrapper installs terminal-buffer-local one-key commands: +`M-w` copies, `M-v`/`C-v` page up/down, and `M-<`/`M->` move to the oldest +retained row/bottom. They run only after the terminal escape prefix; normal +terminal input sends those keys to the child. + +### 4.4 Stage 2 view and controller contracts + +Stage 2 adds `src/terminal/view.rs`. It is a projection over the one +`TerminalScreen`, not another screen: + +```rust +TerminalViewKey { frontend_id, window_id, buffer_id } +LogicalCellAnchor { logical_line_id, cell_offset } +TerminalSelection { anchor, head } // inclusive display cells +TerminalViewState { top, selection, drag } // keyed by TerminalViewKey +TerminalController { frontend_id, window_id } +``` + +`cell_offset` is the leading display-cell offset within one logical line. +Clicks on a wide continuation canonicalize to its lead. Ordering is resolved +against the current retained row sequence, not by comparing IDs. A top anchor +resolves to the physical row containing that logical offset after reflow. + +`TerminalManager` gains owned operations to register/retain/detach view keys, +claim or release a session controller, create a +`snapshot_for_view(key, viewport_size)`, scroll/select/copy one view, and query +fresh global/view metadata. `detach_frontend` and live-layout retention remove +stale view/controller state; buffer prune removes every context for that +session. The context-free Stage 1 `snapshot(buffer)` remains available for +headless/core callers. + +`snapshot_for_view` returns exactly `viewport_size.area()` cells for a nonzero +valid viewport. At bottom it tail-aligns rows, padding above and to the right +with default cells; a smaller view clips top/rows and right/columns. A +scrolled view starts at its resolved top anchor and pads only after retained +content is exhausted. Cursor and selection spans are translated into this +returned coordinate space. `scroll_offset` is a saturating derived count of +display rows between the viewport and live tail. + +`at_bottom` is purely geometric: it is `true` exactly when +`scroll_offset == 0`, meaning the live tail is currently visible. It is +independent of the follow predicate (`top == None && selection == None`). +Therefore a Shift-selection frozen at the tail has `top != None`, +`scroll_offset == 0`, and `at_bottom == true`: the active cursor remains +visible, unshifted supported child mouse reporting remains eligible, and the +statusline emits no `↑0`. The first later output retained behind that anchor +makes `scroll_offset > 0` and `at_bottom == false`. + +Main-screen reflow keeps logical anchors stable. Beginning a selection freezes +the current first visible row as `top`, even when it was the live tail, so +later output has an anchor to preserve. Oldest-row eviction clamps a missing +anchor once to the first surviving leading cell; a selection whose two ends +collapse is cleared. Alternate-screen entry/exit or a reset that removes the +referenced logical IDs clears affected selection/top state and returns the view +to bottom. Child output follows only views with `top=None` and no selection. ## 5. Stage 2 — TUI integration -### 5.1 Composition and cursor +### 5.1 Composition, cursor, and status -For every window whose `buffer_id` belongs to `TerminalManager`, the window -content rectangle is painted from a terminal snapshot instead of -`TextView::render`. Modeline/statusline composition remains unchanged. Normal -text overlays, line-number gutters, syntax, diagnostics, and wrapping are not -run over terminal cells. +`RenderState::render_frame` and `paint_frame` take an explicit target +`FrontendId`. Layout lookup, statusline evaluation, active-window choice, and +terminal view keys use that ID. A shared placement helper computes each +window's outer rectangle, one-row modeline reservation, and content rectangle; +both resize synchronization and painting consume the same result. -Each `(frontend_id, window_id, buffer_id)` owns a `TerminalViewState`. At -bottom, the last screen row aligns with the content rectangle's last row. -Scrolling records a stable logical-line top anchor rather than a numeric -distance from a moving tail. New child output therefore does not move a -scrolled-back viewport or selection. If retention evicts that anchor, it -clamps once to the oldest retained row. A “not at bottom” marker is available -to the built-in terminal statusline provider. +For every terminal buffer, the content rectangle is painted from +`snapshot_for_view` instead of `TextView::render`. Cell glyph/style values are +copied directly; no ANSI is reinterpreted. Line-number gutters, document +wrapping, syntax, diagnostics, inlays, ordinary selections, and local/peer +document overlays are suppressed for that window. Modeline/statusline, +sibling splits, and cells outside the rectangle retain the existing pipeline. -The active terminal cursor is translated from terminal-local coordinates into -the window rectangle. It is hidden when the child hid it, the window is not -active, the viewport is scrolled away from bottom, or the coordinate is -clipped. Other terminal windows do not paint a cursor. +Each `(frontend_id, window_id, buffer_id)` view follows §4.4. Merely rendering +a passive view never resizes the PTY. Zero-area content is skipped without +creating a view or changing the prior valid size. -A smaller window clips; a larger window pads with default cells. Merely -rendering a passive view never resizes the PTY. +The active terminal cursor is translated from snapshot-local coordinates into +the content rectangle. It is hidden when the child hid it, the window is not +the target frontend's active window, the viewport is scrolled from bottom, or +the coordinate is clipped. Other terminal windows do not paint a cursor. -BEL is forwarded only from the active terminal through the existing frontend -signal path. OSC title is sanitized and exposed in terminal metadata/frame and -the terminal statusline; it does not rename the identity buffer or directly -set the host window title. +BEL is an out-of-band `InstanceSignal::Bell` only when a new bell occurs in +the target frontend's active terminal. Historical bells are never replayed on +later activation, and one bell count is delivered at most once per frontend. +The in-process loop drains both clipboard and terminal signals and feeds them +through `Frontend::present_messages`; the TUI Bell arm emits one host BEL. +OSC title remains sanitized metadata (and a Stage 3 frame field). It never +renames the identity buffer, injects host control bytes, or sets the host title. -### 5.2 Input precedence +`terminal.lua` registers a right-side provider named `terminal`, priority 10, +face `ui.modeline.terminal`. It emits `TERM` while running, `TERM:` on +normal exit, `TERM:` on signal, or `TERM:ERR` on crash, and appends +` ↑` only when `scroll_offset > 0`. An unset child face +inherits the existing modeline foreground through the Arc 4 contract. -Modal editor surfaces remain authoritative. Input precedence is: +### 5.2 Input precedence and per-frontend dispatch + +Stage 2 replaces the one pending `KeyDispatcher` with dispatch state keyed by +`FrontendId`; keymaps and command registries remain shared. Terminal escape +state is stored beside that dispatcher. `dispatch_idle_for(frontend_id)` uses +the same frontend's pending prefix and active window; global modal surfaces +still make every frontend non-idle while they own input. + +`EditorState` carries an ephemeral authenticated command origin around +key/menu/M-x interactive invocation and clears it with the invocation guard. +Nested calls inherit that origin. Plain `pmacs.command.invoke` outside the +guard neither stamps command history nor creates terminal view authority. + +Input precedence is: 1. minibuffer, incremental search, completion/menu, query-replace, and other existing modal shadows; -2. terminal escape-prefix state; -3. terminal key/mouse/paste handling when the active buffer is terminal; -4. ordinary buffer-local/global keymaps and self-insert. +2. that frontend's terminal escape-prefix state; +3. terminal key/mouse/paste handling when that frontend's active buffer is a + terminal; +4. that frontend's ordinary buffer-local/global dispatcher and self-insert. -All terminal buffers remain in `round_trip_buffers`, so GPU/TUI input reaches -this daemon-owned decision before any optimistic edit. -Escape-prefix state is per frontend, so one attached user's pending escape -never captures another user's next key. +All terminal buffers stay in `round_trip_buffers`, so attached frontends reach +this daemon-owned decision before optimistic edit. One user's pending escape +or longer ordinary prefix cannot consume, cancel, or display as another +user's pending sequence. -When terminal input owns a normalized key, `terminal/input.rs` encodes: +When terminal input owns a normalized key, `terminal/input.rs` encodes UTF-8 +printable characters; Ctrl mappings and Alt ESC-prefixing; Enter, Tab, +Backspace, Escape; arrows/Home/End according to application-cursor mode; +Insert/Delete/Page, F1–F12, Shift-Tab, and supported modifier parameters. +Unknown/lock/media keys are ignored. Press is actionable; local repeat is +treated as another press and release is not forwarded. The normalized +protocol cannot distinguish number-row from numeric-keypad characters, so +application-keypad mode remains tracked but cannot transform them. -- UTF-8 printable characters; -- Ctrl-character mappings, Alt ESC-prefixing, Enter/Tab/Backspace/Escape; -- arrows/Home/End according to application-cursor mode; -- Insert/Delete/Page and F1–F12 xterm sequences; -- Shift-Tab and supported modifier parameters. +`C-c` is the fixed terminal escape prefix. It is consumed and makes the next +key run through the same frontend's ordinary dispatcher; a resulting longer +prefix stays in that frontend's dispatcher. `C-c C-c` instead sends literal +Ctrl-C. Modal shadows consume their keys before either rule. -Unknown/lock/media keys are ignored, never converted into text. Press is the -only actionable event in the current normalized protocol; repeat arrives as -repeated press and release is not forwarded. -The normalized protocol does not distinguish number-row digits from numeric -keypad digits, so application-keypad mode is tracked but cannot transform -those ambiguous `Key::Char` events. - -`C-c` is the fixed stage-2 terminal escape prefix. It is consumed and makes the -next key run through the ordinary editor dispatcher, allowing `C-c C-x ...` -for editor commands. `C-c C-c` sends the literal Ctrl-C byte required to -interrupt the child. This is an intentional fixed stage-2 policy. +This is deliberately a consumed transport escape, not an Emacs-style +`C-c` prefix map: the dispatcher receives the post-escape key as a fresh +sequence, while `C-c C-c` is reserved for literal interrupt. Consequently a +global or buffer-local binding whose first chord is `C-c` cannot fire in a +terminal window. Packages may bind a post-escape one-key sequence, as the +built-in terminal-local commands do. A configurable escape/prefix-map policy +remains the named §11 deferral. Paste sends exact bytes, wrapped in `ESC[200~` / `ESC[201~` only while the -child enabled bracketed paste. It never passes through a command shell or Lua. -When the child enabled focus reporting, authenticated frontend focus gain/loss -sends `ESC[I` / `ESC[O` for the controlling terminal. With the mode off, -focus changes send no PTY bytes. +child enabled bracketed paste. It never passes through a shell or Lua. +Authenticated focus gain claims the active terminal context and emits +`ESC[I` when enabled. Focus loss emits `ESC[O` only when that source currently +controls the session, then releases that controller; mode-off focus is silent. ### 5.3 Mouse, selection, copy, and scrollback -If the child enabled a supported mouse mode, pointer events inside the terminal -content rectangle are encoded as SGR mouse reports, with coordinates translated -to terminal-local 1-based cells. The active mode determines whether press, -release, drag, move, and wheel are reported. +Child mouse reporting owns a pointer only when the exact terminal view is at +bottom, the child enabled a supported tracking mode plus SGR encoding, and +Shift is not held. Events inside the content rectangle translate to +terminal-local 1-based cells; the active mode filters press, release, drag, +move, and wheel. Otherwise the editor owns the gesture: -- wheel changes the per-window scrollback offset; -- primary drag creates a terminal-cell selection across history and screen; -- copy serializes selected rows as UTF-8, trims only trailing default blank - cells, joins soft-wrapped rows without `\n`, and separates hard rows with - `\n`; -- wide-cell continuations are never emitted twice; -- a new plain click clears the old selection; -- child output does not move a scrolled-back viewport or selection anchor. +- wheel scrolls that `TerminalViewKey`; +- primary drag stores inclusive logical-cell endpoints across history/screen; +- Shift is the explicit editor-selection override while child mouse reporting + is active; +- an editor-owned right press follows the existing context-menu path; +- a new plain primary click clears the old selection before setting its anchor; +- child output does not move a scrolled viewport or selection. -`pmacs.terminal.copy_selection()` publishes through the existing kill-ring / -clipboard path. Ordinary document selection fields remain untouched. +Copy resolves anchors against retained logical rows, emits each leading glyph +once, trims only trailing default blank cells, joins soft wraps without `\n`, +and separates hard rows with `\n`. Wide continuations are never duplicated; +combining clusters remain one UTF-8 sequence. Reversed drags normalize by +retained row order. `pmacs.terminal.copy_selection()` writes through the +existing kill-ring/clipboard setter for the acting frontend; ordinary document +selection state is untouched. -### 5.4 Resize ownership +Scrolling uses physical retained display rows and clamps at oldest/bottom. +Reaching bottom clears `top` only when no selection is active. The explicit +`scroll_to_bottom`/`M->` action clears both selection and `top` so live-tail +following resumes; ordinary copy leaves selection intact. Page commands use +the current nonzero content-row count. Selection and scroll are available in +the alternate screen only over its visible rows; no alternate output enters +main history. -One PTY has one kernel window size even when displayed in several views. The -controlling view is the active window of `core.active_frontend` — the frontend -that most recently supplied accepted input/focus. Only that view may resize the -PTY. Passive views clip/pad. +### 5.4 Durable control and resize -For grid frontends, the daemon derives the terminal content `rows × cols` from -the computed split rectangle and modeline reservation. Focus/split/frontend -resize changes trigger one checked `resize_pty`; unchanged dimensions are -suppressed. The screen model resizes before the child receives `SIGWINCH`, so -its repaint lands into the new geometry. -If the computed content rectangle has zero rows or columns, rendering skips it -and the prior valid PTY size remains unchanged; zero is never sent to -`resize_pty`. +Each terminal session stores at most one `TerminalController`. Successful +`open` claims the newly active `(frontend, window)`. Later authenticated +terminal key, paste, editor-owned/child-owned pointer, or focus gain claims the +source's currently active terminal window. A render pass never claims control. +Switching that controller away, killing its window/buffer, focus loss, or +frontend detach releases it; size stays unchanged until another accepted +context claims it. + +Before the next terminal process drain and before paint, the instance +synchronizes live terminal layouts. Only a controller whose exact window still +shows that session may resize. Unchanged dimensions are suppressed before any +PTY syscall. The manager validates once, performs `resize_pty`, then applies +the same prevalidated `TerminalScreen::resize` in the same main-thread call; +no supervisor output drain can interleave between them. A PTY failure leaves +the prior screen geometry intact. The child may emit after `SIGWINCH` only on a +later drain, when the screen already has the new geometry. + +Content rows/columns come from the shared placement helper after modeline +reservation and with no document gutter. A zero row/column result performs no +resize and preserves the prior valid geometry. Passive splits/frontends only +clip or pad their own snapshots. + +### 5.5 Local and v18-daemon adapters + +The in-process event adapter handles key press/repeat, mouse, paste, +focus-gained/lost, and resize through the same `EditorState` terminal methods. +It synchronizes layout before `tick_processes`, then presents pending +clipboard/BEL messages. Host restoration remains the existing `Frontend` +drop/error contract. + +The daemon passes the authenticated connection `source` explicitly to grid +render and input. Client-supplied IDs in v18 Key, Mouse, Paste, Focus, Resize, +and Detach payloads never choose a view or controller. Session detach removes +that frontend's dispatcher, terminal views, controller claims, and bell +baseline. These are Stage 2 changes to existing v18 grid behavior; no protocol +bump or semantic/GPU terminal surface is added in this PR. ## 6. Stage 3 — protocol v19 and GPU integration @@ -805,21 +987,22 @@ coherent. | Owner | Stable scope | Primary files | | --- | --- | --- | -| Lead/integrator | contracts first; `TerminalManager`, buffer/Lua/builtin wiring, cross-surface acceptance, gates, docs, branches/PRs | `src/terminal/session.rs`, `src/lua_bindings/mod.rs`, `builtin/runtime/terminal.lua`, `tests/vterm_*_acceptance.rs`, docs | -| VT core agent | streaming parser operations, screen state machine, input encoder, model units | `src/ansi.rs`, `src/terminal/screen.rs`, `src/terminal/input.rs` | -| TUI agent | terminal window composition, cursor, per-view scroll/selection/copy, grid input and resize | `src/terminal/view.rs`, owned sections of `src/editor.rs`, focused TUI tests | -| Protocol/GPU agent | v19 types/limits/gates, semantic terminal producer, authenticated daemon routing, GPU state/render/hit-test | `pmacs-protocol`, `src/protocol.rs`, `src/semantic_render.rs`, owned sections of `src/daemon.rs`, `pmacs-gpu/src/main.rs` | +| Lead/integrator | contracts first; `TerminalManager`, Lua/builtin wiring, lifecycle/signal integration, shared acceptance, gates, docs, branches/PRs | `src/terminal/session.rs`, `src/lua_bindings/mod.rs`, `builtin/runtime/terminal.lua`, narrow Stage 2 authenticated-dispatch sections of `src/daemon.rs`, `tests/vterm_*_acceptance.rs`, docs | +| VT core agent | encoder corrections and screen query seams needed by view projection; no renderer ownership | `src/ansi.rs`, `src/terminal/screen.rs`, `src/terminal/input.rs` | +| TUI agent | view projection, frontend-explicit grid composition/cursor, per-frontend dispatch, local events, scroll/selection/copy/resize | `src/terminal/view.rs`, `src/instance_render.rs`, `src/frontend.rs`, owned sections of `src/editor.rs`, focused TUI tests | +| Protocol/GPU agent | Stage 2 contract review; Stage 3 v19 types/limits/gates, semantic producer, authenticated new-event routing, GPU state/render/hit-test | `pmacs-protocol`, `src/protocol.rs`, `src/semantic_render.rs`, Stage 3 sections of `src/daemon.rs`, `pmacs-gpu/src/{main,attach}.rs` | Coordination rules: -- Lead establishes types and method signatures before another lane edits a - caller. +- Lead establishes types, invariants, and method signatures before another + lane edits callers. - Strict file ownership. `src/editor.rs` passes from lead to TUI only after - stage-1 construction wiring is settled; `src/daemon.rs` belongs only to the - protocol/GPU lane in stage 3. + construction wiring; lead and TUI coordinate exact non-overlapping + `src/daemon.rs`/signal hunks. Stage 3 daemon work begins only after Stage 2 + lands. - Workers do not update docs, ledgers, branches, or PRs and do not stash, checkout, rebase, or merge. -- Workers add focused tests in their owned modules. Lead alone owns shared +- Workers add focused tests in owned modules. Lead alone owns shared acceptance files. - Exact-path staging only; never `git add .`. - Four agents are the total vterm team, not four implementation workers plus a @@ -827,26 +1010,26 @@ Coordination rules: Per-stage utilization: -- Stage 1: lead + VT core implement; TUI and protocol/GPU owners review the - snapshot/input contracts against their future consumers. -- Stage 2: TUI implements; VT core owns encoder corrections; lead integrates - lifecycle/acceptance; protocol/GPU owner checks that no TUI-only assumption - enters the snapshot contract. -- Stage 3: protocol/GPU implements; TUI and VT core owners add parity cases in - their existing surfaces; lead integrates and gates. +- Stage 1: completed by lead + VT core; TUI and protocol/GPU reviewed future + consumer contracts. +- Stage 2: lead establishes manager/Lua contracts and authenticated adapters; + TUI implements projection/input/rendering; VT core adds only required + encoder/query corrections; protocol/GPU checks snapshot neutrality. +- Stage 3: protocol/GPU implements; TUI and VT core add parity cases in their + existing surfaces; lead integrates and gates. ## 8. Branch and PR plan -Stage 1 is implemented and fully gated on `vterm-core`; review fixes through -`bf972a7` are published in open, non-draft PR #126 -(), targeting `main` and not -merged. Continue the approved plan only after the preceding PR lands: +Stage 1 landed on `main` as PR #126 at merge `643d1e1`. Continue one clean PR +at a time: -1. review PR #126; merge only when the user says; -2. after stage 1 merges, create `pmacs-vterm-tui`, branch `vterm-tui`, from the - new `main`; implement/gate/open a second PR; -3. after stage 2 merges, create `pmacs-vterm-gpu`, branch `vterm-gpu`, from the - new `main`; implement/gate/open the third PR. +1. Revision 7 framing review complete; implementation contract approved; +2. create worktree `pmacs-vterm-tui`, branch `vterm-tui`, from current + `githubsucks/main`; carry this approved framing as the first commit, + implement/gate, and open the second PR; +3. merge Stage 2 only when the user says; +4. create `pmacs-vterm-gpu`, branch `vterm-gpu`, from the then-current `main`; + implement/gate/open the third PR. The framing branch is `vterm-framing` in worktree `pmacs-vterm-framing`. Implementation branches are not stacked across an unmerged parent. This avoids @@ -896,49 +1079,83 @@ base-branch deletion/auto-close risk and makes each PR's gate evidence honest. ### Stage 2 — TUI -15. Lua `open` performs the same strict raw-field validation, publishes no - partial state on error, and switches the active window only after success. +15. Lua `open` enforces the strict owned table contract, uniquifies generated + names without consuming failed suffixes, publishes no partial state on + error, and switches/claims the active window only after success. + `state` and `view_state` return exact fresh plain tables; no public Lua + resize bypass exists. Context-implicit view operations error without an + authenticated interactive terminal origin and otherwise return the exact + changed/copied booleans without partial mutation. 16. A terminal window paints exact cells/styles inside its content rectangle; - statusline, sibling splits, and outside cells are untouched. -17. Active cursor translation, child-hidden cursor, passive window, clipping, - and scrolled-back hiding are exact. -18. Printable, Ctrl, Alt, arrows, Home/End, function keys, application cursor, - focus reporting, and unknown keys produce the specified PTY bytes. -19. `C-c` dispatches one editor key; `C-c C-c` sends Ctrl-C; modal minibuffer, - search, menu, and query-replace remain authoritative. -20. Paste is byte-exact with bracketed wrappers only when enabled. -21. Mouse-reporting modes receive translated SGR reports. With reporting off, - the same gestures scroll/select/copy and write no PTY bytes. -22. Copy handles soft/hard wraps, trailing blanks, wide/combining glyphs, - resize/reflow, eviction-clamped anchors, and selections crossing - history/screen exactly once. -23. The controlling active view alone resizes the PTY; passive split/frontend - renders never cause resize thrash. -24. A hermetic real TUI smoke opens `/bin/sh`, runs a cursor-addressed probe, - resizes, scrolls/copies, exits, and restores the host terminal cleanly. + top/right padding, clipping, modeline, sibling splits, outside cells, and + suppression of document/peer overlays are exact and control-free. +17. Per-context snapshots preserve logical top/selection anchors through + main-screen reflow, derive the correct scroll offset, clamp oldest-row + eviction once, and clear invalid alternate/reset anchors. A selection + frozen at the live tail pins `top != None`, `scroll_offset == 0`, and + `at_bottom == true`; cursor, child-mouse eligibility, status suffix, and + the first subsequent output transition follow that definition exactly. +18. Printable, Ctrl, Alt, navigation/function keys, application cursor, focus, + unknown keys, and paste produce exact PTY bytes; bracketed wrappers appear + only when enabled. +19. `C-c` dispatches one fresh editor key and retains the owning frontend + through a longer prefix; `C-c C-c` sends Ctrl-C. Ordinary bindings whose + first chord is `C-c` are deliberately unreachable in terminal windows. + Two frontends cannot consume one another's escape/pending state or + dispatch-idle value, and existing modal shadows remain authoritative. +20. Supported SGR mouse modes receive translated reports only at bottom without + Shift. Mode-off, scrolled, unsupported-legacy, and Shift gestures remain + editor-owned; wheel, drag, clear, and right-context behavior write no PTY + bytes. +21. Copy handles soft/hard wraps, trailing blanks, wide/combining glyphs, + reversed drags, resize/reflow, eviction, alternate screen, and + history/screen crossings exactly once, then publishes to the acting + frontend's kill-ring/clipboard path without touching document selection. +22. The durable authenticated controller alone resizes. Open/input/focus + claims and focus/switch/kill/detach releases are exact; unchanged, zero, + passive, and failed resize cases preserve prior geometry without thrash, + and screen resize precedes the next child-output drain. +23. Forged v18 grid payload frontend IDs for key, mouse, paste, focus, resize, + or detach cannot select or affect another frontend's terminal context. + Detach and layout retention remove only the matching views, dispatcher, + controller, and bell baseline. +24. Local and daemon-grid paths deliver clipboard and each new active-terminal + BEL exactly once. Historical/passive bells and OSC titles do not become + host control effects. The built-in terminal provider reports exact + process/scroll state for each split. +25. Two frontends and sibling splits over one terminal retain independent + bottom/scroll/selection snapshots while sharing one process, screen, title, + process outcome, and controller. +26. Killing the identity buffer terminates/reaps the child and removes all + views; switching away leaves it running; editor/drop and error paths + restore the host terminal and leak no child, reader, or stale round-trip + state. +27. A hermetic real TUI smoke opens `/bin/sh`, runs a cursor-addressed probe, + exercises key/paste, resize, scroll/select/copy, BEL, and clean exit, then + proves host raw/alternate-screen state is restored. ### Stage 3 — GPU/protocol -25. Protocol v19 appends all new variants after v18 pins; v18 grid traffic +28. Protocol v19 appends all new variants after v18 pins; v18 grid traffic round-trips unchanged and new outbound variants are version-gated. -26. Terminal frame validation accepts exact shared boundaries and atomically +29. Terminal frame validation accepts exact shared boundaries and atomically rejects over-area, bad area, out-of-bounds cursor, malformed cluster, orphan continuation, invalid selection spans, attachment, overlong title, and overlong process-state text while retaining the prior valid frame. -27. Semantic terminal activation suppresses document-only messages; switching +30. Semantic terminal activation suppresses document-only messages; switching back forces a complete document resync. -28. Two frontends/splits on one terminal keep independent scroll/selection +31. Two frontends/splits on one terminal keep independent scroll/selection snapshots; only the active controlling context resizes or writes input. -29. Forged frontend/buffer IDs in terminal resize/pointer events cannot affect +32. Forged frontend/buffer IDs in terminal resize/pointer events cannot affect another terminal or process. -30. Headless GPU rendering pins background rectangles, indexed/truecolor, +33. Headless GPU rendering pins background rectangles, indexed/truecolor, reverse, wide/combining cells, clipping, cursor visibility, status-band separation, and no frontend wrapping. -31. Font/window resize emits cell dimensions, never pixels, and identical +34. Font/window resize emits cell dimensions, never pixels, and identical resize requests are suppressed. -32. Theme/font/terminal generation changes invalidate exactly the affected +35. Theme/font/terminal generation changes invalidate exactly the affected caches; an unchanged terminal frame produces no redraw message. -33. A real daemon + required-GPU smoke runs a full-screen alternate-screen +36. A real daemon + required-GPU smoke runs a full-screen alternate-screen probe, handles input and resize, exits, and returns to the preserved main screen. @@ -996,7 +1213,8 @@ panic, unbounded allocation, or child leak. ## 12. Resolved decisions -The 2026-07-21 architecture discussion resolved every Revision 1 question: +The 2026-07-21 architecture, re-scout, and final framing review resolved every +current question: 1. Fixed terminal editor escape: `C-c`; `C-c C-c` sends literal Ctrl-C. 2. Resize: reflow main-screen soft wraps; clip/pad alternate screen. @@ -1005,5 +1223,22 @@ The 2026-07-21 architecture discussion resolved every Revision 1 question: no terminal surface. 5. GPU wire: complete visible frames with complete-payload suppression. 6. Style: preserve the shared encoding and defer unsupported attributes. -7. Identity: one process/screen per terminal `BufferId`; the most recently - active frontend's active view controls PTY size. +7. Identity: one process/screen per terminal `BufferId`. +8. View state: logical-line/cell anchors per + `(FrontendId, WindowId, BufferId)`; `at_bottom` means + `scroll_offset == 0`, while following additionally requires no top anchor + and no selection; no second screen. +9. Control: the most recently authenticated accepted terminal context owns + resize until focus/switch/kill/detach releases it; render never claims. +10. Dispatch: terminal escape and ordinary pending prefixes are per frontend; + the consumed `C-c` escape deliberately hides ordinary `C-c`-leading + bindings in terminal windows. +11. Mouse: Shift or scrollback forces editor selection; supported SGR child + reporting owns unshifted at-bottom gestures. +12. Resize order: validate, suppress unchanged, resize PTY, resize the screen + before any subsequent child-output drain; failure preserves old geometry. +13. Lua: strict open/state/view/send/terminate/scroll/copy surface; no public + geometry bypass; implicit view operations require authenticated interactive + origin and otherwise error without mutation. +14. Host effects: clipboard and BEL use explicit frontend signals; OSC title + remains sanitized metadata. From 1b32f81033c489bbefdde8cb50ca729cd1b06533 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 21 Jul 2026 18:56:15 -0400 Subject: [PATCH 02/15] feat(vterm): establish Stage 2 view authority contracts Add exact frontend/window/buffer view identities, logical-cell selections, durable controller ownership, lifecycle cleanup, default-name uniquification, and a nested authenticated interactive-command origin shared with Lua. These contracts let the terminal core and TUI lanes proceed without inventing parallel state. Co-Authored-By: Claude --- src/editor.rs | 44 +++++++++++++- src/lua_bindings/mod.rs | 15 +++-- src/terminal/mod.rs | 5 ++ src/terminal/session.rs | 123 +++++++++++++++++++++++++++++++++++++--- src/terminal/view.rs | 86 ++++++++++++++++++++++++++++ 5 files changed, 260 insertions(+), 13 deletions(-) create mode 100644 src/terminal/view.rs diff --git a/src/editor.rs b/src/editor.rs index a8590ae..95d2139 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -12,7 +12,7 @@ //! them through the dispatcher, and invokes the resulting Lua commands //! until the user quits. -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::collections::HashMap; use std::io; use std::path::PathBuf; @@ -37,6 +37,43 @@ use crate::protocol::FrontendId; use crate::view::{View, Viewport}; use crate::window::{Rect, WindowId}; +/// Ephemeral authenticated origin for one interactive command invocation. +/// +/// The shared slot is installed as Lua app data so Rust dispatch and nested +/// `pmacs.command.invoke_interactive` calls use the same authority. Guards +/// restore the prior value, which makes nesting safe and clears the outermost +/// origin even when a Lua command errors. +#[derive(Clone, Default)] +pub(crate) struct InteractiveCommandOrigin(Rc>>); + +impl InteractiveCommandOrigin { + /// Current authenticated frontend while an interactive command runs. + #[must_use] + pub(crate) fn current(&self) -> Option { + self.0.get() + } + + /// Enter an interactive command scope for `frontend_id`. + pub(crate) fn enter(&self, frontend_id: FrontendId) -> InteractiveCommandOriginGuard { + let previous = self.0.replace(Some(frontend_id)); + InteractiveCommandOriginGuard { + origin: self.clone(), + previous, + } + } +} + +pub(crate) struct InteractiveCommandOriginGuard { + origin: InteractiveCommandOrigin, + previous: Option, +} + +impl Drop for InteractiveCommandOriginGuard { + fn drop(&mut self) { + self.origin.0.set(self.previous); + } +} + // --------------------------------------------------------------------------- // EditorState // --------------------------------------------------------------------------- @@ -50,6 +87,8 @@ pub struct EditorState { pub lua_host: LuaHost, /// Multi-key prefix state machine. Driven by the run loop. pub dispatcher: KeyDispatcher, + /// Authenticated frontend scoped to the current interactive invocation. + pub(crate) interactive_origin: InteractiveCommandOrigin, /// Main-thread async runtime (T M3.3). Owns the worker pool and /// the message bus pair; [`Self::tick_async`] drives one /// drain-and-resume pass per run-loop iteration. @@ -177,6 +216,8 @@ impl EditorState { Rc::new(RefCell::new(crate::buffer_registry::BufferRegistry::new())); let core = Rc::new(RefCell::new(EditorCore::new(registry.clone()))); let mut lua_host = LuaHost::with_registry(registry).expect("Lua runtime initialization"); + let interactive_origin = InteractiveCommandOrigin::default(); + lua_host.lua().set_app_data(interactive_origin.clone()); lua_host .attach_editor(&core) .expect("editor bindings + builtin chunks"); @@ -487,6 +528,7 @@ impl EditorState { core, lua_host, dispatcher: KeyDispatcher::new(), + interactive_origin, async_runtime, syntax_registry, process_supervisor, diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index 9af5b5b..7821d4b 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -5239,11 +5239,18 @@ fn install_command_module(lua: &Lua, commands: &SharedCommandRegistry) -> mlua:: command.set( "invoke_interactive", lua.create_function(move |lua, (name, args): (String, Variadic)| { - if let Some(core) = lua.app_data_ref::() { + let frontend_id = lua.app_data_ref::().map(|core| { let mut core = core.borrow_mut(); - let fid = core.active_frontend; - core.rotate_command(fid, &name); - } + let frontend_id = core.active_frontend; + core.rotate_command(frontend_id, &name); + frontend_id + }); + let origin = lua + .app_data_ref::() + .map(|origin| origin.clone()); + let _origin_guard = frontend_id + .zip(origin.as_ref()) + .map(|(frontend_id, origin)| origin.enter(frontend_id)); let body = { let r = cmds.borrow(); r.get(&name) diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index ca205c5..267ff32 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -8,11 +8,16 @@ pub mod input; /// Stateful terminal screen model. pub mod screen; pub mod session; +/// Per-context terminal viewport, selection, and controller identities. +pub mod view; pub use session::{ SharedTerminalManager, TerminalError, TerminalManager, TerminalProcessState, TerminalSelectionSpan, TerminalSnapshot, TerminalSpec, }; +pub use view::{ + LogicalCellAnchor, TerminalController, TerminalSelection, TerminalViewKey, TerminalViewState, +}; /// Maximum terminal rows accepted at creation or resize. pub const MAX_TERMINAL_ROWS: u16 = 512; diff --git a/src/terminal/session.rs b/src/terminal/session.rs index 2c1876b..78f62ba 100644 --- a/src/terminal/session.rs +++ b/src/terminal/session.rs @@ -17,6 +17,7 @@ use crate::process::{ RestartPolicy, StdinMode, TerminalMode, }; use crate::terminal::screen::TerminalScreen; +use crate::terminal::view::{TerminalController, TerminalViewKey, TerminalViewState}; use crate::terminal::{ MAX_TERMINAL_COLS, MAX_TERMINAL_HISTORY_CELLS, MAX_TERMINAL_METADATA_BYTES, MAX_TERMINAL_ROWS, MAX_TERMINAL_VISIBLE_CELLS, @@ -206,22 +207,26 @@ pub enum TerminalError { Process(String), } -struct TerminalSession { - process_id: ProcessId, - pid: u32, - screen: TerminalScreen, - process: TerminalProcessState, - annotated: bool, +pub(super) struct TerminalSession { + pub(super) process_id: ProcessId, + pub(super) pid: u32, + pub(super) screen: TerminalScreen, + pub(super) process: TerminalProcessState, + pub(super) annotated: bool, } /// Owns the one-buffer/one-process/one-screen terminal registry. #[derive(Default)] pub struct TerminalManager { - sessions: HashMap, + pub(super) sessions: HashMap, process_to_buffer: HashMap, /// Removed buffers whose children are still being reaped. Their events /// remain manager-owned so Lua/LSP/MCP consumers cannot steal a batch. closing: HashSet, + /// Per-frontend/window projections over the one session screen. + pub(super) views: HashMap, + /// At most one authenticated frontend/window controls each session PTY. + pub(super) controllers: HashMap, } impl TerminalManager { @@ -255,7 +260,12 @@ impl TerminalManager { let screen = TerminalScreen::new(size, spec.scrollback_rows) .map_err(|error| TerminalError::Screen(error.to_string()))?; - let buffer_name = spec.buffer_name(); + let base_name = spec.buffer_name(); + let buffer_name = if spec.name.is_some() { + base_name + } else { + unique_terminal_name(core, &base_name) + }; let buffer_id = BufferId::next(); let mut buffer = Buffer::new(buffer_id, buffer_name.clone()); buffer.set_read_only(true); @@ -338,6 +348,85 @@ impl TerminalManager { .map(|session| session.process_id) } + /// Ensure an exact terminal view exists without changing its controller. + /// + /// Returns `false` when the key's buffer is not a published terminal. + pub fn register_view(&mut self, key: TerminalViewKey) -> bool { + if !self.sessions.contains_key(&key.buffer_id) { + return false; + } + self.views.entry(key).or_default(); + true + } + + /// Borrow fresh mutable state for an already registered exact view. + pub fn view_state_mut(&mut self, key: TerminalViewKey) -> Option<&mut TerminalViewState> { + self.views.get_mut(&key) + } + + /// Borrow fresh state for an already registered exact view. + #[must_use] + pub fn view_state(&self, key: TerminalViewKey) -> Option<&TerminalViewState> { + self.views.get(&key) + } + + /// Retain only `live` views belonging to one authenticated frontend. + pub fn retain_frontend_views( + &mut self, + frontend_id: crate::protocol::FrontendId, + live: &HashSet, + ) { + self.views.retain(|key, _| { + key.frontend_id != frontend_id + || (live.contains(key) && self.sessions.contains_key(&key.buffer_id)) + }); + self.controllers.retain(|buffer_id, controller| { + controller.frontend_id != frontend_id + || live.contains(&TerminalViewKey::new( + frontend_id, + controller.window_id, + *buffer_id, + )) + }); + } + + /// Drop all view and controller state owned by a detached frontend. + pub fn detach_frontend(&mut self, frontend_id: crate::protocol::FrontendId) { + self.views.retain(|key, _| key.frontend_id != frontend_id); + self.controllers + .retain(|_, controller| controller.frontend_id != frontend_id); + } + + /// Give an exact registered view durable PTY control for its session. + pub fn claim_controller(&mut self, key: TerminalViewKey) -> bool { + if !self.views.contains_key(&key) || !self.sessions.contains_key(&key.buffer_id) { + return false; + } + self.controllers + .insert(key.buffer_id, TerminalController::from_view(key)); + true + } + + /// Release control only when `key` is the current controller. + pub fn release_controller(&mut self, key: TerminalViewKey) -> bool { + if self + .controllers + .get(&key.buffer_id) + .is_some_and(|controller| controller.matches(key)) + { + self.controllers.remove(&key.buffer_id); + true + } else { + false + } + } + + /// Current durable controller for one terminal session. + #[must_use] + pub fn controller(&self, buffer_id: BufferId) -> Option { + self.controllers.get(&buffer_id).copied() + } + /// Capture context-free owned visible state after the latest tick. #[must_use] pub fn snapshot(&self, buffer_id: BufferId) -> Option { @@ -488,6 +577,8 @@ impl TerminalManager { continue; }; self.process_to_buffer.remove(&session.process_id); + self.views.retain(|key, _| key.buffer_id != buffer_id); + self.controllers.remove(&buffer_id); match supervisor.state(session.process_id) { Some( ProcessState::Starting @@ -528,9 +619,25 @@ impl TerminalManager { } self.sessions.clear(); self.process_to_buffer.clear(); + self.views.clear(); + self.controllers.clear(); } } +fn unique_terminal_name(core: &EditorCore, base: &str) -> String { + let registry = core.registry.borrow(); + if registry.find_by_name(base).is_none() { + return base.to_owned(); + } + for suffix in 2usize.. { + let candidate = format!("{base}<{suffix}>"); + if registry.find_by_name(&candidate).is_none() { + return candidate; + } + } + unreachable!("unbounded terminal suffix search must find a free name") +} + fn finish_session(session: &mut TerminalSession, outcome: TerminalProcessState) { if session.annotated { session.process = outcome; diff --git a/src/terminal/view.rs b/src/terminal/view.rs new file mode 100644 index 0000000..b16c462 --- /dev/null +++ b/src/terminal/view.rs @@ -0,0 +1,86 @@ +//! Per-frontend terminal viewport and selection identities. +//! +//! These types identify projections over one [`super::screen::TerminalScreen`]. +//! They never own or mirror terminal cells. + +use crate::buffer::BufferId; +use crate::protocol::FrontendId; +use crate::window::WindowId; + +/// One frontend/window projection of a terminal session. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct TerminalViewKey { + /// Authenticated frontend that owns this view state. + pub frontend_id: FrontendId, + /// Stable editor window showing the terminal. + pub window_id: WindowId, + /// Identity buffer whose session is projected. + pub buffer_id: BufferId, +} + +impl TerminalViewKey { + /// Construct an exact terminal view identity. + #[must_use] + pub const fn new(frontend_id: FrontendId, window_id: WindowId, buffer_id: BufferId) -> Self { + Self { + frontend_id, + window_id, + buffer_id, + } + } +} + +/// Leading display-cell offset within one retained logical line. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct LogicalCellAnchor { + /// Stable logical line identity preserved by main-screen reflow. + pub logical_line_id: u64, + /// Leading display-cell offset within that logical line. + pub cell_offset: u32, +} + +/// Inclusive terminal selection endpoints. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct TerminalSelection { + /// Fixed endpoint where the drag began. + pub anchor: LogicalCellAnchor, + /// Moving endpoint under the pointer. + pub head: LogicalCellAnchor, +} + +/// Mutable state for one [`TerminalViewKey`]. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct TerminalViewState { + /// First visible retained logical cell, or live-tail following when absent. + pub top: Option, + /// Inclusive logical-cell selection. + pub selection: Option, + /// Current editor-owned drag endpoint; cleared on release. + pub drag: Option, +} + +/// The one authenticated frontend/window allowed to control a session's PTY. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct TerminalController { + /// Authenticated controlling frontend. + pub frontend_id: FrontendId, + /// Active terminal window on that frontend. + pub window_id: WindowId, +} + +impl TerminalController { + /// Construct a controller from an exact view identity. + #[must_use] + pub const fn from_view(key: TerminalViewKey) -> Self { + Self { + frontend_id: key.frontend_id, + window_id: key.window_id, + } + } + + /// Whether this controller names `key`'s frontend and window. + #[must_use] + pub fn matches(self, key: TerminalViewKey) -> bool { + self.frontend_id == key.frontend_id && self.window_id == key.window_id + } +} From 39e07cbf9ba55dc80299f95f98641cdf07d8e541 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 21 Jul 2026 19:06:14 -0400 Subject: [PATCH 03/15] feat(vterm): expose atomic screen projections Publish retained row metadata, alternate-screen identity, cursor, title, and cells through the same synchronized-output generation gate. This gives each Stage 2 view one coherent projection without borrowing or mirroring mutable screen state. Co-Authored-By: Claude --- src/terminal/screen.rs | 107 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 97 insertions(+), 10 deletions(-) diff --git a/src/terminal/screen.rs b/src/terminal/screen.rs index fe4939f..7d7cbdb 100644 --- a/src/terminal/screen.rs +++ b/src/terminal/screen.rs @@ -97,6 +97,25 @@ pub struct ScreenSnapshot { pub generation: u64, } +/// Owned row projection published atomically to terminal views. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ScreenProjection { + /// Terminal grid dimensions. + pub size: CellSize, + /// Whether the projected visible rows belong to the alternate screen. + pub alternate_active: bool, + /// Retained main-screen history, empty while the alternate screen is active. + pub history: Vec, + /// Active visible rows, including logical-line and soft-wrap metadata. + pub visible_rows: Vec, + /// Published cursor position when visible. + pub cursor: Option, + /// Published terminal title. + pub title: Option, + /// Screen generation represented by this projection. + pub generation: u64, +} + #[derive(Clone, Copy, Debug, Default)] struct Cursor { row: usize, @@ -138,7 +157,7 @@ pub struct TerminalScreen { tab_stops: BTreeSet, title: Option, generation: u64, - published: ScreenSnapshot, + published: ScreenProjection, sync_started: Option, next_line_id: u64, scrollback_rows: usize, @@ -159,9 +178,11 @@ impl TerminalScreen { let mut next_line_id = 1; let main = Grid::new(size, &mut next_line_id); let alt = Grid::new(size, &mut next_line_id); - let published = ScreenSnapshot { + let published = ScreenProjection { size, - cells: flatten(&main.rows), + alternate_active: false, + history: Vec::new(), + visible_rows: main.rows.clone(), cursor: Some(CellCoord::new(0, 0)), title: None, generation: 0, @@ -502,15 +523,33 @@ impl TerminalScreen { pub fn snapshot(&self) -> ScreenSnapshot { if self.modes.synchronized_output { - self.published.clone() + snapshot_from_projection(&self.published) } else { self.current_snapshot() } } + /// Return one owned, publication-consistent row projection. + #[must_use] + pub fn projection(&self) -> ScreenProjection { + if self.modes.synchronized_output { + self.published.clone() + } else { + self.current_projection() + } + } pub fn modes(&self) -> TerminalModes { self.modes } + /// Return whether the published active screen is alternate. + #[must_use] + pub fn alternate_active(&self) -> bool { + if self.modes.synchronized_output { + self.published.alternate_active + } else { + self.alt_active + } + } pub fn bell_count(&self) -> u64 { self.bell_count } @@ -1380,8 +1419,26 @@ impl TerminalScreen { generation: self.generation, } } + fn current_projection(&self) -> ScreenProjection { + ScreenProjection { + size: self.size, + alternate_active: self.alt_active, + history: if self.alt_active { + Vec::new() + } else { + self.main.history.iter().cloned().collect() + }, + visible_rows: self.active().rows.clone(), + cursor: self + .modes + .cursor_visible + .then(|| CellCoord::new(self.cursor.row as u32, self.cursor.col as u32)), + title: self.title.clone(), + generation: self.generation, + } + } fn publish(&mut self) { - self.published = self.current_snapshot(); + self.published = self.current_projection(); } } @@ -1491,6 +1548,15 @@ fn glyph_width(glyph: &Glyph) -> usize { Glyph::Continuation => 0, } } +fn snapshot_from_projection(projection: &ScreenProjection) -> ScreenSnapshot { + ScreenSnapshot { + size: projection.size, + cells: flatten(&projection.visible_rows), + cursor: projection.cursor, + title: projection.title.clone(), + generation: projection.generation, + } +} fn resize_grid_clip( grid: &mut Grid, old: CellSize, @@ -1625,18 +1691,21 @@ mod tests { #[test] fn alternate_screen_preserves_main_and_has_no_history() { let mut s = screen(2, 4); + assert!(!s.alternate_active()); s.apply_event(AnsiEvent::Text("main".into())); let main = s.snapshot(); s.apply_event(AnsiEvent::AlternateScreen { mode: AlternateScreenMode::Mode1049, enabled: true, }); + assert!(s.alternate_active()); s.apply_event(AnsiEvent::Text("alt\nmore".into())); assert!(s.history().is_empty()); s.apply_event(AnsiEvent::AlternateScreen { mode: AlternateScreenMode::Mode1049, enabled: false, }); + assert!(!s.alternate_active()); assert_eq!(&s.snapshot().cells[..4], &main.cells[..4]); } @@ -1668,17 +1737,35 @@ mod tests { } #[test] - fn synchronized_output_gates_snapshot_and_finish_releases() { + fn synchronized_output_gates_snapshot_and_row_projection_until_release() { let mut s = screen(2, 4); - let before = s.snapshot(); + s.apply_event(AnsiEvent::Text("main".into())); + s.apply_event(AnsiEvent::LineFeed); + s.apply_event(AnsiEvent::LineFeed); + let before_snapshot = s.snapshot(); + let before_projection = s.projection(); + assert_eq!(before_projection.history.len(), 1); + assert!(!before_projection.alternate_active); + s.apply_event(AnsiEvent::SetMode { mode: TerminalMode::SynchronizedOutput, enabled: true, }); - s.apply_event(AnsiEvent::Text("x".into())); - assert_eq!(s.snapshot(), before); + s.apply_event(AnsiEvent::AlternateScreen { + mode: AlternateScreenMode::Mode1049, + enabled: true, + }); + s.apply_event(AnsiEvent::Text("alt".into())); + assert_eq!(s.snapshot(), before_snapshot); + assert_eq!(s.projection(), before_projection); + assert!(!s.alternate_active()); + s.finish_output(); - assert_ne!(s.snapshot(), before); + let released = s.projection(); + assert!(released.alternate_active); + assert!(released.history.is_empty()); + assert_eq!(s.snapshot().cells, flatten(&released.visible_rows)); + assert!(s.alternate_active()); } #[test] From 7c3953563ccbc1b6a098a41bc432b6d80a07988e Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 21 Jul 2026 19:10:45 -0400 Subject: [PATCH 04/15] feat(vterm): add strict Lua and daemon foundations Install strict owned terminal specification parsing, fresh global state tables, default-name uniquification, durable view/controller lifecycle, and the builtin terminal command/statusline surface. Route daemon key and mouse input by the authenticated source and add non-replaying per-frontend BEL baselines. Co-Authored-By: Claude --- builtin/runtime/terminal.lua | 95 +++++++++ src/daemon.rs | 88 ++++++++- src/editor_core.rs | 6 + src/lua_bindings/mod.rs | 360 +++++++++++++++++++++++++++++++++++ src/terminal/session.rs | 8 + 5 files changed, 550 insertions(+), 7 deletions(-) create mode 100644 builtin/runtime/terminal.lua diff --git a/builtin/runtime/terminal.lua b/builtin/runtime/terminal.lua new file mode 100644 index 0000000..6be0987 --- /dev/null +++ b/builtin/runtime/terminal.lua @@ -0,0 +1,95 @@ +-- terminal.lua --- Friendly Vterm Stage 2 command and modeline surface. + +local terminal = assert(pmacs.terminal, "pmacs.terminal raw bindings are required") +local raw_open = assert(terminal._open, "pmacs.terminal._open is required") + +local function bind_terminal_keys(buffer) + local function bind(sequence, command) + pmacs.keymap.bind { + scope = "buffer", + buffer = buffer, + sequence = sequence, + command = command, + } + end + bind("M-w", "terminal.copy-selection") + bind("M-v", "terminal.page-up") + bind("C-v", "terminal.page-down") + bind("M-<", "terminal.scroll-oldest") + bind("M->", "terminal.scroll-bottom") +end + +function terminal.open(spec) + local buffer = raw_open(spec) + bind_terminal_keys(buffer) + return buffer +end + +pmacs.command.define { + name = "terminal", + description = "Open a terminal running $SHELL (or /bin/sh).", + fn = function() + return terminal.open { + command = os.getenv("SHELL") or "/bin/sh", + } + end, +} + +pmacs.command.define { + name = "terminal.copy-selection", + description = "Copy the active terminal selection.", + fn = function() return terminal.copy_selection() end, +} + +pmacs.command.define { + name = "terminal.page-up", + description = "Scroll the active terminal viewport up one page.", + fn = function() return terminal._scroll_page(1) end, +} + +pmacs.command.define { + name = "terminal.page-down", + description = "Scroll the active terminal viewport down one page.", + fn = function() return terminal._scroll_page(-1) end, +} + +pmacs.command.define { + name = "terminal.scroll-oldest", + description = "Scroll the active terminal viewport to the oldest retained row.", + fn = function() return terminal.scroll(math.maxinteger) end, +} + +pmacs.command.define { + name = "terminal.scroll-bottom", + description = "Return the active terminal viewport to the live tail.", + fn = function() return terminal.scroll_to_bottom() end, +} + +pmacs.statusline.register { + name = "terminal", + side = "right", + priority = 10, + face = "ui.modeline.terminal", + fn = function(ctx) + if not terminal.is_terminal(ctx.buffer) then return nil end + local state = terminal.state(ctx.buffer) + local view = terminal.view_state(ctx) + if not view then return nil end + + local process = state.process + local text + if process.kind == "running" then + text = "TERM" + elseif process.kind == "exited" then + text = "TERM:" .. tostring(process.code) + elseif process.kind == "signaled" then + text = "TERM:" .. process.signal + else + text = "TERM:ERR" + end + if view.scroll_offset > 0 then + text = text .. " ↑" .. tostring(view.scroll_offset) + end + return text + end, +} diff --git a/src/daemon.rs b/src/daemon.rs index 3587622..5178d32 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -869,6 +869,12 @@ fn dispatcher_loop( // Declared for both flavors (the follow path is crdt-gated; the // detach cleanup isn't). let mut last_active_buffer_sent: HashMap = HashMap::new(); + // Active-terminal BEL delivery baseline. Switching away forgets the + // terminal so historical bells are never replayed on later activation. + let mut terminal_bell_baselines: HashMap< + FrontendId, + (crate::buffer::BufferId, u64), + > = HashMap::new(); let mut session_registry = SessionRegistry::new(); // T M10.11 Q8 — jitter PRNG, seeded once so the // convergence-under-jitter scenario is deterministically @@ -1082,6 +1088,14 @@ fn dispatcher_loop( // — initial-after-attach (`last_dispatch_idle_sent` absent) // and value-change emissions only. let mut write_failed = false; + if take_pending_terminal_bell(editor, *fid, &mut terminal_bell_baselines) + && let Some(stream) = streams.get_mut(fid) + && let Err(error) = + write_message(stream, &InstanceMessage::Signal(InstanceSignal::Bell)) + { + eprintln!("pmacs: write terminal Bell for {fid:?} failed: {error}"); + write_failed = true; + } if session_registry.session_state(*fid).is_some_and(|s| { // Filter on both the `crdt_replica` capability (only // optimistic-apply frontends care) and the negotiated @@ -1283,6 +1297,8 @@ fn dispatcher_loop( term_sizes.remove(fid); last_dispatch_idle_sent.remove(fid); last_active_buffer_sent.remove(fid); + terminal_bell_baselines.remove(fid); + editor.terminal_manager.borrow_mut().detach_frontend(*fid); session_registry.unregister_session(*fid); editor .statusline_registry @@ -1328,6 +1344,7 @@ fn dispatcher_loop( &mut term_sizes, &mut last_dispatch_idle_sent, &mut last_active_buffer_sent, + &mut terminal_bell_baselines, &mut session_registry, ); // Drain a burst of immediately-available events to @@ -1344,6 +1361,7 @@ fn dispatcher_loop( &mut term_sizes, &mut last_dispatch_idle_sent, &mut last_active_buffer_sent, + &mut terminal_bell_baselines, &mut session_registry, ); } @@ -1376,6 +1394,45 @@ fn dispatcher_loop( /// declares a viewport); every other session keeps the M5.3 /// force-full-grid grid path. #[allow(clippy::too_many_arguments)] +fn take_pending_terminal_bell( + editor: &EditorState, + frontend_id: FrontendId, + baselines: &mut HashMap, +) -> bool { + let buffer_id = editor + .core + .borrow() + .active_window_for(frontend_id) + .map(|window| window.buffer_id); + let Some((buffer_id, count)) = buffer_id.and_then(|buffer_id| { + editor + .terminal_manager + .borrow() + .bell_count(buffer_id) + .map(|count| (buffer_id, count)) + }) else { + baselines.remove(&frontend_id); + return false; + }; + + match baselines.get_mut(&frontend_id) { + Some((baseline_buffer, delivered)) + if *baseline_buffer == buffer_id && count > *delivered => + { + *delivered += 1; + true + } + Some((baseline_buffer, delivered)) if *baseline_buffer == buffer_id => { + *delivered = count; + false + } + _ => { + baselines.insert(frontend_id, (buffer_id, count)); + false + } + } +} + fn handle_session_established( editor: &mut EditorState, render_states: &mut HashMap, @@ -1460,6 +1517,10 @@ fn handle_dispatcher_event( term_sizes: &mut HashMap, last_dispatch_idle_sent: &mut HashMap, last_active_buffer_sent: &mut HashMap, + terminal_bell_baselines: &mut HashMap< + FrontendId, + (crate::buffer::BufferId, u64), + >, session_registry: &mut SessionRegistry, ) { match event { @@ -1627,7 +1688,7 @@ fn handle_dispatcher_event( .expect("term_size present for source"); let mut term_size = term_size; if let Some(render_state) = render_states.get_mut(&source) { - apply_event(editor, event, &mut term_size, render_state); + apply_event(editor, source, event, &mut term_size, render_state); term_sizes.insert(source, term_size); } else if semantic_states.contains_key(&source) { // Phase B (session B1) — a semantic (grid-less) @@ -1641,7 +1702,7 @@ fn handle_dispatcher_event( // arm dropped these events — the "M11.5 scope" // posture — which is why typing in pmacs-gpu did // nothing before B1.) - apply_semantic_input_event(editor, event, term_size); + apply_semantic_input_event(editor, source, event, term_size); } else { debug_assert!( false, @@ -1659,6 +1720,11 @@ fn handle_dispatcher_event( term_sizes.remove(&frontend_id); last_dispatch_idle_sent.remove(&frontend_id); last_active_buffer_sent.remove(&frontend_id); + terminal_bell_baselines.remove(&frontend_id); + editor + .terminal_manager + .borrow_mut() + .detach_frontend(frontend_id); session_registry.unregister_session(frontend_id); editor .statusline_registry @@ -2461,16 +2527,21 @@ fn build_presence_snapshot(editor: &EditorState, frontend_id: FrontendId) -> Pre /// `Paste` (Q#KR10a) are handled in their own dispatcher arms and /// never reach here. #[allow(clippy::needless_pass_by_value)] // consumes the event, mirroring `apply_event`. -fn apply_semantic_input_event(editor: &mut EditorState, ev: FrontendEvent, term_size: CellSize) { +fn apply_semantic_input_event( + editor: &mut EditorState, + source: FrontendId, + ev: FrontendEvent, + term_size: CellSize, +) { match ev { FrontendEvent::Key(pmacs_key) => { if let Some(ct_key) = key_to_crossterm(&pmacs_key) { - editor.dispatch_key(pmacs_key.frontend_id, ct_key); + editor.dispatch_key(source, ct_key); } } FrontendEvent::Mouse(pmacs_mouse) => { let ct_mouse = mouse_to_crossterm(&pmacs_mouse); - editor.dispatch_mouse(pmacs_mouse.frontend_id, ct_mouse, term_size); + editor.dispatch_mouse(source, ct_mouse, term_size); } _ => {} } @@ -2482,6 +2553,7 @@ fn apply_semantic_input_event(editor: &mut EditorState, ev: FrontendEvent, term_ #[allow(clippy::needless_pass_by_value)] fn apply_event( editor: &mut EditorState, + source: FrontendId, ev: FrontendEvent, term_size: &mut CellSize, render_state: &mut RenderState, @@ -2489,14 +2561,14 @@ fn apply_event( match ev { FrontendEvent::Key(pmacs_key) => { if let Some(ct_key) = key_to_crossterm(&pmacs_key) { - editor.dispatch_key(pmacs_key.frontend_id, ct_key); + editor.dispatch_key(source, ct_key); } // `Key::Unknown` keys (media buttons etc.) have no // crossterm equivalent and do not actuate commands; drop. } FrontendEvent::Mouse(pmacs_mouse) => { let ct_mouse = mouse_to_crossterm(&pmacs_mouse); - editor.dispatch_mouse(pmacs_mouse.frontend_id, ct_mouse, *term_size); + editor.dispatch_mouse(source, ct_mouse, *term_size); } FrontendEvent::Resize { size, .. } => { render_state.resize(size); @@ -3160,6 +3232,7 @@ mod tests { apply_semantic_input_event( &mut editor, + fid, FrontendEvent::Key(KeyEvent { frontend_id: fid, key: Key::Char('X'), @@ -3236,6 +3309,7 @@ mod tests { // A key now edits the *displayed* buffer, advancing its cursor. apply_semantic_input_event( &mut editor, + fid, FrontendEvent::Key(KeyEvent { frontend_id: fid, key: Key::Char('Z'), diff --git a/src/editor_core.rs b/src/editor_core.rs index fea6261..0fcbf2b 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -2821,6 +2821,12 @@ impl EditorCore { self.round_trip_buffers.contains(&self.active_buffer_id()) } + /// Whether an explicit buffer requires daemon-owned round-trip input. + #[must_use] + pub fn active_buffer_round_trips_for(&self, buffer_id: BufferId) -> bool { + self.round_trip_buffers.contains(&buffer_id) + } + /// Ensure the active window carries a /// [`crate::completion::CompletionView`] overlay (deduped by kind). /// The view reads the shared popup, so one instance suffices; it diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index 7821d4b..38fb010 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -8120,6 +8120,366 @@ pub fn make_process_supervisor(lua: &Lua) -> mlua::Result mlua::Result { + let manager = Rc::new(RefCell::new(crate::terminal::TerminalManager::new())); + install_terminal(lua, &manager, &supervisor)?; + Ok(manager) +} + +fn install_terminal( + lua: &Lua, + manager: &crate::terminal::SharedTerminalManager, + supervisor: &SharedProcessSupervisor, +) -> mlua::Result<()> { + let pmacs: Table = lua.globals().get("pmacs")?; + let terminal = lua.create_table()?; + + { + let manager = manager.clone(); + let supervisor = supervisor.clone(); + terminal.set( + "_open", + lua.create_function(move |lua, spec: Table| -> mlua::Result { + let spec = parse_terminal_spec(spec)?; + let core = lua + .app_data_ref::() + .map(|core| core.clone()) + .ok_or_else(|| { + mlua::Error::external("pmacs.terminal.open: editor core unavailable") + })?; + let buffer_id = { + let mut manager = manager.borrow_mut(); + manager + .open( + spec, + &mut core.borrow_mut(), + &mut supervisor.borrow_mut(), + ) + .map_err(mlua::Error::external)? + }; + let key = { + let mut core = core.borrow_mut(); + if let Err(error) = core.switch_active_buffer(buffer_id) { + let _ = core.registry.borrow_mut().remove(buffer_id); + manager + .borrow_mut() + .prune(&mut core, &mut supervisor.borrow_mut()); + return Err(mlua::Error::external(format!( + "pmacs.terminal.open: active-window switch failed: {error}" + ))); + } + crate::terminal::TerminalViewKey::new( + core.active_frontend, + core.active_window_id(), + buffer_id, + ) + }; + { + let mut manager = manager.borrow_mut(); + if !manager.register_view(key) || !manager.claim_controller(key) { + return Err(mlua::Error::external( + "pmacs.terminal.open: failed to claim the new terminal view", + )); + } + } + run_hook_if_defined(lua, "buffer.after-switch", mlua::MultiValue::new()); + Ok(BufferIdLua(buffer_id)) + })?, + )?; + } + + { + let manager = manager.clone(); + terminal.set( + "is_terminal", + lua.create_function(move |_, buffer: BufferIdLua| { + Ok(manager.borrow().is_terminal(buffer.0)) + })?, + )?; + } + + { + let manager = manager.clone(); + terminal.set( + "state", + lua.create_function(move |lua, buffer: BufferIdLua| { + let snapshot = manager.borrow().snapshot(buffer.0).ok_or_else(|| { + mlua::Error::external(format!( + "pmacs.terminal.state: buffer {:?} is not a terminal", + buffer.0 + )) + })?; + terminal_state_table(lua, snapshot) + })?, + )?; + } + + { + let manager = manager.clone(); + let supervisor = supervisor.clone(); + terminal.set( + "send", + lua.create_function(move |_, (buffer, bytes): (BufferIdLua, mlua::String)| { + manager + .borrow() + .send(buffer.0, bytes.as_bytes().as_ref(), &mut supervisor.borrow_mut()) + .map_err(mlua::Error::external) + })?, + )?; + } + + { + let manager = manager.clone(); + let supervisor = supervisor.clone(); + terminal.set( + "terminate", + lua.create_function(move |_, buffer: BufferIdLua| { + manager + .borrow_mut() + .terminate(buffer.0, &mut supervisor.borrow_mut()) + .map_err(mlua::Error::external) + })?, + )?; + } + + pmacs.set("terminal", terminal) +} + +fn parse_terminal_spec(table: Table) -> mlua::Result { + const FIELDS: &[&str] = &[ + "command", + "args", + "cwd", + "env", + "name", + "rows", + "cols", + "scrollback_rows", + ]; + let mut unknown = None; + table.clone().for_each(|key: Value, _: Value| { + let key = match key { + Value::String(key) => key.to_str()?.to_owned(), + other => { + unknown = Some(format!("<{} key>", other.type_name())); + return Ok(()); + } + }; + if !FIELDS.contains(&key.as_str()) { + unknown = Some(key); + } + Ok(()) + })?; + if let Some(field) = unknown { + return Err(mlua::Error::external(format!( + "pmacs.terminal.open: unknown field `{field}`" + ))); + } + + let command = strict_terminal_string(table.raw_get("command")?, "command", false)? + .ok_or_else(|| mlua::Error::external("pmacs.terminal.open: missing field `command`"))?; + let args = strict_terminal_args(table.raw_get("args")?)?; + let cwd = strict_terminal_string(table.raw_get("cwd")?, "cwd", true)? + .map(std::path::PathBuf::from); + let env = strict_terminal_env(table.raw_get("env")?)?; + let name = strict_terminal_string(table.raw_get("name")?, "name", true)?; + let rows = strict_terminal_u16(table.raw_get("rows")?, "rows", 24)?; + let cols = strict_terminal_u16(table.raw_get("cols")?, "cols", 80)?; + let scrollback_rows = strict_terminal_usize( + table.raw_get("scrollback_rows")?, + "scrollback_rows", + crate::terminal::DEFAULT_TERMINAL_SCROLLBACK_ROWS, + )?; + + Ok(crate::terminal::TerminalSpec { + command, + args, + cwd, + env, + name, + rows, + cols, + scrollback_rows, + }) +} + +fn strict_terminal_string( + value: Value, + field: &'static str, + optional: bool, +) -> mlua::Result> { + match value { + Value::Nil if optional => Ok(None), + Value::String(value) => Ok(Some(value.to_str()?.to_owned())), + Value::Nil => Err(mlua::Error::external(format!( + "pmacs.terminal.open: missing field `{field}`" + ))), + other => Err(mlua::Error::external(format!( + "pmacs.terminal.open: `{field}` must be a string, got {}", + other.type_name() + ))), + } +} + +fn strict_terminal_args(value: Value) -> mlua::Result> { + let Value::Table(table) = value else { + return match value { + Value::Nil => Ok(Vec::new()), + other => Err(mlua::Error::external(format!( + "pmacs.terminal.open: `args` must be a dense string array, got {}", + other.type_name() + ))), + }; + }; + let mut entries = std::collections::BTreeMap::new(); + table.for_each(|key: Value, value: Value| { + let Value::Integer(index) = key else { + return Err(mlua::Error::external( + "pmacs.terminal.open: `args` keys must be positive integers", + )); + }; + let index = usize::try_from(index).map_err(|_| { + mlua::Error::external("pmacs.terminal.open: `args` keys must be positive integers") + })?; + if index == 0 { + return Err(mlua::Error::external( + "pmacs.terminal.open: `args` keys must be positive integers", + )); + } + let Value::String(value) = value else { + return Err(mlua::Error::external(format!( + "pmacs.terminal.open: `args[{index}]` must be a string" + ))); + }; + entries.insert(index, value.to_str()?.to_owned()); + Ok(()) + })?; + let mut args = Vec::with_capacity(entries.len()); + for expected in 1..=entries.len() { + let value = entries.remove(&expected).ok_or_else(|| { + mlua::Error::external(format!( + "pmacs.terminal.open: `args` has a hole at index {expected}" + )) + })?; + args.push(value); + } + if let Some((&index, _)) = entries.first_key_value() { + return Err(mlua::Error::external(format!( + "pmacs.terminal.open: `args` has a hole before index {index}" + ))); + } + Ok(args) +} + +fn strict_terminal_env(value: Value) -> mlua::Result> { + let Value::Table(table) = value else { + return match value { + Value::Nil => Ok(Vec::new()), + other => Err(mlua::Error::external(format!( + "pmacs.terminal.open: `env` must be a string-to-string table, got {}", + other.type_name() + ))), + }; + }; + let mut env = Vec::new(); + table.for_each(|key: Value, value: Value| { + let Value::String(key) = key else { + return Err(mlua::Error::external( + "pmacs.terminal.open: `env` keys must be strings", + )); + }; + let key = key.to_str()?.to_owned(); + let Value::String(value) = value else { + return Err(mlua::Error::external(format!( + "pmacs.terminal.open: `env[{key}]` must be a string" + ))); + }; + env.push((key, value.to_str()?.to_owned())); + Ok(()) + })?; + env.sort_unstable_by(|left, right| left.0.cmp(&right.0)); + Ok(env) +} + +fn strict_terminal_u16(value: Value, field: &'static str, default: u16) -> mlua::Result { + match value { + Value::Nil => Ok(default), + Value::Integer(value) => u16::try_from(value).map_err(|_| { + mlua::Error::external(format!( + "pmacs.terminal.open: `{field}` must be an integer in 0..={}", + u16::MAX + )) + }), + other => Err(mlua::Error::external(format!( + "pmacs.terminal.open: `{field}` must be an integer, got {}", + other.type_name() + ))), + } +} + +fn strict_terminal_usize( + value: Value, + field: &'static str, + default: usize, +) -> mlua::Result { + match value { + Value::Nil => Ok(default), + Value::Integer(value) => usize::try_from(value).map_err(|_| { + mlua::Error::external(format!( + "pmacs.terminal.open: `{field}` must be a non-negative integer" + )) + }), + other => Err(mlua::Error::external(format!( + "pmacs.terminal.open: `{field}` must be an integer, got {}", + other.type_name() + ))), + } +} + +fn terminal_state_table( + lua: &Lua, + snapshot: crate::terminal::TerminalSnapshot, +) -> mlua::Result { + let state = lua.create_table()?; + state.set("buffer", BufferIdLua(snapshot.buffer_id))?; + state.set("pid", i64::from(snapshot.pid))?; + state.set("rows", i64::from(snapshot.size.rows))?; + state.set("cols", i64::from(snapshot.size.cols))?; + if let Some(title) = snapshot.title { + state.set("title", title)?; + } + state.set( + "screen_generation", + i64::try_from(snapshot.screen_generation).unwrap_or(i64::MAX), + )?; + let process = lua.create_table()?; + match snapshot.process { + crate::terminal::TerminalProcessState::Running => process.set("kind", "running")?, + crate::terminal::TerminalProcessState::Exited(code) => { + process.set("kind", "exited")?; + process.set("code", code)?; + } + crate::terminal::TerminalProcessState::Signaled(signal) => { + process.set("kind", "signaled")?; + process.set("signal", signal)?; + } + crate::terminal::TerminalProcessState::Crashed(message) => { + process.set("kind", "crashed")?; + process.set("message", message)?; + } + } + state.set("process", process)?; + Ok(state) +} + // --------------------------------------------------------------------------- // pmacs.lsp: LSP client surface (T M4.5) // --------------------------------------------------------------------------- diff --git a/src/terminal/session.rs b/src/terminal/session.rs index 78f62ba..c7761db 100644 --- a/src/terminal/session.rs +++ b/src/terminal/session.rs @@ -348,6 +348,14 @@ impl TerminalManager { .map(|session| session.process_id) } + /// Monotonic terminal BEL count used for per-frontend delivery baselines. + #[must_use] + pub fn bell_count(&self, buffer_id: BufferId) -> Option { + self.sessions + .get(&buffer_id) + .map(|session| session.screen.bell_count()) + } + /// Ensure an exact terminal view exists without changing its controller. /// /// Returns `false` when the key's buffer is not a published terminal. From 0a846d91ceedefd19eb56b519433d52ab8aeebcf Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 21 Jul 2026 19:20:00 -0400 Subject: [PATCH 05/15] feat(vterm): bind exact frontend terminal contexts Resolve terminal commands and buffer switches against the authenticated invoking frontend, publish copied selections to that frontend, and wire daemon rendering, resize, focus, paste, bell, and detach lifecycle through exact view identities. Co-Authored-By: Claude --- src/daemon.rs | 35 ++++++- src/editor_core.rs | 26 ++++- src/lua_bindings/mod.rs | 221 ++++++++++++++++++++++++++++++++++++++-- src/semantic_render.rs | 7 +- 4 files changed, 271 insertions(+), 18 deletions(-) diff --git a/src/daemon.rs b/src/daemon.rs index 5178d32..0fb8cee 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1069,10 +1069,20 @@ fn dispatcher_loop( // the sweep below); other-frontend snapshots lag by // at most one tick. Imperceptible at frame cadence. let other_presences = session_registry.other_presences_for(*fid); + let render_size = render_states + .get(fid) + .expect("render_state present for attached grid fid") + .size(); + let terminal_snapshots = editor.prepare_terminal_views(*fid, render_size); let render_state = render_states .get_mut(fid) .expect("render_state present for attached grid fid"); - render_state.render_frame(editor, &other_presences) + render_state.render_frame( + editor, + *fid, + &terminal_snapshots, + &other_presences, + ) }; // T M10.6 per-frontend presence sweep. The snapshot is @@ -1103,7 +1113,7 @@ fn dispatcher_loop( s.negotiated_capabilities.crdt_replica && s.negotiated_protocol_version >= 4 }) && let Some(stream) = streams.get_mut(fid) { - let idle_now = editor.dispatch_idle(); + let idle_now = editor.dispatch_idle_for(*fid); if last_dispatch_idle_sent.get(fid) != Some(&idle_now) { if let Err(e) = write_message(stream, &InstanceMessage::DispatchIdle { idle: idle_now }) @@ -1298,6 +1308,7 @@ fn dispatcher_loop( last_dispatch_idle_sent.remove(fid); last_active_buffer_sent.remove(fid); terminal_bell_baselines.remove(fid); + editor.detach_frontend_input(*fid); editor.terminal_manager.borrow_mut().detach_frontend(*fid); session_registry.unregister_session(*fid); editor @@ -1370,6 +1381,15 @@ fn dispatcher_loop( Err(mpsc::RecvTimeoutError::Disconnected) => break, } + // Accepted terminal context controls PTY size. Apply any focus, + // window, or resize changes before consuming another child-output + // batch so screen reflow and subsequent bytes share one geometry. + for frontend_id in &attached_fids { + if let Some(size) = term_sizes.get(frontend_id).copied() { + editor.sync_terminal_layout(*frontend_id, size); + } + } + // `tick_async` last: the M4.5 async bridge settles awaiters // inside `tick_lsp` (via the message bus); draining + resuming // in the same frame keeps LSP `:await()` latency at one frame @@ -1658,6 +1678,12 @@ fn handle_dispatcher_event( editor.dispatch_menu_pointer(source, index, invoke); } } + FrontendEvent::FocusGained(_) => { + editor.dispatch_focus(source, true); + } + FrontendEvent::FocusLost(_) => { + editor.dispatch_focus(source, false); + } FrontendEvent::Paste { frontend_id: claimed_fid, data, @@ -1680,7 +1706,9 @@ fn handle_dispatcher_event( // source's command chain (Q#KR2), and it fires // `buffer.after-edit` like any other edit (Q#KR10b) // — previously it never did, so LSP missed pastes. - handle_inbound_paste(editor, source, claimed_fid, &data); + if !editor.dispatch_paste(source, &data) { + handle_inbound_paste(editor, source, claimed_fid, &data); + } } _ => { let term_size = *term_sizes @@ -1721,6 +1749,7 @@ fn handle_dispatcher_event( last_dispatch_idle_sent.remove(&frontend_id); last_active_buffer_sent.remove(&frontend_id); terminal_bell_baselines.remove(&frontend_id); + editor.detach_frontend_input(frontend_id); editor .terminal_manager .borrow_mut() diff --git a/src/editor_core.rs b/src/editor_core.rs index 0fcbf2b..4625de9 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -2484,8 +2484,13 @@ impl EditorCore { /// read (`C-k`'s killed line, an appended chain), so the Lua ring /// pushes the exact bytes here. pub fn clipboard_set(&mut self, bytes: Vec) { + self.clipboard_set_for(self.active_frontend, bytes); + } + + /// Set and publish clipboard bytes to one authenticated frontend. + pub fn clipboard_set_for(&mut self, frontend_id: FrontendId, bytes: Vec) { self.clipboard_slot.clone_from(&bytes); - self.pending_clipboard = Some((self.active_frontend, bytes)); + self.pending_clipboard = Some((frontend_id, bytes)); } /// The clipboard slot's current bytes, or `None` when empty (kill @@ -2895,15 +2900,21 @@ impl EditorCore { .map_err(|e| e.to_string()) } - /// Switch the active window to a different buffer, allocating a - /// fresh [`TextView`] for it. - pub fn switch_active_buffer(&mut self, buffer_id: BufferId) -> Result<(), String> { + /// Switch one frontend's active window to a different buffer, allocating + /// a fresh [`TextView`] for it without changing global active state. + pub fn switch_active_buffer_for( + &mut self, + frontend_id: FrontendId, + buffer_id: BufferId, + ) -> Result<(), String> { let text_view = { let reg = self.registry.borrow(); let buf = reg.get(buffer_id).map_err(|e| e.to_string())?; TextView::new(buf) }; - let aw = self.active_window_mut(); + let aw = self + .active_window_mut_for(frontend_id) + .ok_or_else(|| format!("frontend {frontend_id:?} has no active window"))?; aw.buffer_id = buffer_id; aw.text_view = text_view; // Overlays were keyed to the previous buffer's coordinates; @@ -2917,6 +2928,11 @@ impl EditorCore { aw.goal_col = None; Ok(()) } + + /// Switch the globally active frontend's active window. + pub fn switch_active_buffer(&mut self, buffer_id: BufferId) -> Result<(), String> { + self.switch_active_buffer_for(self.active_frontend, buffer_id) + } } // --------------------------------------------------------------------------- diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index 38fb010..cb25a00 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -55,6 +55,7 @@ use crate::buffer_registry::BufferRegistry; use crate::cell::{Color, Style, UnderlineStyle}; use crate::command::{Command, CommandError, CommandRegistry, SourceLocation}; use crate::editor_core::EditorCore; +use crate::editor::InteractiveCommandOrigin; use crate::highlight::SyntaxHighlightView; use crate::hook::{Hook, HookRegistry}; use crate::key::{display_sequence, parse_sequence}; @@ -8134,6 +8135,111 @@ pub fn make_terminal_manager( Ok(manager) } +fn terminal_shared_core(lua: &Lua, operation: &str) -> mlua::Result { + lua.app_data_ref::() + .map(|core| core.clone()) + .ok_or_else(|| { + mlua::Error::external(format!( + "pmacs.terminal.{operation}: editor core unavailable" + )) + }) +} + +fn terminal_command_frontend(lua: &Lua, core: &SharedCore) -> crate::protocol::FrontendId { + lua.app_data_ref::() + .and_then(|origin| origin.current()) + .unwrap_or_else(|| core.borrow().active_frontend) +} + +fn active_terminal_view_key( + lua: &Lua, + core: &SharedCore, + operation: &str, +) -> mlua::Result { + let frontend_id = lua + .app_data_ref::() + .and_then(|origin| origin.current()) + .ok_or_else(|| { + mlua::Error::external(format!( + "pmacs.terminal.{operation}: requires an interactive frontend context" + )) + })?; + let core = core.borrow(); + let window = core.active_window_for(frontend_id).ok_or_else(|| { + mlua::Error::external(format!( + "pmacs.terminal.{operation}: invoking frontend has no active window" + )) + })?; + Ok(crate::terminal::TerminalViewKey::new( + frontend_id, + core.views + .get(&frontend_id) + .expect("active window implies registered frontend view") + .active, + window.buffer_id, + )) +} + +fn terminal_view_key_from_context( + core: &SharedCore, + context: &Table, +) -> mlua::Result> { + const FIELDS: &[&str] = &["frontend", "window", "buffer", "active"]; + let mut unknown = None; + context.clone().for_each(|key: Value, _: Value| { + if unknown.is_none() { + match key { + Value::String(value) => { + let value = value.to_str()?; + if !FIELDS.contains(&value.as_ref()) { + unknown = Some(value.to_owned()); + } + } + other => unknown = Some(format!("{other:?}")), + } + } + Ok(()) + })?; + if let Some(field) = unknown { + return Err(mlua::Error::external(format!( + "pmacs.terminal.view_state: unknown field `{field}`" + ))); + } + let frontend_raw = context.get::("frontend")?; + let frontend_id = crate::protocol::FrontendId(u64::try_from(frontend_raw).map_err(|_| { + mlua::Error::external("pmacs.terminal.view_state: `frontend` must be nonnegative") + })?); + let window_raw = u64::try_from(context.get::("window")?).map_err(|_| { + mlua::Error::external("pmacs.terminal.view_state: `window` must be nonnegative") + })?; + let buffer_id = context.get::("buffer")?.0; + + let core = core.borrow(); + let Some(view) = core.views.get(&frontend_id) else { + return Ok(None); + }; + let Some(window_id) = view + .layout + .iter_ids() + .into_iter() + .find(|window_id| window_id.raw() == window_raw) + else { + return Ok(None); + }; + if core + .windows + .get(&window_id) + .is_none_or(|window| window.buffer_id != buffer_id) + { + return Ok(None); + } + Ok(Some(crate::terminal::TerminalViewKey::new( + frontend_id, + window_id, + buffer_id, + ))) +} + fn install_terminal( lua: &Lua, manager: &crate::terminal::SharedTerminalManager, @@ -8155,6 +8261,12 @@ fn install_terminal( .ok_or_else(|| { mlua::Error::external("pmacs.terminal.open: editor core unavailable") })?; + let frontend_id = terminal_command_frontend(lua, &core); + if core.borrow().active_window_for(frontend_id).is_none() { + return Err(mlua::Error::external( + "pmacs.terminal.open: target frontend has no active window", + )); + } let buffer_id = { let mut manager = manager.borrow_mut(); manager @@ -8167,7 +8279,7 @@ fn install_terminal( }; let key = { let mut core = core.borrow_mut(); - if let Err(error) = core.switch_active_buffer(buffer_id) { + if let Err(error) = core.switch_active_buffer_for(frontend_id, buffer_id) { let _ = core.registry.borrow_mut().remove(buffer_id); manager .borrow_mut() @@ -8177,18 +8289,27 @@ fn install_terminal( ))); } crate::terminal::TerminalViewKey::new( - core.active_frontend, - core.active_window_id(), + frontend_id, + core.views + .get(&frontend_id) + .expect("checked frontend has active view") + .active, buffer_id, ) }; - { + let claimed = { let mut manager = manager.borrow_mut(); - if !manager.register_view(key) || !manager.claim_controller(key) { - return Err(mlua::Error::external( - "pmacs.terminal.open: failed to claim the new terminal view", - )); - } + manager.register_view(key) && manager.claim_controller(key) + }; + if !claimed { + let mut core = core.borrow_mut(); + let _ = core.registry.borrow_mut().remove(buffer_id); + manager + .borrow_mut() + .prune(&mut core, &mut supervisor.borrow_mut()); + return Err(mlua::Error::external( + "pmacs.terminal.open: failed to claim the new terminal view", + )); } run_hook_if_defined(lua, "buffer.after-switch", mlua::MultiValue::new()); Ok(BufferIdLua(buffer_id)) @@ -8250,6 +8371,88 @@ fn install_terminal( )?; } + { + let manager = manager.clone(); + terminal.set( + "view_state", + lua.create_function(move |lua, context: Table| -> mlua::Result> { + let core = terminal_shared_core(lua, "view_state")?; + let Some(key) = terminal_view_key_from_context(&core, &context)? else { + return Ok(None); + }; + let Some(status) = manager.borrow_mut().view_status(key) else { + return Ok(None); + }; + let table = lua.create_table()?; + table.set("at_bottom", status.at_bottom)?; + table.set("scroll_offset", status.scroll_offset)?; + table.set("selection", status.selection)?; + Ok(Some(table)) + })?, + )?; + } + + { + let manager = manager.clone(); + terminal.set( + "scroll", + lua.create_function(move |lua, lines: i64| { + let lines = i32::try_from(lines).map_err(|_| { + mlua::Error::external("pmacs.terminal.scroll: `lines` exceeds i32 range") + })?; + let core = terminal_shared_core(lua, "scroll")?; + let key = active_terminal_view_key(lua, &core, "scroll")?; + Ok(manager.borrow_mut().scroll_lines(key, lines)) + })?, + )?; + } + + { + let manager = manager.clone(); + terminal.set( + "_scroll_page", + lua.create_function(move |lua, direction: i64| { + let direction = i32::try_from(direction).map_err(|_| { + mlua::Error::external( + "pmacs.terminal._scroll_page: `direction` exceeds i32 range", + ) + })?; + let core = terminal_shared_core(lua, "_scroll_page")?; + let key = active_terminal_view_key(lua, &core, "_scroll_page")?; + Ok(manager.borrow_mut().scroll_page(key, direction)) + })?, + )?; + } + + { + let manager = manager.clone(); + terminal.set( + "scroll_to_bottom", + lua.create_function(move |lua, ()| { + let core = terminal_shared_core(lua, "scroll_to_bottom")?; + let key = active_terminal_view_key(lua, &core, "scroll_to_bottom")?; + Ok(manager.borrow_mut().scroll_to_bottom(key)) + })?, + )?; + } + + { + let manager = manager.clone(); + terminal.set( + "copy_selection", + lua.create_function(move |lua, ()| { + let core = terminal_shared_core(lua, "copy_selection")?; + let key = active_terminal_view_key(lua, &core, "copy_selection")?; + let Some(bytes) = manager.borrow_mut().copy_selection(key) else { + return Ok(false); + }; + core.borrow_mut() + .clipboard_set_for(key.frontend_id, bytes); + Ok(true) + })?, + )?; + } + pmacs.set("terminal", terminal) } diff --git a/src/semantic_render.rs b/src/semantic_render.rs index 14ff5a7..35b6389 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -3824,7 +3824,12 @@ mod tests { let buffer_id = active_buffer(&state); sem.set_viewport(buffer_id, ByteRange { start: 0, end: 80 }, 0); - let grid_msgs = grid.render_frame(&state, &[]); + let grid_msgs = grid.render_frame( + &state, + FrontendId::LOCAL, + &HashMap::new(), + &[], + ); let sem_msgs = sem.render_frame(&state); assert!( matches!(grid_msgs[0], InstanceMessage::CellDelta { .. }), From 0dacac7e8d9f55cd4dd61cf5a30a5322ea599b1e Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 21 Jul 2026 19:21:50 -0400 Subject: [PATCH 06/15] feat(vterm): compose terminal views in the TUI Render exact per-frontend terminal snapshots inside split-window content rects, route terminal key, paste, focus, and mouse input through durable controllers, and preserve logical row anchors across reflow, selection, and scrollback. Co-Authored-By: Claude --- src/editor.rs | 991 ++++++++++++++++++++++++++++++++--------- src/frontend.rs | 5 +- src/instance_render.rs | 59 ++- src/terminal/view.rs | 853 ++++++++++++++++++++++++++++++++++- 4 files changed, 1673 insertions(+), 235 deletions(-) diff --git a/src/editor.rs b/src/editor.rs index 95d2139..8cd6984 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -13,7 +13,7 @@ //! until the user quits. use std::cell::{Cell, RefCell}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::io; use std::path::PathBuf; use std::rc::Rc; @@ -24,7 +24,7 @@ use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; use crate::async_runtime::SharedAsyncRuntime; -use crate::cell::CellCoord; +use crate::cell::{CellCoord, CellSize}; use crate::editor_core::EditorCore; use crate::file_io::load_file; use crate::frontend::{Event, Frontend, KeyEvent, KeyEventKind, MouseEvent, install_panic_hook}; @@ -33,9 +33,15 @@ use crate::keymap_stack::{Action, KeyDispatcher}; use crate::lua::LuaHost; use crate::lua_bindings::SharedCore; use crate::minibuffer::Minibuffer; -use crate::protocol::FrontendId; +use crate::protocol::{ + FrontendId, InstanceMessage, InstanceSignal, Key as TerminalKey, + Modifiers as TerminalModifiers, MouseButton as TerminalMouseButton, + MouseKind as TerminalMouseKind, +}; +use crate::terminal::TerminalSnapshot; use crate::view::{View, Viewport}; use crate::window::{Rect, WindowId}; +use crate::terminal::view::TerminalViewKey; /// Ephemeral authenticated origin for one interactive command invocation. /// @@ -85,8 +91,8 @@ pub struct EditorState { pub core: SharedCore, /// The embedded Lua VM and its command/keymap registries. pub lua_host: LuaHost, - /// Multi-key prefix state machine. Driven by the run loop. - pub dispatcher: KeyDispatcher, + /// Independent key-prefix and terminal-escape state per authenticated frontend. + dispatchers: HashMap, /// Authenticated frontend scoped to the current interactive invocation. pub(crate) interactive_origin: InteractiveCommandOrigin, /// Main-thread async runtime (T M3.3). Owns the worker pool and @@ -153,6 +159,12 @@ pub struct EditorState { mouse_click: Option, } +#[derive(Default)] +struct FrontendDispatchState { + dispatcher: KeyDispatcher, + terminal_escape: bool, +} + impl Drop for EditorState { /// Tear down the worker-pool threads. /// @@ -288,7 +300,17 @@ impl EditorState { // shutdown enforces no-zombie cleanup at editor exit. let process_supervisor = crate::lua_bindings::make_process_supervisor(lua_host.lua()) .expect("install pmacs.process"); - let terminal_manager = Rc::new(RefCell::new(crate::terminal::TerminalManager::new())); + let terminal_manager = crate::lua_bindings::make_terminal_manager( + lua_host.lua(), + process_supervisor.clone(), + ) + .expect("install pmacs.terminal"); + lua_host + .eval( + Some("@pmacs/builtin/runtime/terminal.lua"), + include_str!("../builtin/runtime/terminal.lua"), + ) + .expect("load terminal builtin chunk"); // T M4.5 LSP manager. Wires onto the same supervisor so its // spawn/restart/I/O machinery is shared with `pmacs.process.*`. // The manager itself is reachable from Lua as `pmacs.lsp.*`. @@ -527,7 +549,7 @@ impl EditorState { Self { core, lua_host, - dispatcher: KeyDispatcher::new(), + dispatchers: HashMap::new(), interactive_origin, async_runtime, syntax_registry, @@ -698,51 +720,42 @@ impl EditorState { let _ = core.switch_active_buffer(buffer_id); } - /// Translate a key event into a chord, run it through the - /// dispatcher, and invoke the resolved command (or the - /// self-insert fallback for an unbound printable chord). - /// - /// Whether the daemon's key-dispatch path is currently "idle" in - /// the sense that the *next* key event would self-insert into the - /// active buffer rather than being intercepted. - /// - /// `false` when either: - /// - /// - the dispatcher holds a pending multi-key prefix (e.g. the - /// user has typed `C-x` and the daemon is waiting for the next - /// chord), or - /// - a minibuffer prompt is active and absorbing keys, or - /// - an incremental search is running and absorbing keys (Q#SR5). - /// - /// Used by the daemon to drive the `InstanceMessage::DispatchIdle` - /// wire signal that gates `crdt_replica` frontends' optimistic-apply - /// path. Without this signal the optimistic layer would Insert a - /// plain-char keystroke into the active document while the - /// daemon's actual intent is to route the keystroke into the - /// minibuffer prompt — the M10.10 "documented limitation" that - /// surfaced during session-5 manual validation. Isearch reuses the - /// exact same gate: while a search runs every keystroke must - /// round-trip so the daemon's `dispatch_search_key` receives it - /// (extend the query / step) instead of the frontend self-inserting - /// it into the buffer. + /// Whether `frontend_id` may optimistically self-insert its next key. #[must_use] - pub fn dispatch_idle(&self) -> bool { - if !self.dispatcher.pending().is_empty() { + pub fn dispatch_idle_for(&self, frontend_id: FrontendId) -> bool { + if self + .dispatchers + .get(&frontend_id) + .is_some_and(|state| { + state.terminal_escape || !state.dispatcher.pending().is_empty() + }) + { return false; } let core = self.core.borrow(); - // A live context menu shadows the keymap too (Q#CM1): keys must - // round-trip so the daemon's `dispatch_menu_key` drives the menu - // rather than the frontend self-inserting. A round-trip buffer - // (Arc 1b Q#P6 — a focused panel) is the buffer-shaped member of - // the same family: RET must reach its buffer-local bindings and - // typing must reach its read-only intercept, neither of which an - // optimistic local edit would do. !core.minibuffer.is_active() && !core.search_active() && !core.query_replace_active() && !core.menu_is_open() - && !core.active_buffer_round_trips() + && core + .active_window_for(frontend_id) + .is_some_and(|window| { + !core.active_buffer_round_trips_for(window.buffer_id) + }) + } + + /// Local-frontend compatibility wrapper. + #[must_use] + pub fn dispatch_idle(&self) -> bool { + self.dispatch_idle_for(FrontendId::LOCAL) + } + + /// Drop one detached frontend's pending key and terminal escape state. + pub fn detach_frontend_input(&mut self, frontend_id: FrontendId) { + self.dispatchers.remove(&frontend_id); + self.terminal_manager + .borrow_mut() + .detach_frontend(frontend_id); } /// `frontend_id` records which frontend produced the event. v0.1 @@ -753,21 +766,14 @@ impl EditorState { /// before any command body runs, so observers always see a fresh /// value. pub fn dispatch_key(&mut self, frontend_id: FrontendId, key: KeyEvent) { - let Some(chord) = key_event_to_chord(key) else { + if !matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) { return; - }; + } + let chord = key_event_to_chord(key); { let mut core = self.core.borrow_mut(); core.status.clear(); core.active_frontend = frontend_id; - } - - // Modal surfaces beat the completion popup (Q#C3): if a menu / - // search / minibuffer opened while the popup was up, close the - // popup before the modal shadow swallows this key --- otherwise - // it would linger, rendered but unreachable. - { - let mut core = self.core.borrow_mut(); if core.completion_popup_is_open() && (core.menu_is_open() || core.search_active() @@ -778,91 +784,105 @@ impl EditorState { } } - // Context-menu interception (Q#CM1): while a menu is open every - // key drives it (navigate / invoke / dismiss), shadowing the - // global keymap like search and the minibuffer. Same shared path - // both frontends reach via the `FrontendEvent::Key` round-trip. + // Global modal surfaces own input before terminal transport. if self.core.borrow().menu_is_open() { - self.dispatch_menu_key(chord); + if let Some(chord) = chord { + self.dispatch_menu_key(frontend_id, chord); + } return; } - - // Incremental-search interception: while an isearch is running, - // every key routes through the search handler (the global keymap - // is shadowed, like the minibuffer). Printable chars extend the - // query; C-s / C-r step; RET accepts; C-g / Esc cancel. This is - // the shared input path for both frontends — the daemon's - // `FrontendEvent::Key` round-trip lands here too. if self.core.borrow().search_active() { - self.dispatch_search_key(chord); + if let Some(chord) = chord { + self.dispatch_search_key(chord); + } return; } - - // Query-replace interception (Arc 2): the fifth modal shadow. - // While the interactive phase runs, every key drives it - // (y/n/!/./q), shadowing the global keymap like search. Both - // frontends reach this via the `FrontendEvent::Key` round-trip - // (`dispatch_idle` is false while it runs). The handler fires - // `buffer.after-edit` itself — a modal shadow returns before the - // normal post-command edit check below (Q#QR1). if self.core.borrow().query_replace_active() { - self.dispatch_query_replace_key(chord); + if let Some(chord) = chord { + self.dispatch_query_replace_key(chord); + } return; } - - // Minibuffer interception: when a prompt is active, every key - // routes through the minibuffer's hardcoded handler. The main - // editor's keymap is bypassed; the user can still cancel with - // C-g and resume normal dispatch. if self.core.borrow().minibuffer.is_active() { - self.dispatch_minibuffer_key(chord); + if let Some(chord) = chord { + self.dispatch_minibuffer_key(frontend_id, chord); + } return; } - // In-buffer completion popup (Q#C3): a PARTIAL shadow, the - // fourth member of the family above. Only the popup-control - // chords (TAB / RET / C-n / C-p / Up / Down / Esc / C-g) are - // intercepted; every other key falls through to normal dispatch - // below, so typing keeps self-inserting and motion keys keep - // moving. The post-dispatch validation at the bottom of this - // function closes the session when a fallen-through key breaks - // the anchor/prefix invariant. A pending multi-key prefix owns - // the keyboard: while one is in flight (`C-x ...`) the popup - // must not steal its continuation or its `C-g` abort --- and - // the Pending arm below closes the popup anyway, so this guard - // only covers the same-dispatch race. + let dispatcher_pending = self + .dispatchers + .get(&frontend_id) + .is_some_and(|state| !state.dispatcher.pending().is_empty()); if self.core.borrow().completion_popup_is_open() - && self.dispatcher.pending().is_empty() - && let Some(key) = CompletionPopupKey::from_chord(chord) + && !dispatcher_pending + && let Some(popup_key) = chord.and_then(CompletionPopupKey::from_chord) { - self.dispatch_completion_key(key); + self.dispatch_completion_key(popup_key); return; } - // Buffer-scope keybindings need the active buffer id passed - // through the dispatcher (otherwise `keymap_stack::resolve` - // skips the buffer-local map entirely and every "scope = - // buffer" binding falls through to global). The id is read - // outside the keymap borrow so a single-buffer focus check - // doesn't collide with the stack lookup below. + let terminal_key = self.active_terminal_key(frontend_id); + let escaped = self + .dispatchers + .get(&frontend_id) + .is_some_and(|state| state.terminal_escape); + if let Some(view_key) = terminal_key { + if escaped { + self.dispatchers + .entry(frontend_id) + .or_default() + .terminal_escape = false; + if chord.is_some_and(is_terminal_escape_chord) { + self.claim_terminal_controller(view_key); + self.send_terminal_bytes(view_key.buffer_id, &[0x03]); + return; + } + // The post-escape key starts a fresh ordinary sequence below. + } else if !dispatcher_pending { + if chord.is_some_and(is_terminal_escape_chord) { + let state = self.dispatchers.entry(frontend_id).or_default(); + state.terminal_escape = true; + state.dispatcher = KeyDispatcher::new(); + self.claim_terminal_controller(view_key); + return; + } + let Some((terminal_key, modifiers)) = terminal_key_from_crossterm(key) else { + return; + }; + let modes = self + .terminal_manager + .borrow() + .modes_for_view(view_key) + .unwrap_or_default(); + if let Some(bytes) = + crate::terminal::input::encode_key(terminal_key, modifiers, modes) + { + self.claim_terminal_controller(view_key); + self.send_terminal_bytes(view_key.buffer_id, &bytes); + } + return; + } + } + + let Some(chord) = chord else { + return; + }; let active_buffer = Some(self.core.borrow().active_buffer_id()); let action = { let stack = self.lua_host.keymaps().borrow(); - self.dispatcher.dispatch(chord, &stack, active_buffer, &[]) + self.dispatchers + .entry(frontend_id) + .or_default() + .dispatcher + .dispatch(chord, &stack, active_buffer, &[]) }; - - // Snapshot the active buffer's edit revision before the command - // runs so we can fire `buffer.after-edit` only when it changes. - // Stale-id paths (active buffer killed mid-dispatch) fall back - // to "no edit observed", which is the correct conservative call. let pre_revision = self.active_buffer_revision(); match action { Action::Run { command, .. } => { - // Kill ring Q#KR2: record the command boundary before the - // body runs, so the body's own `ed.last_command()` reads - // its *predecessor* (Emacs `last-command` semantics). self.core.borrow_mut().rotate_command(frontend_id, &command); + let _origin = self.interactive_origin.enter(frontend_id); if let Err(e) = self .lua_host .invoke_command(&command, mlua::MultiValue::new()) @@ -872,39 +892,22 @@ impl EditorState { } } Action::Pending { .. } => { - // The pending prefix is rendered from - // `dispatcher.pending()`; no command runs yet. Starting - // a command sequence dismisses the completion popup: - // leaving it open would route the sequence's `C-g` - // abort (and its continuation chords) into the popup's - // shadow instead of the dispatcher. self.core.borrow_mut().completion_popup_close(); } Action::Unbound { sequence } => { if let Some(ch) = printable_char(&sequence) { - // Typing a character is a command too (Q#KR2): it - // must break a kill chain — `C-k x C-k` is two ring - // entries, not an append. self.core .borrow_mut() .rotate_command(frontend_id, "buffer.self-insert"); - // Auto-pairing Q#AP9: this dispatch is the typed - // self-insert producer — arm the exact typed-edit - // record so the after-edit fan-out below can - // expose it. The insert primitive completes the - // record with the effective (post-intercept) edit; - // `typed_edit_finish` takes it back on every path - // out of this dispatch. self.core.borrow_mut().typed_edit_arm(frontend_id, ch); let mut args = mlua::MultiValue::new(); args.push_back(mlua::Value::Integer(ch as i64)); + let _origin = self.interactive_origin.enter(frontend_id); if let Err(e) = self.lua_host.invoke_command("buffer.self-insert", args) { self.core.borrow_mut().status = format!("self-insert failed: {}", first_line(&e.to_string())); } } else { - // An unbound key still breaks the chain (Q#KR2) — - // Emacs's `undefined` runs as a command. self.core.borrow_mut().break_command_chain(frontend_id); self.core.borrow_mut().status = format!("{}: not bound", display_sequence(&sequence)); @@ -912,15 +915,7 @@ impl EditorState { } } - // Auto-pairing Q#AP9: take back the typed-edit arm on every - // path out of this dispatch — command error, rejected insert, - // and the no-revision-change case all land here with either a - // completed record or nothing. The record is armed for Lua - // only across the one after-edit fan-out below and cleared - // the moment it returns, so paste, later dispatches, and - // manual hook runs can never observe a stale record. let typed_edit = self.core.borrow_mut().typed_edit_finish(frontend_id); - let post_revision = self.active_buffer_revision(); if pre_revision != post_revision { if let Some(record) = typed_edit { @@ -932,15 +927,239 @@ impl EditorState { .run_hook("buffer.after-edit", mlua::MultiValue::new()); self.core.borrow_mut().typed_edit_clear_armed(); } - - // Q#C3 post-dispatch validation, deliberately AFTER the - // after-edit hook: the Lua driver may have just refreshed (or - // re-anchored) the popup for this very edit, and validation - // must judge the fresh session, not the stale one. A closed - // popup makes this a single mutex peek. self.core.borrow_mut().completion_popup_validate(); } + fn active_terminal_key(&self, frontend_id: FrontendId) -> Option { + let core = self.core.borrow(); + let view = core.views.get(&frontend_id)?; + let window = core.windows.get(&view.active)?; + let key = TerminalViewKey::new(frontend_id, window.id, window.buffer_id); + self.terminal_manager + .borrow() + .is_terminal(window.buffer_id) + .then_some(key) + } + + fn claim_terminal_controller(&self, key: TerminalViewKey) { + let mut manager = self.terminal_manager.borrow_mut(); + if let Some(previous) = manager.controller_view_for_frontend(key.frontend_id) + && previous != key + { + let _ = manager.release_controller(previous); + } + let _ = manager.register_view(key); + let _ = manager.claim_controller(key); + } + + fn send_terminal_bytes(&self, buffer_id: crate::buffer::BufferId, bytes: &[u8]) { + let result = self.terminal_manager.borrow().send( + buffer_id, + bytes, + &mut self.process_supervisor.borrow_mut(), + ); + if let Err(error) = result { + self.core.borrow_mut().status = error.to_string(); + } + } + + /// Consume a paste as terminal input for one authenticated frontend. + /// + /// Returns `false` when modal/document paste handling must run instead. + pub fn dispatch_paste(&mut self, frontend_id: FrontendId, bytes: &[u8]) -> bool { + { + let mut core = self.core.borrow_mut(); + core.active_frontend = frontend_id; + if core.menu_is_open() + || core.search_active() + || core.query_replace_active() + || core.minibuffer.is_active() + { + return false; + } + } + let Some(key) = self.active_terminal_key(frontend_id) else { + return false; + }; + let modes = self + .terminal_manager + .borrow() + .modes_for_view(key) + .unwrap_or_default(); + let encoded = crate::terminal::input::encode_paste(bytes, modes.bracketed_paste); + self.claim_terminal_controller(key); + self.send_terminal_bytes(key.buffer_id, &encoded); + true + } + + /// Apply authenticated frontend focus to terminal control/reporting. + pub fn dispatch_focus(&mut self, frontend_id: FrontendId, gained: bool) { + self.core.borrow_mut().active_frontend = frontend_id; + if gained { + let Some(key) = self.active_terminal_key(frontend_id) else { + return; + }; + let modes = self + .terminal_manager + .borrow() + .modes_for_view(key) + .unwrap_or_default(); + self.claim_terminal_controller(key); + if let Some(bytes) = + crate::terminal::input::encode_focus(true, modes.focus_reporting) + { + self.send_terminal_bytes(key.buffer_id, &bytes); + } + return; + } + + let controlled = self + .terminal_manager + .borrow() + .controller_view_for_frontend(frontend_id); + let Some(key) = controlled else { + return; + }; + let modes = self + .terminal_manager + .borrow() + .modes_for_view(key) + .unwrap_or_default(); + if let Some(bytes) = + crate::terminal::input::encode_focus(false, modes.focus_reporting) + { + self.send_terminal_bytes(key.buffer_id, &bytes); + } + let _ = self.terminal_manager.borrow_mut().release_controller(key); + } + + /// Resize the one session durably controlled by `frontend_id`. + /// + /// This is called before process drain and paint, never from rendering. + pub fn sync_terminal_layout( + &mut self, + frontend_id: FrontendId, + term_size: CellSize, + ) -> bool { + let Some(key) = self + .terminal_manager + .borrow() + .controller_view_for_frontend(frontend_id) + else { + return false; + }; + let content = { + let core = self.core.borrow(); + let Some(view) = core.views.get(&frontend_id) else { + let _ = self.terminal_manager.borrow_mut().release_controller(key); + return false; + }; + if view.active != key.window_id + || core + .windows + .get(&key.window_id) + .is_none_or(|window| window.buffer_id != key.buffer_id) + { + let _ = self.terminal_manager.borrow_mut().release_controller(key); + return false; + } + let Some(placement) = + window_placements(&core, frontend_id, term_size).get(&key.window_id).copied() + else { + return false; + }; + placement.content + }; + if content.size.rows == 0 || content.size.cols == 0 { + return false; + } + let old_size = self + .terminal_manager + .borrow() + .snapshot(key.buffer_id) + .map(|snapshot| snapshot.size); + if old_size == Some(content.size) { + return false; + } + let Ok(rows) = u16::try_from(content.size.rows) else { + return false; + }; + let Ok(cols) = u16::try_from(content.size.cols) else { + return false; + }; + let result = self.terminal_manager.borrow_mut().resize( + key.buffer_id, + rows, + cols, + &mut self.process_supervisor.borrow_mut(), + ); + if let Err(error) = result { + self.core.borrow_mut().status = error.to_string(); + false + } else { + true + } + } + + /// Precompute owned terminal view snapshots before entering paint borrows. + pub fn prepare_terminal_views( + &mut self, + frontend_id: FrontendId, + term_size: CellSize, + ) -> HashMap { + let (live, sizes) = { + let core = self.core.borrow(); + let placements = window_placements(&core, frontend_id, term_size); + let mut live = HashSet::new(); + let mut sizes = Vec::new(); + for (window_id, placement) in placements { + let Some(window) = core.windows.get(&window_id) else { + continue; + }; + if placement.content.size.rows == 0 || placement.content.size.cols == 0 { + continue; + } + let key = + TerminalViewKey::new(frontend_id, window_id, window.buffer_id); + if self.terminal_manager.borrow().is_terminal(window.buffer_id) { + live.insert(key); + sizes.push((key, placement.content.size)); + } + } + (live, sizes) + }; + let mut manager = self.terminal_manager.borrow_mut(); + manager.retain_frontend_views(frontend_id, &live); + sizes + .into_iter() + .filter_map(|(key, size)| { + manager + .snapshot_for_view(key, size) + .map(|snapshot| (key.window_id, snapshot)) + }) + .collect() + } + + /// Drain local-only terminal/clipboard output signals after a frame. + pub fn take_local_signals(&mut self) -> Vec { + let frontend_id = FrontendId::LOCAL; + let active = self.active_terminal_key(frontend_id); + let mut messages = Vec::new(); + if self + .terminal_manager + .borrow_mut() + .take_bell_for_frontend(frontend_id, active) + { + messages.push(InstanceMessage::Signal(InstanceSignal::Bell)); + } + if let Some((target, bytes)) = self.core.borrow_mut().take_pending_clipboard() + && target == frontend_id + { + messages.push(InstanceMessage::Signal(InstanceSignal::Clipboard(bytes))); + } + messages + } + /// Active buffer's edit revision, or `None` if the registry no /// longer knows about the active buffer (e.g. a command killed it /// mid-dispatch). @@ -1006,13 +1225,13 @@ impl EditorState { /// Keys without a handler are silently ignored --- this matches /// Emacs's behaviour, where minibuffer mode shadows the global /// keymap. - fn dispatch_minibuffer_key(&mut self, chord: Chord) { + fn dispatch_minibuffer_key(&mut self, frontend_id: FrontendId, chord: Chord) { use crate::minibuffer::MinibufferAction; use crossterm::event::{KeyCode, KeyModifiers}; let action = MinibufferAction::from_chord(chord); match action { - MinibufferAction::Accept => self.minibuffer_accept(), + MinibufferAction::Accept => self.minibuffer_accept(frontend_id), MinibufferAction::Cancel => self.minibuffer_cancel(), MinibufferAction::Complete => self.minibuffer_complete(), MinibufferAction::HistoryPrev => self.with_minibuffer(Minibuffer::history_prev), @@ -1155,11 +1374,11 @@ impl EditorState { } /// Drive an open context menu from a keystroke (Q#CM1). - fn dispatch_menu_key(&mut self, chord: Chord) { + fn dispatch_menu_key(&mut self, frontend_id: FrontendId, chord: Chord) { match MenuKey::from_chord(chord) { MenuKey::Next => self.core.borrow_mut().menu_step(1), MenuKey::Prev => self.core.borrow_mut().menu_step(-1), - MenuKey::Invoke => self.menu_invoke_active(), + MenuKey::Invoke => self.menu_invoke_active(frontend_id), MenuKey::Cancel | MenuKey::Dismiss => self.core.borrow_mut().menu_close(), } } @@ -1168,7 +1387,7 @@ impl EditorState { /// menu closes *first* so the command runs against a clean state /// (and a command that itself opens a menu isn't immediately torn /// down). - fn menu_invoke_active(&mut self) { + fn menu_invoke_active(&mut self, frontend_id: FrontendId) { let command = self.core.borrow().menu_active_command(); self.core.borrow_mut().menu_close(); if let Some(command) = command { @@ -1176,14 +1395,13 @@ impl EditorState { // rotate the boundary so a menu Cut chains like a keybound // one. The invoke below bypasses dispatch_key, which would // otherwise leave the boundary stale. - { - let mut core = self.core.borrow_mut(); - let fid = core.active_frontend; - core.rotate_command(fid, &command); - } + self.core + .borrow_mut() + .rotate_command(frontend_id, &command); // Q#KR10b: menu invocation bypasses dispatch_key's // revision check — a menu Cut's edit must still fire // `buffer.after-edit`. + let _origin = self.interactive_origin.enter(frontend_id); self.with_after_edit_check(|state| { if let Err(e) = state .lua_host @@ -1251,7 +1469,13 @@ impl EditorState { /// Drive an open menu from a mouse event (Q#CM1): hover highlights, /// left-click invokes, a click outside (or right-click) dismisses. - fn dispatch_menu_mouse(&mut self, ev: MouseEvent, cell_row: u32, cell_col: u32) { + fn dispatch_menu_mouse( + &mut self, + frontend_id: FrontendId, + ev: MouseEvent, + cell_row: u32, + cell_col: u32, + ) { use crossterm::event::{MouseButton, MouseEventKind}; let hit = self.core.borrow().menu_hit(cell_row, cell_col); match ev.kind { @@ -1263,7 +1487,7 @@ impl EditorState { MouseEventKind::Down(MouseButton::Left) => match hit { Some(row) => { self.core.borrow_mut().menu_set_active_row(row); - self.menu_invoke_active(); + self.menu_invoke_active(frontend_id); } None => self.core.borrow_mut().menu_close(), }, @@ -1287,7 +1511,7 @@ impl EditorState { } } - fn minibuffer_accept(&mut self) { + fn minibuffer_accept(&mut self, frontend_id: FrontendId) { let outcome = self.core.borrow_mut().minibuffer.accept(); let Some((on_accept, contents)) = outcome else { return; @@ -1306,6 +1530,7 @@ impl EditorState { // post-command revision check (the minibuffer interception // returns before it), so an M-x'd editing command would never // fire `buffer.after-edit` without this wrapper. + let _origin = self.interactive_origin.enter(frontend_id); self.with_after_edit_check(|state| { if let Err(e) = on_accept.call::(args) { state.core.borrow_mut().status = format!( @@ -1373,17 +1598,37 @@ impl EditorState { // outside dismisses) — handled before window hit-testing so an // outside click anywhere closes it. if self.core.borrow().menu_is_open() { - self.dispatch_menu_mouse(ev, cell_row, cell_col); + self.dispatch_menu_mouse(frontend_id, ev, cell_row, cell_col); return; } let Some((win_id, rect)) = - window_at_cell(&self.core.borrow(), term_size, cell_row, cell_col) + window_at_cell(&self.core.borrow(), frontend_id, term_size, cell_row, cell_col) else { return; }; let inner_rows = rect.size.rows.saturating_sub(1); let local_row = cell_row.saturating_sub(rect.origin.row); + let buffer_id = self.core.borrow().windows[&win_id].buffer_id; + if self.terminal_manager.borrow().is_terminal(buffer_id) { + let content_size = CellSize::new(inner_rows, rect.size.cols); + if local_row >= inner_rows || content_size.rows == 0 || content_size.cols == 0 { + self.mouse_click = None; + return; + } + let local = CellCoord::new( + local_row, + cell_col.saturating_sub(rect.origin.col), + ); + self.dispatch_terminal_mouse( + TerminalViewKey::new(frontend_id, win_id, buffer_id), + content_size, + local, + ev, + (cell_row, cell_col), + ); + return; + } // UX gutter (Q#UX6): subtract the reserved gutter width so the // hit-test lands on the right text byte. A click inside the gutter // strip (raw < gutter_w) saturates to column 0 → the start of that @@ -1469,6 +1714,74 @@ impl EditorState { } } + fn dispatch_terminal_mouse( + &mut self, + key: TerminalViewKey, + viewport_size: CellSize, + coord: CellCoord, + event: MouseEvent, + global: (u32, u32), + ) { + use crossterm::event::{MouseButton, MouseEventKind}; + + let Some(kind) = terminal_mouse_kind(event.kind) else { + return; + }; + let modifiers = terminal_modifiers(event.modifiers); + let shift = modifiers.contains(TerminalModifiers::SHIFT); + let (at_bottom, modes, screen_size) = { + let mut manager = self.terminal_manager.borrow_mut(); + let Some(snapshot) = manager.snapshot_for_view(key, viewport_size) else { + return; + }; + let modes = manager.modes_for_view(key).unwrap_or_default(); + let screen_size = manager + .snapshot(key.buffer_id) + .map_or(viewport_size, |snapshot| snapshot.size); + (snapshot.at_bottom, modes, screen_size) + }; + + if !shift + && at_bottom + && modes.mouse_sgr + && coord.row < screen_size.rows + && coord.col < screen_size.cols + && let Some(bytes) = + crate::terminal::input::encode_mouse(kind, coord, modifiers, modes) + { + self.claim_terminal_controller(key); + self.send_terminal_bytes(key.buffer_id, &bytes); + return; + } + + self.claim_terminal_controller(key); + let mut manager = self.terminal_manager.borrow_mut(); + match event.kind { + MouseEventKind::ScrollUp => { + let _ = manager.scroll_view(key, viewport_size, SCROLL_LINES); + } + MouseEventKind::ScrollDown => { + let _ = manager.scroll_view(key, viewport_size, -SCROLL_LINES); + } + MouseEventKind::Down(MouseButton::Left) => { + let _ = manager.begin_selection(key, viewport_size, coord); + } + MouseEventKind::Drag(MouseButton::Left) => { + let _ = manager.update_selection(key, viewport_size, coord); + } + MouseEventKind::Up(MouseButton::Left) => { + let _ = manager.finish_selection(key, viewport_size, coord); + } + MouseEventKind::Down(MouseButton::Right) => { + drop(manager); + self.core.borrow_mut().break_command_chain(key.frontend_id); + let rows = self.build_menu_rows(); + self.core.borrow_mut().menu_open(rows, global); + } + _ => {} + } + } + fn is_double_click( &self, frontend_id: FrontendId, @@ -1652,7 +1965,7 @@ impl EditorState { (Some(i), false) => self.core.borrow_mut().menu_set_active_row(i as usize), (Some(i), true) => { self.core.borrow_mut().menu_set_active_row(i as usize); - self.menu_invoke_active(); + self.menu_invoke_active(frontend_id); } (None, true) => self.core.borrow_mut().menu_close(), (None, false) => {} @@ -1754,6 +2067,42 @@ impl EditorState { /// readline / Emacs default and is what most terminal users expect. const SCROLL_LINES: i32 = 3; +/// Shared outer/content geometry consumed by terminal paint and PTY resize. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct WindowPlacement { + pub(crate) outer: Rect, + pub(crate) content: Rect, +} + +/// Compute one explicit frontend's split geometry. +#[must_use] +pub(crate) fn window_placements( + core: &EditorCore, + frontend_id: FrontendId, + term_size: CellSize, +) -> HashMap { + if term_size.rows < 2 || term_size.cols == 0 { + return HashMap::new(); + } + let Some(view) = core.views.get(&frontend_id) else { + return HashMap::new(); + }; + let area = Rect::new(0, 0, term_size.rows - 1, term_size.cols); + view.layout + .compute(area) + .into_iter() + .map(|(window_id, outer)| { + let content = Rect::new( + outer.origin.row, + outer.origin.col, + outer.size.rows.saturating_sub(1), + outer.size.cols, + ); + (window_id, WindowPlacement { outer, content }) + }) + .collect() +} + /// Find the leaf window whose viewport rectangle contains /// `(cell_row, cell_col)` in the global cell grid. Used by the mouse /// dispatcher to route clicks. The bottom row of the terminal (status @@ -1761,26 +2110,23 @@ const SCROLL_LINES: i32 = 3; /// `None`. fn window_at_cell( core: &EditorCore, - term_size: crate::cell::CellSize, + frontend_id: FrontendId, + term_size: CellSize, cell_row: u32, cell_col: u32, ) -> Option<(WindowId, Rect)> { - if term_size.rows < 2 { + if cell_row >= term_size.rows.saturating_sub(1) { return None; } - let text_rows = term_size.rows - 1; - if cell_row >= text_rows { - return None; - } - let area = Rect::new(0, 0, text_rows, term_size.cols); - let placements = core.active_layout().compute(area); - placements.iter().find_map(|(id, rect)| { + let placements = window_placements(core, frontend_id, term_size); + placements.iter().find_map(|(id, placement)| { + let rect = placement.outer; if cell_row >= rect.origin.row && cell_row < rect.origin.row + rect.size.rows && cell_col >= rect.origin.col && cell_col < rect.origin.col + rect.size.cols { - Some((*id, *rect)) + Some((*id, rect)) } else { None } @@ -1871,8 +2217,16 @@ pub fn run(file: Option) -> io::Result<()> { let mut render_state = crate::instance_render::RenderState::new(frontend.size()); loop { - // In-process TUI never has remote frontends; no overlays. - let messages = render_state.render_frame(&state, &[]); + let size = frontend.size(); + let _ = state.sync_terminal_layout(FrontendId::LOCAL, size); + let terminal_snapshots = state.prepare_terminal_views(FrontendId::LOCAL, size); + let mut messages = render_state.render_frame( + &state, + FrontendId::LOCAL, + &terminal_snapshots, + &[], + ); + messages.extend(state.take_local_signals()); frontend.present_messages(&messages)?; if state.core.borrow().quit { break; @@ -1914,6 +2268,7 @@ pub fn run(file: Option) -> io::Result<()> { // resumption by a full frame. The documented invariant is // only `tick_processes → tick_lsp → tick_mcp` (same-batch // supervisor I/O ordering), which is preserved. + let _ = state.sync_terminal_layout(FrontendId::LOCAL, frontend.size()); state.tick_processes(); state.tick_lsp(); state.tick_mcp(); @@ -1953,7 +2308,20 @@ fn process_event(state: &mut EditorState, ev: Event, term_size: crate::cell::Cel Event::Mouse(m) => { state.dispatch_mouse(frontend_id, m, term_size); } - _ => {} + Event::Paste(bytes) => { + if !state.dispatch_paste(frontend_id, bytes.as_bytes()) { + state.core.borrow_mut().active_frontend = frontend_id; + state.with_after_edit_check(|state| { + if let Err(error) = state.core.borrow_mut().paste_inbound(bytes.as_bytes()) { + state.core.borrow_mut().status = error; + } + }); + } + } + Event::FocusGained => state.dispatch_focus(frontend_id, true), + Event::FocusLost => state.dispatch_focus(frontend_id, false), + Event::Key(_) => {} + Event::Resize(_, _) => {} } } @@ -2179,17 +2547,17 @@ impl CompletionPopupKey { #[allow(clippy::too_many_lines, reason = "linear paint pipeline")] pub fn paint_frame( state: &EditorState, + frontend_id: FrontendId, + terminal_snapshots: &HashMap, grid: &mut crate::cell::CellGrid<'_>, - term_size: crate::cell::CellSize, + term_size: CellSize, ) -> Option { if term_size.rows < 2 || term_size.cols == 0 { return None; } - let text_rows = term_size.rows - 1; // Statusline callbacks may call arbitrary editor APIs. Evaluate the // complete visible-window fan-out before the long mutable core borrow // below, then paint only the transactionally validated owned results. - let frontend_id = state.core.borrow().active_frontend; let statusline_evaluation = crate::statusline::evaluate_statusline( state.lua_host.lua(), &state.core, @@ -2213,15 +2581,17 @@ pub fn paint_frame( let t = handle.lock().expect("theme mutex poisoned"); t.clone() }; + let empty_dispatcher = KeyDispatcher::new(); + let dispatcher = state + .dispatchers + .get(&frontend_id) + .map_or(&empty_dispatcher, |state| &state.dispatcher); let mut core_ref = state.core.borrow_mut(); let core: &mut EditorCore = &mut core_ref; - // Compute per-window rectangles. The text area is the term size - // minus the bottom row (status / minibuffer). - let text_area = crate::window::Rect::new(0, 0, text_rows, term_size.cols); - let placements = core.active_layout().compute(text_area); - let active = core.active_window_id(); + let placements = window_placements(core, frontend_id, term_size); + let active = core.views.get(&frontend_id)?.active; // Clear the whole grid first so windows that shrink on resize // don't leak the old contents. @@ -2233,12 +2603,15 @@ pub fn paint_frame( // Scroll the active window so its cursor stays visible. Inactive // windows keep their existing scroll. - if let Some(active_rect) = placements.get(&active) { - let inner_rows = inner_rows(active_rect); + if let Some(active_placement) = placements.get(&active) { + let inner_rows = active_placement.content.size.rows; let registry = core.registry.clone(); let reg = registry.borrow(); - let buf_id = core.active_buffer_id(); - if let Ok(buf) = reg.get(buf_id) { + let buf_id = core.windows.get(&active).map(|window| window.buffer_id); + if !terminal_snapshots.contains_key(&active) + && let Some(buf_id) = buf_id + && let Ok(buf) = reg.get(buf_id) + { let aw = core.windows.get_mut(&active).expect( "invariant: active_window_id always references a live window in core.windows", ); @@ -2259,16 +2632,46 @@ pub fn paint_frame( let reg = registry.borrow(); let diag_store = state.lsp_manager.borrow().diag_store(); for (id, window) in &mut core.windows { - let Some(rect) = placements.get(id).copied() else { + let Some(placement) = placements.get(id).copied() else { continue; }; - let inner_rows = inner_rows(&rect); + let rect = placement.outer; + let inner_rows = placement.content.size.rows; // Record viewport height for page motion (cursor.page-down / // cursor.page-up consume this). window.last_visible_rows = inner_rows; if inner_rows == 0 || rect.size.cols == 0 { continue; } + if let Some(snapshot) = terminal_snapshots.get(id) { + paint_terminal_snapshot(grid, placement.content, snapshot, &theme); + let Ok(buf) = reg.get(window.buffer_id) else { + continue; + }; + let cursor = snapshot.cursor.unwrap_or_default(); + let scroll = if snapshot.scroll_offset == 0 { + String::new() + } else { + format!("↑{}", snapshot.scroll_offset) + }; + let custom = statusline_by_window.get(id); + paint_mode_line( + grid, + &rect, + buf.name(), + false, + *id == active, + cursor.row, + cursor.col, + &scroll, + "", + mode_line_style(&theme), + custom.map_or(&[], |segments| segments.left.as_slice()), + custom.map_or(&[], |segments| segments.right.as_slice()), + &theme, + ); + continue; + } let Ok(buf) = reg.get(window.buffer_id) else { continue; }; @@ -2346,7 +2749,7 @@ pub fn paint_frame( grid, core, &state.lua_host, - &state.dispatcher, + dispatcher, term_size, &theme, ); @@ -2367,7 +2770,20 @@ pub fn paint_frame( if let Some(col) = mb_cursor_col { return Some(CellCoord::new(term_size.rows - 1, col)); } - let active_rect = placements.get(&active).copied()?; + let active_placement = placements.get(&active).copied()?; + if let Some(snapshot) = terminal_snapshots.get(&active) { + let cursor = snapshot.cursor?; + if cursor.row >= active_placement.content.size.rows + || cursor.col >= active_placement.content.size.cols + { + return None; + } + return Some(CellCoord::new( + active_placement.content.origin.row + cursor.row, + active_placement.content.origin.col + cursor.col, + )); + } + let active_rect = active_placement.outer; let registry = core.registry.clone(); let reg = registry.borrow(); let aw = &core.windows[&active]; @@ -2390,6 +2806,47 @@ pub fn paint_frame( Some(CellCoord::new(grid_row, grid_col)) } +fn paint_terminal_snapshot( + grid: &mut crate::cell::CellGrid<'_>, + content: Rect, + snapshot: &TerminalSnapshot, + theme: &crate::highlight::Theme, +) { + let rows = content.size.rows.min(snapshot.size.rows); + let cols = content.size.cols.min(snapshot.size.cols); + for row in 0..rows { + for col in 0..cols { + let source = row as usize * snapshot.size.cols as usize + col as usize; + *grid.at(CellCoord::new( + content.origin.row + row, + content.origin.col + col, + )) = snapshot.cells[source].clone(); + } + } + let overlay = theme.face("ui.selection").map_or( + crate::cell::Style { + reverse: true, + ..crate::cell::Style::default() + }, + |face| crate::cell::Style { + bg: face.bg, + ..crate::cell::Style::default() + }, + ); + for span in &snapshot.selection { + if span.row >= rows { + continue; + } + for col in span.start_col.min(cols)..span.end_col.min(cols) { + let cell = grid.at(CellCoord::new( + content.origin.row + span.row, + content.origin.col + col, + )); + cell.style = crate::overlay::merge_styles(cell.style, overlay); + } + } +} + /// The mode-line row style (themes arc Q#TH5): a set `ui.modeline` /// face owns the surface within its {fg, bg, reverse} mask — the row /// resets to plain plus the face's in-mask components — else today's @@ -3142,6 +3599,43 @@ fn sanitize_single_line(s: &str) -> String { .collect() } +fn is_terminal_escape_chord(chord: Chord) -> bool { + chord.code == KeyCode::Char('c') && chord.modifiers == KeyModifiers::CONTROL +} + +fn terminal_key_from_crossterm(key: KeyEvent) -> Option<(TerminalKey, TerminalModifiers)> { + let modifiers = + crate::protocol::crossterm_translate::mods_from_crossterm(key.modifiers); + let key = crate::protocol::crossterm_translate::keycode_from_crossterm(key.code); + if matches!(key, TerminalKey::Unknown(_)) { + return None; + } + Some((key, modifiers)) +} + +fn terminal_modifiers(modifiers: KeyModifiers) -> TerminalModifiers { + crate::protocol::crossterm_translate::mods_from_crossterm(modifiers) +} + +fn terminal_mouse_kind(kind: crossterm::event::MouseEventKind) -> Option { + use crossterm::event::{MouseButton, MouseEventKind}; + let button = |button| match button { + MouseButton::Left => TerminalMouseButton::Left, + MouseButton::Right => TerminalMouseButton::Right, + MouseButton::Middle => TerminalMouseButton::Middle, + }; + Some(match kind { + MouseEventKind::Down(value) => TerminalMouseKind::Down(button(value)), + MouseEventKind::Up(value) => TerminalMouseKind::Up(button(value)), + MouseEventKind::Drag(value) => TerminalMouseKind::Drag(button(value)), + MouseEventKind::Moved => TerminalMouseKind::Move, + MouseEventKind::ScrollUp => TerminalMouseKind::ScrollUp, + MouseEventKind::ScrollDown => TerminalMouseKind::ScrollDown, + MouseEventKind::ScrollLeft => TerminalMouseKind::ScrollLeft, + MouseEventKind::ScrollRight => TerminalMouseKind::ScrollRight, + }) +} + fn key_event_to_chord(key: KeyEvent) -> Option { // Accept Press and Repeat. Some terminals (notably ones speaking // the kitty keyboard protocol with auto-repeat) deliver held-key @@ -3193,6 +3687,83 @@ mod tests { use super::*; use crate::frontend::KeyEventKind; + fn local_dispatcher(state: &EditorState) -> &KeyDispatcher { + &state + .dispatchers + .get(&FrontendId::LOCAL) + .expect("local dispatcher registered by dispatch") + .dispatcher + } + + #[test] + fn dispatch_prefix_state_is_independent_per_frontend() { + let mut state = fresh_with(b""); + let other = FrontendId(77); + state.dispatch_key(FrontendId::LOCAL, ctrl('x')); + state.dispatch_key(other, plain(KeyCode::Char('a'))); + assert_eq!(local_dispatcher(&state).pending().len(), 1); + assert!( + state + .dispatchers + .get(&other) + .expect("other dispatcher registered") + .dispatcher + .pending() + .is_empty() + ); + assert_eq!(state.core.borrow().active_buffer_len(), 1); + } + + #[test] + fn terminal_snapshot_composes_only_content_and_translates_cursor() { + let state = fresh_with(b""); + let window_id = state.core.borrow().active_window_id(); + let buffer_id = state.core.borrow().active_buffer_id(); + let size = CellSize::new(4, 5); + let viewport = CellSize::new(2, 5); + let mut cells = vec![crate::cell::Cell::default(); viewport.area() as usize]; + cells[0].glyph = crate::cell::Glyph::Char('T'); + cells[7].glyph = crate::cell::Glyph::Char('X'); + let snapshot = TerminalSnapshot { + buffer_id, + size: viewport, + cells, + cursor: Some(CellCoord::new(1, 2)), + title: Some("shell".into()), + screen_generation: 1, + selection: vec![crate::terminal::TerminalSelectionSpan { + row: 0, + start_col: 0, + end_col: 1, + }], + scroll_offset: 0, + at_bottom: true, + pid: 1, + process: crate::terminal::TerminalProcessState::Running, + }; + let snapshots = HashMap::from([(window_id, snapshot)]); + let mut backing = vec![crate::cell::Cell::default(); size.area() as usize]; + let cursor = { + let mut grid = crate::cell::CellGrid { + cells: &mut backing, + stride: size.cols, + size, + }; + paint_frame( + &state, + FrontendId::LOCAL, + &snapshots, + &mut grid, + size, + ) + }; + assert_eq!(backing[0].glyph, crate::cell::Glyph::Char('T')); + assert!(backing[0].style.reverse); + assert_eq!(backing[7].glyph, crate::cell::Glyph::Char('X')); + assert_ne!(backing[10].glyph, crate::cell::Glyph::Char('X')); + assert_eq!(cursor, Some(CellCoord::new(1, 2))); + } + #[test] fn line_number_gutter_renders_right_aligned_digits() { use crate::buffer::{Buffer, BufferId}; @@ -3417,10 +3988,10 @@ mod tests { fn cx_cc_quits() { let mut s = fresh_with(b""); s.dispatch_key(FrontendId::LOCAL, ctrl('x')); - assert_eq!(s.dispatcher.pending().len(), 1); + assert_eq!(local_dispatcher(&s).pending().len(), 1); s.dispatch_key(FrontendId::LOCAL, ctrl('c')); assert!(s.core.borrow().quit); - assert!(s.dispatcher.pending().is_empty()); + assert!(local_dispatcher(&s).pending().is_empty()); } #[test] @@ -3542,10 +4113,10 @@ mod tests { let size = crate::cell::CellSize::new(24, 80); let mut rs = crate::instance_render::RenderState::new(size); - let _ = rs.render_frame(&s, &[]); + let _ = rs.render_frame(&s, FrontendId::LOCAL, &HashMap::new(), &[]); process_event(&mut s, Event::Key(ctrl('s')), size); assert!(s.core.borrow().search_active(), "C-s starts the search"); - let _ = rs.render_frame(&s, &[]); + let _ = rs.render_frame(&s, FrontendId::LOCAL, &HashMap::new(), &[]); for c in "foo".chars() { process_event( @@ -3553,7 +4124,7 @@ mod tests { Event::Key(key(KeyCode::Char(c), KeyModifiers::NONE)), size, ); - let _ = rs.render_frame(&s, &[]); + let _ = rs.render_frame(&s, FrontendId::LOCAL, &HashMap::new(), &[]); } assert_eq!( s.core.borrow().search_query(), @@ -3581,7 +4152,7 @@ mod tests { stride: size.cols, size, }; - let _ = paint_frame(&s, &mut grid, size); + let _ = paint_frame(&s, FrontendId::LOCAL, &HashMap::new(), &mut grid, size); // The active match [0,3) washes row 0's first cells (bright // Indexed(11); lazy matches would be Indexed(3)). @@ -3707,10 +4278,10 @@ mod tests { state: crossterm::event::KeyEventState::NONE, }; s.dispatch_key(FrontendId::LOCAL, cx_press); - assert_eq!(s.dispatcher.pending().len(), 1); + assert_eq!(local_dispatcher(&s).pending().len(), 1); s.dispatch_key(FrontendId::LOCAL, cb_repeat); assert!( - s.dispatcher.pending().is_empty(), + local_dispatcher(&s).pending().is_empty(), "Repeat-kind C-b did not resolve the pending C-x prefix" ); assert_eq!(s.core.borrow().active_buffer_name(), "*buffer-list*"); @@ -3737,7 +4308,7 @@ mod tests { s.dispatch_key(FrontendId::LOCAL, cx_press); s.dispatch_key(FrontendId::LOCAL, cx_release); assert_eq!( - s.dispatcher.pending().len(), + local_dispatcher(&s).pending().len(), 1, "Release events should be ignored, but the prefix was disturbed" ); @@ -3772,10 +4343,10 @@ mod tests { // window to the *buffer-list* buffer). let mut s = fresh_with(b""); s.dispatch_key(FrontendId::LOCAL, ctrl('x')); - assert_eq!(s.dispatcher.pending().len(), 1, "C-x should start prefix"); + assert_eq!(local_dispatcher(&s).pending().len(), 1, "C-x should start prefix"); s.dispatch_key(FrontendId::LOCAL, ctrl('b')); assert!( - s.dispatcher.pending().is_empty(), + local_dispatcher(&s).pending().is_empty(), "C-x C-b should resolve, leaving no pending prefix; status: {}", s.core.borrow().status ); @@ -3833,9 +4404,9 @@ mod tests { fn unknown_chord_continuation_clears_prefix_with_message() { let mut s = fresh_with(b""); s.dispatch_key(FrontendId::LOCAL, ctrl('x')); - assert_eq!(s.dispatcher.pending().len(), 1); + assert_eq!(local_dispatcher(&s).pending().len(), 1); s.dispatch_key(FrontendId::LOCAL, ctrl('q')); - assert!(s.dispatcher.pending().is_empty()); + assert!(local_dispatcher(&s).pending().is_empty()); assert!(s.core.borrow().status.contains("not bound")); } @@ -3876,7 +4447,7 @@ mod tests { key(KeyCode::Char('u'), KeyModifiers::NONE), ); assert_eq!(s.core.borrow().active_buffer_len(), 0); - assert!(s.dispatcher.pending().is_empty()); + assert!(local_dispatcher(&s).pending().is_empty()); } #[test] @@ -4219,7 +4790,7 @@ mod tests { #[test] fn empty_status_row_is_blank() { let s = fresh_with(b"hello\n"); - let line = build_status_line(&s.core.borrow(), &s.lua_host, &s.dispatcher, 80); + let line = build_status_line(&s.core.borrow(), &s.lua_host, &KeyDispatcher::new(), 80); assert_eq!(line, "", "status row should be empty when nothing to say"); } @@ -4227,7 +4798,7 @@ mod tests { fn captured_lua_error_appears_in_status_line() { let mut s = fresh_with(b""); let _ = s.lua_host.eval(Some("usercfg"), "error('kapow')"); - let line = build_status_line(&s.core.borrow(), &s.lua_host, &s.dispatcher, 200); + let line = build_status_line(&s.core.borrow(), &s.lua_host, &KeyDispatcher::new(), 200); assert!(line.contains("lua: "), "status line: {line}"); assert!(line.contains("kapow"), "status line: {line}"); } @@ -4242,7 +4813,7 @@ mod tests { let s = fresh_with(b""); s.core.borrow_mut().status = "M-x error: command \"foo\" not found\nstack traceback:\n\t[C]: in ?".into(); - let line = build_status_line(&s.core.borrow(), &s.lua_host, &s.dispatcher, 200); + let line = build_status_line(&s.core.borrow(), &s.lua_host, &KeyDispatcher::new(), 200); assert!(!line.contains('\n'), "status line leaked newline: {line:?}"); assert!(!line.contains('\r'), "status line leaked CR: {line:?}"); assert!( @@ -4261,7 +4832,7 @@ mod tests { let _ = s .lua_host .eval(Some("usercfg"), "error('boom\\nlots\\nof\\nlines')"); - let line = build_status_line(&s.core.borrow(), &s.lua_host, &s.dispatcher, 200); + let line = build_status_line(&s.core.borrow(), &s.lua_host, &KeyDispatcher::new(), 200); assert!(!line.contains('\n'), "status line leaked newline: {line:?}"); assert!(line.contains("lua: "), "status line: {line}"); } @@ -4271,7 +4842,7 @@ mod tests { let mut s = fresh_with(b""); let _ = s.lua_host.eval(None, "error('latent')"); s.core.borrow_mut().status = "saved foo".into(); - let line = build_status_line(&s.core.borrow(), &s.lua_host, &s.dispatcher, 200); + let line = build_status_line(&s.core.borrow(), &s.lua_host, &KeyDispatcher::new(), 200); assert!(line.contains("saved foo")); assert!(!line.contains("lua: ")); } @@ -5096,7 +5667,7 @@ mod tests { "raw status leaked newline: {raw:?} (default.lua should take first line)" ); assert!(raw.starts_with("M-x error: "), "raw status: {raw}"); - let line = build_status_line(&s.core.borrow(), &s.lua_host, &s.dispatcher, 200); + let line = build_status_line(&s.core.borrow(), &s.lua_host, &KeyDispatcher::new(), 200); assert!( !line.contains('\n'), "rendered status line leaked newline: {line:?} (raw: {raw:?})" @@ -7085,7 +7656,7 @@ mod tests { stride: cols, size: crate::cell::CellSize::new(rows, cols), }; - let cursor = paint_frame(s, &mut grid, crate::cell::CellSize::new(rows, cols)); + let cursor = paint_frame(s, FrontendId::LOCAL, &HashMap::new(), &mut grid, crate::cell::CellSize::new(rows, cols)); (backing, cols, cursor) } diff --git a/src/frontend.rs b/src/frontend.rs index 8fe6831..6c1ca45 100644 --- a/src/frontend.rs +++ b/src/frontend.rs @@ -348,8 +348,11 @@ impl Frontend { let payload = format!("\x1b]52;c;{}\x07", osc52_base64(data)); queue!(self.out, Print(payload))?; } + InstanceMessage::Signal(InstanceSignal::Bell) => { + queue!(self.out, Print("\x07"))?; + } InstanceMessage::ModeLine(_) - // Bell / window-title Signals stay reserved for v0.3. + // Window-title requests remain metadata-only. | InstanceMessage::Signal(_) | InstanceMessage::Goodbye(_) // T M10.5: CrdtOp's wire shape exists; the v1.0 TUI doesn't diff --git a/src/instance_render.rs b/src/instance_render.rs index 4cbe405..dc19f2d 100644 --- a/src/instance_render.rs +++ b/src/instance_render.rs @@ -18,7 +18,10 @@ use crate::cell::{Cell, CellGrid, CellSize, diff}; use crate::editor::{EditorState, paint_frame}; -use crate::protocol::{CursorState, InstanceMessage}; +use crate::protocol::{CursorState, FrontendId, InstanceMessage}; +use crate::terminal::TerminalSnapshot; +use crate::window::WindowId; +use std::collections::HashMap; /// Owns the cell buffers and runs the paint-and-diff cycle. pub struct RenderState { @@ -95,6 +98,8 @@ impl RenderState { pub fn render_frame( &mut self, state: &EditorState, + frontend_id: FrontendId, + terminal_snapshots: &HashMap, other_presences: &[crate::overlay_paint::OtherPresence], ) -> Vec { if self.size.rows < 2 || self.size.cols == 0 { @@ -107,7 +112,13 @@ impl RenderState { stride: self.size.cols, size: self.size, }; - let coord = paint_frame(state, &mut grid, self.size); + let coord = paint_frame( + state, + frontend_id, + terminal_snapshots, + &mut grid, + self.size, + ); // T M10.9 — overlay paint after main paint, before diff. // Modifies cells in `next`; diff captures the changes // as ordinary style updates. @@ -186,7 +197,7 @@ mod tests { #[test] fn render_returns_cell_delta_and_cursor() { let mut r = RenderState::new(CellSize::new(24, 80)); - let msgs = r.render_frame(&empty_state(), &[]); + let msgs = r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]); assert_eq!(msgs.len(), 2); assert!(matches!(msgs[0], InstanceMessage::CellDelta { .. })); assert!(matches!(msgs[1], InstanceMessage::Cursor(_))); @@ -195,7 +206,7 @@ mod tests { #[test] fn first_frame_is_full_grid_sync() { let mut r = RenderState::new(CellSize::new(24, 80)); - let msgs = r.render_frame(&empty_state(), &[]); + let msgs = r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]); match &msgs[0] { InstanceMessage::CellDelta { full_grid, .. } => assert!(*full_grid), _ => panic!("expected CellDelta first"), @@ -205,8 +216,8 @@ mod tests { #[test] fn second_frame_is_differential() { let mut r = RenderState::new(CellSize::new(24, 80)); - let _ = r.render_frame(&empty_state(), &[]); - let msgs = r.render_frame(&empty_state(), &[]); + let _ = r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]); + let msgs = r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]); match &msgs[0] { InstanceMessage::CellDelta { full_grid, .. } => assert!(!*full_grid), _ => panic!("expected CellDelta first"), @@ -217,8 +228,8 @@ mod tests { fn unchanged_state_produces_empty_spans_after_first_frame() { let state = empty_state(); let mut r = RenderState::new(CellSize::new(24, 80)); - let _ = r.render_frame(&state, &[]); - let msgs = r.render_frame(&state, &[]); + let _ = r.render_frame(&state, FrontendId::LOCAL, &HashMap::new(), &[]); + let msgs = r.render_frame(&state, FrontendId::LOCAL, &HashMap::new(), &[]); match &msgs[0] { InstanceMessage::CellDelta { spans, .. } => assert!( spans.is_empty(), @@ -231,7 +242,7 @@ mod tests { #[test] fn resize_reallocates_and_flags_full_grid() { let mut r = RenderState::new(CellSize::new(24, 80)); - let _ = r.render_frame(&empty_state(), &[]); + let _ = r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]); assert!(!r.needs_full_grid); r.resize(CellSize::new(40, 120)); @@ -240,7 +251,7 @@ mod tests { assert_eq!(r.next.len(), 40 * 120); assert!(r.needs_full_grid); - let msgs = r.render_frame(&empty_state(), &[]); + let msgs = r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]); match &msgs[0] { InstanceMessage::CellDelta { full_grid, .. } => assert!(*full_grid), _ => unreachable!(), @@ -250,7 +261,7 @@ mod tests { #[test] fn resize_to_same_size_is_noop() { let mut r = RenderState::new(CellSize::new(24, 80)); - let _ = r.render_frame(&empty_state(), &[]); + let _ = r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]); assert!(!r.needs_full_grid); r.resize(CellSize::new(24, 80)); // No reallocation, no full-grid flip. @@ -260,7 +271,7 @@ mod tests { #[test] fn force_full_grid_resync_flips_flag() { let mut r = RenderState::new(CellSize::new(24, 80)); - let _ = r.render_frame(&empty_state(), &[]); + let _ = r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]); assert!(!r.needs_full_grid); r.force_full_grid_resync(); assert!(r.needs_full_grid); @@ -270,16 +281,22 @@ mod tests { fn too_small_grid_returns_empty_messages() { // rows < 2 means we can't paint a text-area + status row. let mut r = RenderState::new(CellSize::new(1, 80)); - assert!(r.render_frame(&empty_state(), &[]).is_empty()); + assert!( + r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]) + .is_empty() + ); let mut r = RenderState::new(CellSize::new(24, 0)); - assert!(r.render_frame(&empty_state(), &[]).is_empty()); + assert!( + r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]) + .is_empty() + ); } #[test] fn cursor_message_carries_coord_when_paint_returns_one() { let mut r = RenderState::new(CellSize::new(24, 80)); - let msgs = r.render_frame(&empty_state(), &[]); + let msgs = r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]); match &msgs[1] { InstanceMessage::Cursor(Some(cs)) => { assert!(cs.visible); @@ -309,7 +326,7 @@ mod tests { // Criterion 1: the first frame after construction is a full-grid // CellDelta carrying every non-default cell. let mut r = RenderState::new(CellSize::new(24, 80)); - let msgs = r.render_frame(&empty_state(), &[]); + let msgs = r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]); match &msgs[0] { InstanceMessage::CellDelta { full_grid, spans } => { assert!(*full_grid, "first frame must be flagged full_grid=true"); @@ -335,7 +352,7 @@ mod tests { let mut state = EditorState::new(); let mut r = RenderState::new(size); // Seat the prev buffer. - let _ = r.render_frame(&state, &[]); + let _ = r.render_frame(&state, FrontendId::LOCAL, &HashMap::new(), &[]); // Single character insert. state.dispatch_key( @@ -347,7 +364,7 @@ mod tests { state: KeyEventState::empty(), }, ); - let msgs = r.render_frame(&state, &[]); + let msgs = r.render_frame(&state, FrontendId::LOCAL, &HashMap::new(), &[]); match &msgs[0] { InstanceMessage::CellDelta { full_grid, spans } => { assert!(!*full_grid, "differential frame must not flag full_grid"); @@ -374,7 +391,7 @@ mod tests { let mut r = RenderState::new(size); // First render: seats prev with the painted frame. - let first = r.render_frame(&empty_state(), &[]); + let first = r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]); let baseline_changed: usize = match &first[0] { InstanceMessage::CellDelta { spans, .. } => spans.iter().map(|s| s.cells.len()).sum(), _ => unreachable!(), @@ -383,7 +400,7 @@ mod tests { // A second render with no state change normally produces zero // spans (the state matches prev exactly). - let unchanged = r.render_frame(&empty_state(), &[]); + let unchanged = r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]); match &unchanged[0] { InstanceMessage::CellDelta { full_grid, spans } => { assert!(!*full_grid); @@ -396,7 +413,7 @@ mod tests { // what's on screen. force_full_grid_resync flags the next frame // for full sync. r.force_full_grid_resync(); - let resync = r.render_frame(&empty_state(), &[]); + let resync = r.render_frame(&empty_state(), FrontendId::LOCAL, &HashMap::new(), &[]); match &resync[0] { InstanceMessage::CellDelta { full_grid, spans } => { assert!(*full_grid, "post-resync frame must be full_grid=true"); diff --git a/src/terminal/view.rs b/src/terminal/view.rs index b16c462..d05ac71 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -1,10 +1,18 @@ -//! Per-frontend terminal viewport and selection identities. +//! Per-frontend terminal viewport, selection, copy, and bell projection. //! -//! These types identify projections over one [`super::screen::TerminalScreen`]. -//! They never own or mirror terminal cells. +//! A view stores only logical anchors into one [`TerminalScreen`]. Cells, +//! modes, history, and process state remain session-owned. use crate::buffer::BufferId; +use crate::cell::{Cell, CellCoord, CellSize, Glyph, Style}; use crate::protocol::FrontendId; +use crate::terminal::screen::{ScreenProjection, TerminalModes, TerminalRow}; +use crate::terminal::session::{ + TerminalManager, TerminalSelectionSpan, TerminalSnapshot, +}; +use crate::terminal::{ + MAX_TERMINAL_COLS, MAX_TERMINAL_ROWS, MAX_TERMINAL_VISIBLE_CELLS, +}; use crate::window::WindowId; /// One frontend/window projection of a terminal session. @@ -48,6 +56,17 @@ pub struct TerminalSelection { pub head: LogicalCellAnchor, } +/// Fresh context metadata for Lua/statusline consumers. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct TerminalViewStatus { + /// Geometric live-tail visibility. + pub at_bottom: bool, + /// Physical retained rows between this viewport and the live tail. + pub scroll_offset: u32, + /// Whether the view owns a nonempty selection. + pub selection: bool, +} + /// Mutable state for one [`TerminalViewKey`]. #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct TerminalViewState { @@ -57,6 +76,10 @@ pub struct TerminalViewState { pub selection: Option, /// Current editor-owned drag endpoint; cleared on release. pub drag: Option, + pub(super) alternate_active: Option, + pub(super) last_bell_count: u64, + pub(super) viewport_size: Option, + pub(super) selection_froze_top: bool, } /// The one authenticated frontend/window allowed to control a session's PTY. @@ -84,3 +107,827 @@ impl TerminalController { self.frontend_id == key.frontend_id && self.window_id == key.window_id } } + +impl TerminalManager { + /// Project one exact view into an owned viewport-sized snapshot. + /// + /// Zero or out-of-range viewports and unknown sessions return `None` + /// without registering view state. + #[must_use] + pub fn snapshot_for_view( + &mut self, + key: TerminalViewKey, + viewport_size: CellSize, + ) -> Option { + if !valid_viewport(viewport_size) { + return None; + } + let session = self.sessions.get(&key.buffer_id)?; + let projection = session.screen.projection(); + let pid = session.pid; + let process = session.process.clone(); + let bell_count = session.screen.bell_count(); + + let state = self.views.entry(key).or_insert_with(|| TerminalViewState { + alternate_active: Some(projection.alternate_active), + last_bell_count: bell_count, + ..TerminalViewState::default() + }); + normalize_state(state, &projection); + state.viewport_size = Some(viewport_size); + Some(project_snapshot( + key.buffer_id, + viewport_size, + &projection, + state, + pid, + process, + )) + } + + /// Scroll one view by physical retained rows. + /// + /// Positive values move toward older rows and negative values move toward + /// the live tail. Returns whether the top anchor changed. + pub fn scroll_view( + &mut self, + key: TerminalViewKey, + viewport_size: CellSize, + lines: i32, + ) -> bool { + if lines == 0 || !valid_viewport(viewport_size) { + return false; + } + let Some(session) = self.sessions.get(&key.buffer_id) else { + return false; + }; + let projection = session.screen.projection(); + let bell_count = session.screen.bell_count(); + let state = self.views.entry(key).or_insert_with(|| TerminalViewState { + alternate_active: Some(projection.alternate_active), + last_bell_count: bell_count, + ..TerminalViewState::default() + }); + normalize_state(state, &projection); + state.viewport_size = Some(viewport_size); + let rows = retained_rows(&projection); + if rows.is_empty() { + return false; + } + let geometry = view_geometry(&rows, state, viewport_size.rows); + let tail_start = rows.len().saturating_sub(viewport_size.rows as usize); + let magnitude = lines.unsigned_abs() as usize; + let next = if lines > 0 { + geometry.start.saturating_sub(magnitude) + } else { + geometry.start.saturating_add(magnitude).min(tail_start) + }; + if next == geometry.start { + return false; + } + state.top = if next == tail_start && state.selection.is_none() { + None + } else { + Some(row_lead(rows[next])) + }; + true + } + + /// Scroll by `lines` using the last nonzero rendered viewport. + pub fn scroll_lines(&mut self, key: TerminalViewKey, lines: i32) -> bool { + if lines == 0 { + return false; + } + let Some(size) = self.views.get(&key).and_then(|state| state.viewport_size) else { + return false; + }; + self.scroll_view(key, size, lines) + } + + /// Scroll by one last-rendered page in `direction` (`1` older, `-1` tail). + pub fn scroll_page(&mut self, key: TerminalViewKey, direction: i32) -> bool { + if direction == 0 { + return false; + } + let Some(size) = self.views.get(&key).and_then(|state| state.viewport_size) else { + return false; + }; + let rows = i32::try_from(size.rows).unwrap_or(i32::MAX); + self.scroll_view(key, size, rows.saturating_mul(direction.signum())) + } + + /// Return fresh geometric status for one registered view. + #[must_use] + pub fn view_status(&mut self, key: TerminalViewKey) -> Option { + let projection = self.sessions.get(&key.buffer_id)?.screen.projection(); + let state = self.views.get_mut(&key)?; + normalize_state(state, &projection); + let rows = retained_rows(&projection); + let size = state.viewport_size?; + let geometry = view_geometry(&rows, state, size.rows); + Some(TerminalViewStatus { + at_bottom: geometry.scroll_offset == 0, + scroll_offset: geometry.scroll_offset, + selection: state.selection.is_some(), + }) + } + + /// Clear selection and resume live-tail following for one view. + pub fn scroll_to_bottom(&mut self, key: TerminalViewKey) -> bool { + let Some(state) = self.views.get_mut(&key) else { + return false; + }; + let changed = state.top.is_some() || state.selection.is_some() || state.drag.is_some(); + state.top = None; + state.selection = None; + state.drag = None; + state.selection_froze_top = false; + changed + } + + /// Serialize one view's current selection from retained terminal rows. + #[must_use] + pub fn copy_selection(&mut self, key: TerminalViewKey) -> Option> { + let session = self.sessions.get(&key.buffer_id)?; + let projection = session.screen.projection(); + let state = self.views.get_mut(&key)?; + normalize_state(state, &projection); + let selection = state.selection?; + let rows = retained_rows(&projection); + copy_selection_bytes(&rows, selection) + } + + /// Start an editor-owned primary selection at a viewport coordinate. + pub fn begin_selection( + &mut self, + key: TerminalViewKey, + viewport_size: CellSize, + coord: CellCoord, + ) -> bool { + let Some(session) = self.sessions.get(&key.buffer_id) else { + return false; + }; + let projection = session.screen.projection(); + let bell_count = session.screen.bell_count(); + let state = self.views.entry(key).or_insert_with(|| TerminalViewState { + alternate_active: Some(projection.alternate_active), + last_bell_count: bell_count, + ..TerminalViewState::default() + }); + normalize_state(state, &projection); + let rows = retained_rows(&projection); + let geometry = view_geometry(&rows, state, viewport_size.rows); + let Some(anchor) = anchor_at(&rows, &geometry, viewport_size, coord) else { + state.selection = None; + state.drag = None; + return false; + }; + state.selection_froze_top = state.top.is_none(); + if state.top.is_none() { + state.top = rows + .get(geometry.start) + .copied() + .map(row_lead); + } + state.selection = Some(TerminalSelection { + anchor, + head: anchor, + }); + state.drag = Some(anchor); + true + } + + /// Move an active editor-owned terminal selection. + pub fn update_selection( + &mut self, + key: TerminalViewKey, + viewport_size: CellSize, + coord: CellCoord, + ) -> bool { + let Some(session) = self.sessions.get(&key.buffer_id) else { + return false; + }; + let projection = session.screen.projection(); + let Some(state) = self.views.get_mut(&key) else { + return false; + }; + normalize_state(state, &projection); + if state.drag.is_none() { + return false; + } + let rows = retained_rows(&projection); + let geometry = view_geometry(&rows, state, viewport_size.rows); + let Some(head) = anchor_at(&rows, &geometry, viewport_size, coord) else { + return false; + }; + let changed = state.selection.is_some_and(|selection| selection.head != head); + if let Some(selection) = state.selection.as_mut() { + selection.head = head; + } + state.drag = Some(head); + changed + } + + /// Finish an editor-owned terminal selection. + pub fn finish_selection( + &mut self, + key: TerminalViewKey, + viewport_size: CellSize, + coord: CellCoord, + ) -> bool { + let moved = self.update_selection(key, viewport_size, coord); + let Some(state) = self.views.get_mut(&key) else { + return false; + }; + let was_dragging = state.drag.take().is_some(); + if state + .selection + .is_some_and(|selection| selection.anchor == selection.head) + { + state.selection = None; + if state.selection_froze_top { + state.top = None; + } + state.selection_froze_top = false; + } + moved || was_dragging + } + + /// Clear one view's terminal selection without changing its scroll anchor. + pub fn clear_selection(&mut self, key: TerminalViewKey) -> bool { + let Some(state) = self.views.get_mut(&key) else { + return false; + }; + let changed = state.selection.take().is_some() || state.drag.take().is_some(); + changed + } + + /// Current child input modes for one session. + #[must_use] + pub fn modes_for_view(&self, key: TerminalViewKey) -> Option { + self.sessions + .get(&key.buffer_id) + .map(|session| session.screen.modes()) + } + + /// Exact controlled view for one frontend, if it still exists. + #[must_use] + pub fn controller_view_for_frontend( + &self, + frontend_id: FrontendId, + ) -> Option { + self.controllers.iter().find_map(|(buffer_id, controller)| { + (controller.frontend_id == frontend_id) + .then(|| TerminalViewKey::new(frontend_id, controller.window_id, *buffer_id)) + }) + } + + /// Observe BEL counters for every live view on one frontend. + /// + /// Returns `true` only for a new bell in `active`; all other counters are + /// advanced so historical bells cannot replay after a later activation. + pub fn take_bell_for_frontend( + &mut self, + frontend_id: FrontendId, + active: Option, + ) -> bool { + let mut ring = false; + for (key, state) in &mut self.views { + if key.frontend_id != frontend_id { + continue; + } + let Some(session) = self.sessions.get(&key.buffer_id) else { + continue; + }; + let current = session.screen.bell_count(); + if Some(*key) == active && current > state.last_bell_count { + ring = true; + } + state.last_bell_count = current; + } + ring + } +} + +#[derive(Clone, Copy)] +struct ResolvedCell { + row: usize, + col: usize, +} + +struct ViewGeometry { + start: usize, + top_padding: usize, + scroll_offset: u32, +} + +fn valid_viewport(size: CellSize) -> bool { + size.rows > 0 + && size.cols > 0 + && size.rows <= u32::from(MAX_TERMINAL_ROWS) + && size.cols <= u32::from(MAX_TERMINAL_COLS) + && size.area() as usize <= MAX_TERMINAL_VISIBLE_CELLS +} + +fn retained_rows(projection: &ScreenProjection) -> Vec<&TerminalRow> { + projection + .history + .iter() + .chain(projection.visible_rows.iter()) + .collect() +} + +fn row_lead(row: &TerminalRow) -> LogicalCellAnchor { + LogicalCellAnchor { + logical_line_id: row.logical_line_id, + cell_offset: row.cell_offset, + } +} + +fn resolve_anchor(rows: &[&TerminalRow], anchor: LogicalCellAnchor) -> Option { + rows.iter().enumerate().find_map(|(row_index, row)| { + if row.logical_line_id != anchor.logical_line_id { + return None; + } + let start = row.cell_offset; + let end = start.saturating_add(row.cells.len() as u32); + if anchor.cell_offset < start || anchor.cell_offset >= end { + return None; + } + let col = (anchor.cell_offset - start) as usize; + Some(ResolvedCell { + row: row_index, + col: canonical_col(row, col), + }) + }) +} + +fn canonical_col(row: &TerminalRow, mut col: usize) -> usize { + col = col.min(row.cells.len().saturating_sub(1)); + while col > 0 && matches!(row.cells[col].glyph, Glyph::Continuation) { + col -= 1; + } + col +} + +fn anchor_for(rows: &[&TerminalRow], resolved: ResolvedCell) -> LogicalCellAnchor { + let row = rows[resolved.row]; + LogicalCellAnchor { + logical_line_id: row.logical_line_id, + cell_offset: row.cell_offset.saturating_add(resolved.col as u32), + } +} + +fn clamp_or_clear( + rows: &[&TerminalRow], + anchor: LogicalCellAnchor, +) -> Option { + if let Some(resolved) = resolve_anchor(rows, anchor) { + return Some(anchor_for(rows, resolved)); + } + let first = rows.first()?; + (anchor.logical_line_id < first.logical_line_id).then(|| row_lead(first)) +} + +fn normalize_state(state: &mut TerminalViewState, projection: &ScreenProjection) { + if state + .alternate_active + .is_some_and(|active| active != projection.alternate_active) + { + state.top = None; + state.selection = None; + state.drag = None; + state.selection_froze_top = false; + } + state.alternate_active = Some(projection.alternate_active); + let rows = retained_rows(projection); + state.top = state.top.and_then(|anchor| clamp_or_clear(&rows, anchor)); + state.selection = state.selection.and_then(|selection| { + let anchor = clamp_or_clear(&rows, selection.anchor)?; + let head = clamp_or_clear(&rows, selection.head)?; + let collapsed_by_clamp = + anchor == head && (anchor != selection.anchor || head != selection.head); + (!collapsed_by_clamp).then_some(TerminalSelection { anchor, head }) + }); + state.drag = state.drag.and_then(|anchor| clamp_or_clear(&rows, anchor)); + if state.selection.is_none() { + state.drag = None; + state.selection_froze_top = false; + } +} + +fn view_geometry( + rows: &[&TerminalRow], + state: &TerminalViewState, + viewport_rows: u32, +) -> ViewGeometry { + let viewport_rows = viewport_rows as usize; + let follow = state.top.is_none() && state.selection.is_none(); + let tail_start = rows.len().saturating_sub(viewport_rows); + let start = if follow { + tail_start + } else { + state + .top + .and_then(|anchor| resolve_anchor(rows, anchor)) + .map_or(tail_start, |resolved| resolved.row) + }; + let top_padding = if follow && rows.len() < viewport_rows { + viewport_rows - rows.len() + } else { + 0 + }; + let rows_after_view = rows.len().saturating_sub(start.saturating_add(viewport_rows)); + ViewGeometry { + start, + top_padding, + scroll_offset: u32::try_from(rows_after_view).unwrap_or(u32::MAX), + } +} + +fn viewport_row( + geometry: &ViewGeometry, + viewport_rows: usize, + retained_row: usize, +) -> Option { + if retained_row < geometry.start { + return None; + } + let row = geometry + .top_padding + .saturating_add(retained_row - geometry.start); + (row < viewport_rows).then_some(row) +} + +fn anchor_at( + rows: &[&TerminalRow], + geometry: &ViewGeometry, + viewport_size: CellSize, + coord: CellCoord, +) -> Option { + if coord.row >= viewport_size.rows || coord.col >= viewport_size.cols { + return None; + } + let viewport_row = coord.row as usize; + if viewport_row < geometry.top_padding { + return None; + } + let retained_row = geometry + .start + .saturating_add(viewport_row - geometry.top_padding); + let row = *rows.get(retained_row)?; + if coord.col as usize >= row.cells.len() { + return None; + } + let col = canonical_col(row, coord.col as usize); + Some(LogicalCellAnchor { + logical_line_id: row.logical_line_id, + cell_offset: row.cell_offset.saturating_add(col as u32), + }) +} + +fn normalized_selection( + rows: &[&TerminalRow], + selection: TerminalSelection, +) -> Option<(ResolvedCell, ResolvedCell)> { + let mut start = resolve_anchor(rows, selection.anchor)?; + let mut end = resolve_anchor(rows, selection.head)?; + if (start.row, start.col) > (end.row, end.col) { + std::mem::swap(&mut start, &mut end); + } + Some((start, end)) +} + +fn glyph_width(row: &TerminalRow, col: usize) -> usize { + if col + 1 < row.cells.len() && matches!(row.cells[col + 1].glyph, Glyph::Continuation) { + 2 + } else { + 1 + } +} + +fn project_snapshot( + buffer_id: BufferId, + viewport_size: CellSize, + projection: &ScreenProjection, + state: &TerminalViewState, + pid: u32, + process: crate::terminal::session::TerminalProcessState, +) -> TerminalSnapshot { + let rows = retained_rows(projection); + let geometry = view_geometry(&rows, state, viewport_size.rows); + let mut cells = vec![Cell::default(); viewport_size.area() as usize]; + for retained_row in geometry.start..rows.len() { + let Some(target_row) = + viewport_row(&geometry, viewport_size.rows as usize, retained_row) + else { + continue; + }; + let row = rows[retained_row]; + let copy_cols = row.cells.len().min(viewport_size.cols as usize); + let target = target_row * viewport_size.cols as usize; + cells[target..target + copy_cols].clone_from_slice(&row.cells[..copy_cols]); + } + + let selection = state + .selection + .and_then(|selection| normalized_selection(&rows, selection)) + .map_or_else(Vec::new, |(start, end)| { + let mut spans = Vec::new(); + for retained_row in start.row..=end.row { + let Some(target_row) = + viewport_row(&geometry, viewport_size.rows as usize, retained_row) + else { + continue; + }; + let row = rows[retained_row]; + let start_col = if retained_row == start.row { + start.col + } else { + 0 + }; + let end_col = if retained_row == end.row { + end.col.saturating_add(glyph_width(row, end.col)) + } else { + row.cells.len() + } + .min(viewport_size.cols as usize); + if start_col < end_col && start_col < viewport_size.cols as usize { + spans.push(TerminalSelectionSpan { + row: target_row as u32, + start_col: start_col as u32, + end_col: end_col as u32, + }); + } + } + spans + }); + + let cursor = if geometry.scroll_offset == 0 { + projection.cursor.and_then(|cursor| { + let retained_row = projection + .history + .len() + .saturating_add(cursor.row as usize); + let row = viewport_row(&geometry, viewport_size.rows as usize, retained_row)?; + (cursor.col < viewport_size.cols).then(|| CellCoord::new(row as u32, cursor.col)) + }) + } else { + None + }; + + TerminalSnapshot { + buffer_id, + size: viewport_size, + cells, + cursor, + title: projection.title.clone(), + screen_generation: projection.generation, + selection, + scroll_offset: geometry.scroll_offset, + at_bottom: geometry.scroll_offset == 0, + pid, + process, + } +} + +fn is_default_blank(cell: &Cell) -> bool { + matches!(cell.glyph, Glyph::Char(' ')) + && cell.style == Style::default() + && cell.attachment.is_none() +} + +fn copy_selection_bytes( + rows: &[&TerminalRow], + selection: TerminalSelection, +) -> Option> { + let (start, end) = normalized_selection(rows, selection)?; + let mut out = Vec::new(); + for row_index in start.row..=end.row { + let row = rows[row_index]; + let from = if row_index == start.row { start.col } else { 0 }; + let mut to = if row_index == end.row { + end.col.saturating_add(glyph_width(row, end.col)) + } else { + row.cells.len() + }; + while to > from && is_default_blank(&row.cells[to - 1]) { + to -= 1; + } + for cell in &row.cells[from..to] { + match &cell.glyph { + Glyph::Char(ch) => { + let mut bytes = [0; 4]; + out.extend_from_slice(ch.encode_utf8(&mut bytes).as_bytes()); + } + Glyph::Cluster(bytes) => out.extend_from_slice(bytes), + Glyph::Continuation => {} + } + } + if row_index < end.row && !row.soft_wrapped { + out.push(b'\n'); + } + } + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::terminal::session::TerminalProcessState; + + fn row(id: u64, offset: u32, text: &str, soft_wrapped: bool) -> TerminalRow { + TerminalRow { + cells: text + .chars() + .map(|ch| Cell { + glyph: Glyph::Char(ch), + style: Style::default(), + attachment: None, + }) + .collect(), + logical_line_id: id, + cell_offset: offset, + soft_wrapped, + } + } + + fn projection(history: Vec, visible_rows: Vec) -> ScreenProjection { + let cols = visible_rows + .first() + .map_or(1, |row| row.cells.len() as u32); + ScreenProjection { + size: CellSize::new(visible_rows.len() as u32, cols), + alternate_active: false, + history, + visible_rows, + cursor: None, + title: Some("shell".into()), + generation: 7, + } + } + + #[test] + fn tail_projection_pads_above_and_right_and_translates_cursor() { + let mut source = projection(Vec::new(), vec![row(1, 0, "abc", false), row(2, 0, "def", false)]); + source.cursor = Some(CellCoord::new(1, 2)); + let snapshot = project_snapshot( + BufferId::next(), + CellSize::new(4, 5), + &source, + &TerminalViewState::default(), + 42, + TerminalProcessState::Running, + ); + assert_eq!(snapshot.cells.len(), 20); + assert!(snapshot.cells[..10].iter().all(|cell| *cell == Cell::default())); + assert_eq!(snapshot.cells[10].glyph, Glyph::Char('a')); + assert_eq!(snapshot.cells[13], Cell::default()); + assert_eq!(snapshot.cells[15].glyph, Glyph::Char('d')); + assert_eq!(snapshot.cursor, Some(CellCoord::new(3, 2))); + assert!(snapshot.at_bottom); + assert_eq!(snapshot.scroll_offset, 0); + } + + #[test] + fn frozen_top_is_geometrically_at_bottom_when_view_still_reaches_tail() { + let source = projection( + vec![row(1, 0, "aaa", false)], + vec![row(2, 0, "bbb", false), row(3, 0, "ccc", false)], + ); + let state = TerminalViewState { + top: Some(LogicalCellAnchor { + logical_line_id: 1, + cell_offset: 0, + }), + selection: Some(TerminalSelection { + anchor: LogicalCellAnchor { + logical_line_id: 2, + cell_offset: 0, + }, + head: LogicalCellAnchor { + logical_line_id: 2, + cell_offset: 1, + }, + }), + ..TerminalViewState::default() + }; + let snapshot = project_snapshot( + BufferId::next(), + CellSize::new(3, 3), + &source, + &state, + 1, + TerminalProcessState::Running, + ); + assert!(snapshot.at_bottom); + assert_eq!(snapshot.scroll_offset, 0); + } + + #[test] + fn copy_joins_soft_wraps_trims_default_blanks_and_separates_hard_rows() { + let rows = vec![ + row(1, 0, "ab ", true), + row(1, 3, "cd ", false), + row(2, 0, "e ", false), + ]; + let refs: Vec<&TerminalRow> = rows.iter().collect(); + let bytes = copy_selection_bytes( + &refs, + TerminalSelection { + anchor: LogicalCellAnchor { + logical_line_id: 1, + cell_offset: 0, + }, + head: LogicalCellAnchor { + logical_line_id: 2, + cell_offset: 2, + }, + }, + ) + .expect("selection resolves"); + assert_eq!(bytes, b"abcd\ne"); + } + + #[test] + fn wide_continuation_canonicalizes_to_lead_and_copies_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 refs = vec![&wide]; + let continuation = resolve_anchor( + &refs, + LogicalCellAnchor { + logical_line_id: 9, + cell_offset: 1, + }, + ) + .expect("continuation resolves"); + assert_eq!(continuation.col, 0); + let bytes = copy_selection_bytes( + &refs, + TerminalSelection { + anchor: LogicalCellAnchor { + logical_line_id: 9, + cell_offset: 0, + }, + head: LogicalCellAnchor { + logical_line_id: 9, + cell_offset: 1, + }, + }, + ) + .expect("wide selection resolves"); + assert_eq!(bytes, "界".as_bytes()); + } + + #[test] + fn alternate_switch_clears_view_anchors_and_selection() { + let source = ScreenProjection { + size: CellSize::new(1, 3), + alternate_active: true, + history: Vec::new(), + visible_rows: vec![row(10, 0, "alt", false)], + cursor: None, + title: None, + generation: 2, + }; + let mut state = TerminalViewState { + top: Some(LogicalCellAnchor { + logical_line_id: 1, + cell_offset: 0, + }), + selection: Some(TerminalSelection { + anchor: LogicalCellAnchor { + logical_line_id: 1, + cell_offset: 0, + }, + head: LogicalCellAnchor { + logical_line_id: 1, + cell_offset: 1, + }, + }), + alternate_active: Some(false), + ..TerminalViewState::default() + }; + normalize_state(&mut state, &source); + assert_eq!(state.top, None); + assert_eq!(state.selection, None); + assert_eq!(state.alternate_active, Some(true)); + } +} From dc9225778a90574399006ba1e17197198302f2c0 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 21 Jul 2026 20:16:23 -0400 Subject: [PATCH 07/15] test(vterm): prove Stage 2 TUI integration Add cross-surface Lua, shared-view, clipboard, authenticated routing, BEL, resize, and real-host PTY acceptance. Ensure terminal-local keymaps run before raw child transport and document the criterion-to-test map. Co-Authored-By: Claude --- docs/roadmap-2026-07.md | 15 +- docs/vterm-framing.md | 46 ++- src/daemon.rs | 131 +++++++- src/editor.rs | 143 ++++----- src/instance_render.rs | 8 +- src/lua_bindings/mod.rs | 37 ++- src/semantic_render.rs | 7 +- src/terminal/view.rs | 59 ++-- tests/common/pty.rs | 41 ++- tests/vterm_stage2_acceptance.rs | 508 +++++++++++++++++++++++++++++++ 10 files changed, 819 insertions(+), 176 deletions(-) create mode 100644 tests/vterm_stage2_acceptance.rs diff --git a/docs/roadmap-2026-07.md b/docs/roadmap-2026-07.md index 72f5e12..8c326f2 100644 --- a/docs/roadmap-2026-07.md +++ b/docs/roadmap-2026-07.md @@ -88,21 +88,18 @@ protocol v17; and #125 added composable `pmacs.statusline` providers, per-window TUI composition, a pure built-in LSP segment, dynamic modeline faces, and semantic/GPU transport through protocol v18. -### Arc 5 — Terminal, staged — VTERM STAGE 1 ON FEATURE BRANCH +### Arc 5 — Terminal, staged — VTERM STAGE 2 ON FEATURE BRANCH - **Compile-mode landed** in #113: line-oriented PTY/ANSI output, error-regex navigation, and `M-x compile`. -- **Vterm Stage 1 terminal core** is implemented, two review rounds are - addressed, and the branch is fully gated on `vterm-core`; PR #126 awaits - merge authorization and is **not merged**. It adds compatibility parser +- **Vterm Stage 1 terminal core landed** in #126: compatibility parser profiles, bounded VT screen/scrollback/reflow state, IND/NEL/RI, input encoders, internal `TerminalManager`, read-only identity buffers, process lifecycle, renderer-safe control-free cells, and headless real-PTY - acceptance. It intentionally adds no interactive Lua command or frontend - rendering. -- **Vterm Stage 2 TUI** starts only after Stage 1 merges: terminal-window - composition, input/resize, per-context scroll/selection/copy, and the Lua - surface. + acceptance. +- **Vterm Stage 2 TUI** is implemented on `vterm-tui`: terminal-window + composition, input/resize, per-context scroll/selection/copy, authenticated + frontend ownership, BEL/clipboard drainage, and the strict Lua surface. - **Vterm Stage 3 protocol/GPU** starts only after Stage 2 merges: additive protocol v19 complete frames, authenticated daemon routing, and native GPU cell rendering. Its framing must resolve the current 16 MiB transport cap's diff --git a/docs/vterm-framing.md b/docs/vterm-framing.md index 4c676c3..ffe16ec 100644 --- a/docs/vterm-framing.md +++ b/docs/vterm-framing.md @@ -1,8 +1,8 @@ # Vterm — framing (Arc 5 stage 2, three-PR delivery) **Revision 7 — 2026-07-21. Status: Stage 1 landed on `main` as PR #126 -at merge `643d1e1`; Stages 2 and 3 are not implemented. Stage 2 framing review -is complete and the contract is ready for implementation.** +at merge `643d1e1`; Stage 2 is implemented on branch `vterm-tui` and Stage 3 +is not implemented.** Revision 7 closes the final three precision findings: `at_bottom` is geometric and distinct from live-tail following; the fixed `C-c` transport escape @@ -1134,6 +1134,48 @@ base-branch deletion/auto-close risk and makes each PR's gate evidence honest. exercises key/paste, resize, scroll/select/copy, BEL, and clean exit, then proves host raw/alternate-screen state is restored. +#### Stage 2 verification map + +The cross-surface suite is `tests/vterm_stage2_acceptance.rs`; focused unit +coverage remains beside the owning implementation. The criteria map as follows: + +- **15:** `lua_surface_is_strict_fresh_transactional_and_context_safe`. +- **16:** `editor::tests::terminal_snapshot_composes_only_content_and_translates_cursor` + plus the real-TUI smoke. +- **17:** `terminal::view::tests::{tail_projection_pads_above_and_right_and_translates_cursor, + frozen_top_is_geometrically_at_bottom_when_view_still_reaches_tail, + alternate_switch_clears_view_anchors_and_selection}` and + `shared_screen_keeps_view_scroll_selection_and_controller_independent`. +- **18:** `terminal::input::tests::{utf8_ctrl_and_alt_boundaries, + application_cursor_and_xterm_modifiers, ambiguous_digits_ignore_application_keypad, + paste_and_focus_are_exact, unsupported_keys_are_invisible}` and the real-TUI + input/paste path. +- **19:** `editor::tests::dispatch_prefix_state_is_independent_per_frontend`, + `lua_surface_is_strict_fresh_transactional_and_context_safe`, and the + real-TUI terminal-escape/quit path. +- **20:** `terminal::input::tests::sgr_mouse_modes_modifiers_and_coordinates` + plus the real-TUI editor-owned scroll/drag/copy path. +- **21:** `terminal::view::tests::{copy_joins_soft_wraps_trims_default_blanks_and_separates_hard_rows, + wide_continuation_canonicalizes_to_lead_and_copies_once}`, + `lua_surface_is_strict_fresh_transactional_and_context_safe`, and the real + OSC 52 clipboard assertion. +- **22:** `lua_surface_is_strict_fresh_transactional_and_context_safe`, + `shared_screen_keeps_view_scroll_selection_and_controller_independent`, and + the real child-PTY resize assertion. +- **23:** `daemon::tests::forged_resize_mutates_only_the_authenticated_frontend`, + `daemon::tests::inbound_paste_uses_authenticated_source_not_the_claimed_id`, + the existing `m5_4_dispatch_{key,mouse}_threads_frontend_id_to_lua_surface` + tests, and the multi-frontend detach assertions in + `shared_screen_keeps_view_scroll_selection_and_controller_independent`. +- **24:** the built-in-provider and clipboard assertions in + `lua_surface_is_strict_fresh_transactional_and_context_safe`, + `daemon::tests::terminal_bell_baseline_suppresses_history_and_delivers_each_new_bell_once`, + and the real-TUI BEL/OSC 52 assertions. +- **25:** `shared_screen_keeps_view_scroll_selection_and_controller_independent`. +- **26:** both in-process acceptance tests' termination/cleanup assertions and + the real-TUI clean-exit/host-restoration assertions. +- **27:** `real_tui_terminal_smoke_restores_host_after_output_input_resize_scroll_copy_and_bell`. + ### Stage 3 — GPU/protocol 28. Protocol v19 appends all new variants after v18 pins; v18 grid traffic diff --git a/src/daemon.rs b/src/daemon.rs index 0fb8cee..e47bff2 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -871,10 +871,8 @@ fn dispatcher_loop( let mut last_active_buffer_sent: HashMap = HashMap::new(); // Active-terminal BEL delivery baseline. Switching away forgets the // terminal so historical bells are never replayed on later activation. - let mut terminal_bell_baselines: HashMap< - FrontendId, - (crate::buffer::BufferId, u64), - > = HashMap::new(); + let mut terminal_bell_baselines: HashMap = + HashMap::new(); let mut session_registry = SessionRegistry::new(); // T M10.11 Q8 — jitter PRNG, seeded once so the // convergence-under-jitter scenario is deterministically @@ -1077,12 +1075,7 @@ fn dispatcher_loop( let render_state = render_states .get_mut(fid) .expect("render_state present for attached grid fid"); - render_state.render_frame( - editor, - *fid, - &terminal_snapshots, - &other_presences, - ) + render_state.render_frame(editor, *fid, &terminal_snapshots, &other_presences) }; // T M10.6 per-frontend presence sweep. The snapshot is @@ -1537,10 +1530,7 @@ fn handle_dispatcher_event( term_sizes: &mut HashMap, last_dispatch_idle_sent: &mut HashMap, last_active_buffer_sent: &mut HashMap, - terminal_bell_baselines: &mut HashMap< - FrontendId, - (crate::buffer::BufferId, u64), - >, + terminal_bell_baselines: &mut HashMap, session_registry: &mut SessionRegistry, ) { match event { @@ -3071,6 +3061,119 @@ mod tests { ); } + #[test] + fn forged_resize_mutates_only_the_authenticated_frontend() { + let source = FrontendId(41); + let forged = FrontendId(42); + let old_size = CellSize::new(24, 80); + let new_size = CellSize::new(31, 97); + let mut editor = EditorState::new(); + let mut render_states = HashMap::from([ + (source, RenderState::new(old_size)), + (forged, RenderState::new(old_size)), + ]); + let mut semantic_states: HashMap = + HashMap::new(); + let mut streams: HashMap = HashMap::new(); + let mut term_sizes = HashMap::from([(source, old_size), (forged, old_size)]); + let mut last_dispatch_idle_sent = HashMap::new(); + let mut last_active_buffer_sent = HashMap::new(); + let mut terminal_bell_baselines = HashMap::new(); + let mut session_registry = SessionRegistry::new(); + + handle_dispatcher_event( + DispatcherEvent::FrontendEvent { + source, + event: FrontendEvent::Resize { + frontend_id: forged, + size: new_size, + }, + }, + &mut editor, + &mut render_states, + &mut semantic_states, + &mut streams, + &mut term_sizes, + &mut last_dispatch_idle_sent, + &mut last_active_buffer_sent, + &mut terminal_bell_baselines, + &mut session_registry, + ); + + assert_eq!(render_states[&source].size(), new_size); + assert_eq!(term_sizes[&source], new_size); + assert_eq!(render_states[&forged].size(), old_size); + assert_eq!(term_sizes[&forged], old_size); + } + #[test] + fn terminal_bell_baseline_suppresses_history_and_delivers_each_new_bell_once() { + let mut editor = EditorState::new(); + let mut spec = crate::terminal::TerminalSpec::new("/bin/sh"); + spec.args = vec![ + "-c".into(), + "printf '\\a'; IFS= read -r _; printf '\\a'; sleep 30".into(), + ]; + let buffer_id = editor + .terminal_manager + .borrow_mut() + .open( + spec, + &mut editor.core.borrow_mut(), + &mut editor.process_supervisor.borrow_mut(), + ) + .expect("open bell probe"); + editor + .core + .borrow_mut() + .switch_active_buffer_for(FrontendId::LOCAL, buffer_id) + .expect("display bell probe"); + + let deadline = Instant::now() + Duration::from_secs(5); + while editor.terminal_manager.borrow().bell_count(buffer_id) != Some(1) { + editor.tick_processes(); + assert!(Instant::now() < deadline, "initial terminal bell timed out"); + thread::sleep(Duration::from_millis(10)); + } + let mut baselines = HashMap::new(); + assert!(!take_pending_terminal_bell( + &editor, + FrontendId::LOCAL, + &mut baselines + )); + + editor + .terminal_manager + .borrow() + .send( + buffer_id, + b"\n", + &mut editor.process_supervisor.borrow_mut(), + ) + .expect("advance bell probe"); + let deadline = Instant::now() + Duration::from_secs(5); + while editor.terminal_manager.borrow().bell_count(buffer_id) != Some(2) { + editor.tick_processes(); + assert!(Instant::now() < deadline, "second terminal bell timed out"); + thread::sleep(Duration::from_millis(10)); + } + assert!(take_pending_terminal_bell( + &editor, + FrontendId::LOCAL, + &mut baselines + )); + assert!(!take_pending_terminal_bell( + &editor, + FrontendId::LOCAL, + &mut baselines + )); + + editor + .terminal_manager + .borrow_mut() + .terminate(buffer_id, &mut editor.process_supervisor.borrow_mut()) + .expect("terminate bell probe"); + } + /// Kill ring Q#KR10a — the unified paste route trusts only the /// dispatcher's authenticated source. A forged payload id must not /// paste into another frontend's active window, and the paste diff --git a/src/editor.rs b/src/editor.rs index 8cd6984..7aa879e 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -39,9 +39,9 @@ use crate::protocol::{ MouseKind as TerminalMouseKind, }; use crate::terminal::TerminalSnapshot; +use crate::terminal::view::TerminalViewKey; use crate::view::{View, Viewport}; use crate::window::{Rect, WindowId}; -use crate::terminal::view::TerminalViewKey; /// Ephemeral authenticated origin for one interactive command invocation. /// @@ -300,11 +300,9 @@ impl EditorState { // shutdown enforces no-zombie cleanup at editor exit. let process_supervisor = crate::lua_bindings::make_process_supervisor(lua_host.lua()) .expect("install pmacs.process"); - let terminal_manager = crate::lua_bindings::make_terminal_manager( - lua_host.lua(), - process_supervisor.clone(), - ) - .expect("install pmacs.terminal"); + let terminal_manager = + crate::lua_bindings::make_terminal_manager(lua_host.lua(), process_supervisor.clone()) + .expect("install pmacs.terminal"); lua_host .eval( Some("@pmacs/builtin/runtime/terminal.lua"), @@ -726,9 +724,7 @@ impl EditorState { if self .dispatchers .get(&frontend_id) - .is_some_and(|state| { - state.terminal_escape || !state.dispatcher.pending().is_empty() - }) + .is_some_and(|state| state.terminal_escape || !state.dispatcher.pending().is_empty()) { return false; } @@ -739,9 +735,7 @@ impl EditorState { && !core.menu_is_open() && core .active_window_for(frontend_id) - .is_some_and(|window| { - !core.active_buffer_round_trips_for(window.buffer_id) - }) + .is_some_and(|window| !core.active_buffer_round_trips_for(window.buffer_id)) } /// Local-frontend compatibility wrapper. @@ -827,6 +821,17 @@ impl EditorState { .dispatchers .get(&frontend_id) .is_some_and(|state| state.terminal_escape); + let terminal_local_binding = terminal_key.is_some_and(|view_key| { + chord.is_some_and(|chord| { + let stack = self.lua_host.keymaps().borrow(); + stack.buffers.get(&view_key.buffer_id).is_some_and(|map| { + !matches!( + map.lookup(&[chord]), + crate::keymap_tree::Resolution::Unbound + ) + }) + }) + }); if let Some(view_key) = terminal_key { if escaped { self.dispatchers @@ -847,21 +852,23 @@ impl EditorState { self.claim_terminal_controller(view_key); return; } - let Some((terminal_key, modifiers)) = terminal_key_from_crossterm(key) else { + if !terminal_local_binding { + let Some((terminal_key, modifiers)) = terminal_key_from_crossterm(key) else { + return; + }; + let modes = self + .terminal_manager + .borrow() + .modes_for_view(view_key) + .unwrap_or_default(); + if let Some(bytes) = + crate::terminal::input::encode_key(terminal_key, modifiers, modes) + { + self.claim_terminal_controller(view_key); + self.send_terminal_bytes(view_key.buffer_id, &bytes); + } return; - }; - let modes = self - .terminal_manager - .borrow() - .modes_for_view(view_key) - .unwrap_or_default(); - if let Some(bytes) = - crate::terminal::input::encode_key(terminal_key, modifiers, modes) - { - self.claim_terminal_controller(view_key); - self.send_terminal_bytes(view_key.buffer_id, &bytes); } - return; } } @@ -1005,9 +1012,7 @@ impl EditorState { .modes_for_view(key) .unwrap_or_default(); self.claim_terminal_controller(key); - if let Some(bytes) = - crate::terminal::input::encode_focus(true, modes.focus_reporting) - { + if let Some(bytes) = crate::terminal::input::encode_focus(true, modes.focus_reporting) { self.send_terminal_bytes(key.buffer_id, &bytes); } return; @@ -1025,9 +1030,7 @@ impl EditorState { .borrow() .modes_for_view(key) .unwrap_or_default(); - if let Some(bytes) = - crate::terminal::input::encode_focus(false, modes.focus_reporting) - { + if let Some(bytes) = crate::terminal::input::encode_focus(false, modes.focus_reporting) { self.send_terminal_bytes(key.buffer_id, &bytes); } let _ = self.terminal_manager.borrow_mut().release_controller(key); @@ -1036,11 +1039,7 @@ impl EditorState { /// Resize the one session durably controlled by `frontend_id`. /// /// This is called before process drain and paint, never from rendering. - pub fn sync_terminal_layout( - &mut self, - frontend_id: FrontendId, - term_size: CellSize, - ) -> bool { + pub fn sync_terminal_layout(&mut self, frontend_id: FrontendId, term_size: CellSize) -> bool { let Some(key) = self .terminal_manager .borrow() @@ -1063,8 +1062,9 @@ impl EditorState { let _ = self.terminal_manager.borrow_mut().release_controller(key); return false; } - let Some(placement) = - window_placements(&core, frontend_id, term_size).get(&key.window_id).copied() + let Some(placement) = window_placements(&core, frontend_id, term_size) + .get(&key.window_id) + .copied() else { return false; }; @@ -1119,8 +1119,7 @@ impl EditorState { if placement.content.size.rows == 0 || placement.content.size.cols == 0 { continue; } - let key = - TerminalViewKey::new(frontend_id, window_id, window.buffer_id); + let key = TerminalViewKey::new(frontend_id, window_id, window.buffer_id); if self.terminal_manager.borrow().is_terminal(window.buffer_id) { live.insert(key); sizes.push((key, placement.content.size)); @@ -1395,9 +1394,7 @@ impl EditorState { // rotate the boundary so a menu Cut chains like a keybound // one. The invoke below bypasses dispatch_key, which would // otherwise leave the boundary stale. - self.core - .borrow_mut() - .rotate_command(frontend_id, &command); + self.core.borrow_mut().rotate_command(frontend_id, &command); // Q#KR10b: menu invocation bypasses dispatch_key's // revision check — a menu Cut's edit must still fire // `buffer.after-edit`. @@ -1602,9 +1599,13 @@ impl EditorState { return; } - let Some((win_id, rect)) = - window_at_cell(&self.core.borrow(), frontend_id, term_size, cell_row, cell_col) - else { + let Some((win_id, rect)) = window_at_cell( + &self.core.borrow(), + frontend_id, + term_size, + cell_row, + cell_col, + ) else { return; }; let inner_rows = rect.size.rows.saturating_sub(1); @@ -1616,10 +1617,7 @@ impl EditorState { self.mouse_click = None; return; } - let local = CellCoord::new( - local_row, - cell_col.saturating_sub(rect.origin.col), - ); + let local = CellCoord::new(local_row, cell_col.saturating_sub(rect.origin.col)); self.dispatch_terminal_mouse( TerminalViewKey::new(frontend_id, win_id, buffer_id), content_size, @@ -1746,8 +1744,7 @@ impl EditorState { && modes.mouse_sgr && coord.row < screen_size.rows && coord.col < screen_size.cols - && let Some(bytes) = - crate::terminal::input::encode_mouse(kind, coord, modifiers, modes) + && let Some(bytes) = crate::terminal::input::encode_mouse(kind, coord, modifiers, modes) { self.claim_terminal_controller(key); self.send_terminal_bytes(key.buffer_id, &bytes); @@ -2220,12 +2217,8 @@ pub fn run(file: Option) -> io::Result<()> { let size = frontend.size(); let _ = state.sync_terminal_layout(FrontendId::LOCAL, size); let terminal_snapshots = state.prepare_terminal_views(FrontendId::LOCAL, size); - let mut messages = render_state.render_frame( - &state, - FrontendId::LOCAL, - &terminal_snapshots, - &[], - ); + let mut messages = + render_state.render_frame(&state, FrontendId::LOCAL, &terminal_snapshots, &[]); messages.extend(state.take_local_signals()); frontend.present_messages(&messages)?; if state.core.borrow().quit { @@ -2745,14 +2738,7 @@ pub fn paint_frame( } drop(reg); - paint_status_line( - grid, - core, - &state.lua_host, - dispatcher, - term_size, - &theme, - ); + paint_status_line(grid, core, &state.lua_host, dispatcher, term_size, &theme); // An active isearch owns the bottom row (its prompt + match // readout), but the terminal cursor stays in the buffer at the @@ -3604,8 +3590,7 @@ fn is_terminal_escape_chord(chord: Chord) -> bool { } fn terminal_key_from_crossterm(key: KeyEvent) -> Option<(TerminalKey, TerminalModifiers)> { - let modifiers = - crate::protocol::crossterm_translate::mods_from_crossterm(key.modifiers); + let modifiers = crate::protocol::crossterm_translate::mods_from_crossterm(key.modifiers); let key = crate::protocol::crossterm_translate::keycode_from_crossterm(key.code); if matches!(key, TerminalKey::Unknown(_)) { return None; @@ -3749,13 +3734,7 @@ mod tests { stride: size.cols, size, }; - paint_frame( - &state, - FrontendId::LOCAL, - &snapshots, - &mut grid, - size, - ) + paint_frame(&state, FrontendId::LOCAL, &snapshots, &mut grid, size) }; assert_eq!(backing[0].glyph, crate::cell::Glyph::Char('T')); assert!(backing[0].style.reverse); @@ -4343,7 +4322,11 @@ mod tests { // window to the *buffer-list* buffer). let mut s = fresh_with(b""); s.dispatch_key(FrontendId::LOCAL, ctrl('x')); - assert_eq!(local_dispatcher(&s).pending().len(), 1, "C-x should start prefix"); + assert_eq!( + local_dispatcher(&s).pending().len(), + 1, + "C-x should start prefix" + ); s.dispatch_key(FrontendId::LOCAL, ctrl('b')); assert!( local_dispatcher(&s).pending().is_empty(), @@ -7656,7 +7639,13 @@ mod tests { stride: cols, size: crate::cell::CellSize::new(rows, cols), }; - let cursor = paint_frame(s, FrontendId::LOCAL, &HashMap::new(), &mut grid, crate::cell::CellSize::new(rows, cols)); + let cursor = paint_frame( + s, + FrontendId::LOCAL, + &HashMap::new(), + &mut grid, + crate::cell::CellSize::new(rows, cols), + ); (backing, cols, cursor) } diff --git a/src/instance_render.rs b/src/instance_render.rs index dc19f2d..3c8cf17 100644 --- a/src/instance_render.rs +++ b/src/instance_render.rs @@ -112,13 +112,7 @@ impl RenderState { stride: self.size.cols, size: self.size, }; - let coord = paint_frame( - state, - frontend_id, - terminal_snapshots, - &mut grid, - self.size, - ); + let coord = paint_frame(state, frontend_id, terminal_snapshots, &mut grid, self.size); // T M10.9 — overlay paint after main paint, before diff. // Modifies cells in `next`; diff captures the changes // as ordinary style updates. diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index cb25a00..d919811 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -54,8 +54,8 @@ use crate::buffer::{BufferId, EditOp, MarkGravity, MarkId}; use crate::buffer_registry::BufferRegistry; use crate::cell::{Color, Style, UnderlineStyle}; use crate::command::{Command, CommandError, CommandRegistry, SourceLocation}; -use crate::editor_core::EditorCore; use crate::editor::InteractiveCommandOrigin; +use crate::editor_core::EditorCore; use crate::highlight::SyntaxHighlightView; use crate::hook::{Hook, HookRegistry}; use crate::key::{display_sequence, parse_sequence}; @@ -8270,11 +8270,7 @@ fn install_terminal( let buffer_id = { let mut manager = manager.borrow_mut(); manager - .open( - spec, - &mut core.borrow_mut(), - &mut supervisor.borrow_mut(), - ) + .open(spec, &mut core.borrow_mut(), &mut supervisor.borrow_mut()) .map_err(mlua::Error::external)? }; let key = { @@ -8351,7 +8347,11 @@ fn install_terminal( lua.create_function(move |_, (buffer, bytes): (BufferIdLua, mlua::String)| { manager .borrow() - .send(buffer.0, bytes.as_bytes().as_ref(), &mut supervisor.borrow_mut()) + .send( + buffer.0, + bytes.as_bytes().as_ref(), + &mut supervisor.borrow_mut(), + ) .map_err(mlua::Error::external) })?, )?; @@ -8397,9 +8397,13 @@ fn install_terminal( terminal.set( "scroll", lua.create_function(move |lua, lines: i64| { - let lines = i32::try_from(lines).map_err(|_| { - mlua::Error::external("pmacs.terminal.scroll: `lines` exceeds i32 range") - })?; + let lines = i32::try_from(lines).unwrap_or_else(|_| { + if lines.is_negative() { + i32::MIN + } else { + i32::MAX + } + }); let core = terminal_shared_core(lua, "scroll")?; let key = active_terminal_view_key(lua, &core, "scroll")?; Ok(manager.borrow_mut().scroll_lines(key, lines)) @@ -8446,8 +8450,7 @@ fn install_terminal( let Some(bytes) = manager.borrow_mut().copy_selection(key) else { return Ok(false); }; - core.borrow_mut() - .clipboard_set_for(key.frontend_id, bytes); + core.borrow_mut().clipboard_set_for(key.frontend_id, bytes); Ok(true) })?, )?; @@ -8490,8 +8493,8 @@ fn parse_terminal_spec(table: Table) -> mlua::Result mlua: } } -fn strict_terminal_usize( - value: Value, - field: &'static str, - default: usize, -) -> mlua::Result { +fn strict_terminal_usize(value: Value, field: &'static str, default: usize) -> mlua::Result { match value { Value::Nil => Ok(default), Value::Integer(value) => usize::try_from(value).map_err(|_| { diff --git a/src/semantic_render.rs b/src/semantic_render.rs index 35b6389..2d3ec0a 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -3824,12 +3824,7 @@ mod tests { let buffer_id = active_buffer(&state); sem.set_viewport(buffer_id, ByteRange { start: 0, end: 80 }, 0); - let grid_msgs = grid.render_frame( - &state, - FrontendId::LOCAL, - &HashMap::new(), - &[], - ); + let grid_msgs = grid.render_frame(&state, FrontendId::LOCAL, &HashMap::new(), &[]); let sem_msgs = sem.render_frame(&state); assert!( matches!(grid_msgs[0], InstanceMessage::CellDelta { .. }), diff --git a/src/terminal/view.rs b/src/terminal/view.rs index d05ac71..fb3bedf 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -7,12 +7,8 @@ use crate::buffer::BufferId; use crate::cell::{Cell, CellCoord, CellSize, Glyph, Style}; use crate::protocol::FrontendId; use crate::terminal::screen::{ScreenProjection, TerminalModes, TerminalRow}; -use crate::terminal::session::{ - TerminalManager, TerminalSelectionSpan, TerminalSnapshot, -}; -use crate::terminal::{ - MAX_TERMINAL_COLS, MAX_TERMINAL_ROWS, MAX_TERMINAL_VISIBLE_CELLS, -}; +use crate::terminal::session::{TerminalManager, TerminalSelectionSpan, TerminalSnapshot}; +use crate::terminal::{MAX_TERMINAL_COLS, MAX_TERMINAL_ROWS, MAX_TERMINAL_VISIBLE_CELLS}; use crate::window::WindowId; /// One frontend/window projection of a terminal session. @@ -284,10 +280,7 @@ impl TerminalManager { }; state.selection_froze_top = state.top.is_none(); if state.top.is_none() { - state.top = rows - .get(geometry.start) - .copied() - .map(row_lead); + state.top = rows.get(geometry.start).copied().map(row_lead); } state.selection = Some(TerminalSelection { anchor, @@ -320,7 +313,9 @@ impl TerminalManager { let Some(head) = anchor_at(&rows, &geometry, viewport_size, coord) else { return false; }; - let changed = state.selection.is_some_and(|selection| selection.head != head); + let changed = state + .selection + .is_some_and(|selection| selection.head != head); if let Some(selection) = state.selection.as_mut() { selection.head = head; } @@ -372,10 +367,7 @@ impl TerminalManager { /// Exact controlled view for one frontend, if it still exists. #[must_use] - pub fn controller_view_for_frontend( - &self, - frontend_id: FrontendId, - ) -> Option { + pub fn controller_view_for_frontend(&self, frontend_id: FrontendId) -> Option { self.controllers.iter().find_map(|(buffer_id, controller)| { (controller.frontend_id == frontend_id) .then(|| TerminalViewKey::new(frontend_id, controller.window_id, *buffer_id)) @@ -478,10 +470,7 @@ fn anchor_for(rows: &[&TerminalRow], resolved: ResolvedCell) -> LogicalCellAncho } } -fn clamp_or_clear( - rows: &[&TerminalRow], - anchor: LogicalCellAnchor, -) -> Option { +fn clamp_or_clear(rows: &[&TerminalRow], anchor: LogicalCellAnchor) -> Option { if let Some(resolved) = resolve_anchor(rows, anchor) { return Some(anchor_for(rows, resolved)); } @@ -537,7 +526,9 @@ fn view_geometry( } else { 0 }; - let rows_after_view = rows.len().saturating_sub(start.saturating_add(viewport_rows)); + let rows_after_view = rows + .len() + .saturating_sub(start.saturating_add(viewport_rows)); ViewGeometry { start, top_padding, @@ -618,8 +609,7 @@ fn project_snapshot( let geometry = view_geometry(&rows, state, viewport_size.rows); let mut cells = vec![Cell::default(); viewport_size.area() as usize]; for retained_row in geometry.start..rows.len() { - let Some(target_row) = - viewport_row(&geometry, viewport_size.rows as usize, retained_row) + let Some(target_row) = viewport_row(&geometry, viewport_size.rows as usize, retained_row) else { continue; }; @@ -665,10 +655,7 @@ fn project_snapshot( let cursor = if geometry.scroll_offset == 0 { projection.cursor.and_then(|cursor| { - let retained_row = projection - .history - .len() - .saturating_add(cursor.row as usize); + let retained_row = projection.history.len().saturating_add(cursor.row as usize); let row = viewport_row(&geometry, viewport_size.rows as usize, retained_row)?; (cursor.col < viewport_size.cols).then(|| CellCoord::new(row as u32, cursor.col)) }) @@ -697,10 +684,7 @@ fn is_default_blank(cell: &Cell) -> bool { && cell.attachment.is_none() } -fn copy_selection_bytes( - rows: &[&TerminalRow], - selection: TerminalSelection, -) -> Option> { +fn copy_selection_bytes(rows: &[&TerminalRow], selection: TerminalSelection) -> Option> { let (start, end) = normalized_selection(rows, selection)?; let mut out = Vec::new(); for row_index in start.row..=end.row { @@ -753,9 +737,7 @@ mod tests { } fn projection(history: Vec, visible_rows: Vec) -> ScreenProjection { - let cols = visible_rows - .first() - .map_or(1, |row| row.cells.len() as u32); + let cols = visible_rows.first().map_or(1, |row| row.cells.len() as u32); ScreenProjection { size: CellSize::new(visible_rows.len() as u32, cols), alternate_active: false, @@ -769,7 +751,10 @@ mod tests { #[test] fn tail_projection_pads_above_and_right_and_translates_cursor() { - let mut source = projection(Vec::new(), vec![row(1, 0, "abc", false), row(2, 0, "def", false)]); + let mut source = projection( + Vec::new(), + vec![row(1, 0, "abc", false), row(2, 0, "def", false)], + ); source.cursor = Some(CellCoord::new(1, 2)); let snapshot = project_snapshot( BufferId::next(), @@ -780,7 +765,11 @@ mod tests { TerminalProcessState::Running, ); assert_eq!(snapshot.cells.len(), 20); - assert!(snapshot.cells[..10].iter().all(|cell| *cell == Cell::default())); + assert!( + snapshot.cells[..10] + .iter() + .all(|cell| *cell == Cell::default()) + ); assert_eq!(snapshot.cells[10].glyph, Glyph::Char('a')); assert_eq!(snapshot.cells[13], Cell::default()); assert_eq!(snapshot.cells[15].glyph, Glyph::Char('d')); diff --git a/tests/common/pty.rs b/tests/common/pty.rs index f500899..a1f16c8 100644 --- a/tests/common/pty.rs +++ b/tests/common/pty.rs @@ -12,6 +12,7 @@ use std::ffi::OsString; use std::io::{Read, Write}; use std::path::Path; +use std::sync::{Arc, Mutex}; use std::thread; use std::time::{Duration, Instant}; @@ -26,7 +27,8 @@ pub struct PmacsPty { child: Box, writer: Box, _reader_thread: thread::JoinHandle<()>, - _master: Box, + output: Arc>>, + master: Box, } impl PmacsPty { @@ -36,6 +38,27 @@ impl PmacsPty { self.writer.flush() } + /// Resize the real host PTY in terminal cells. + pub fn resize(&self, rows: u16, cols: u16) -> Result<(), String> { + self.master + .resize(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }) + .map_err(|error| error.to_string()) + } + + /// Snapshot all bytes emitted by pmacs to its host terminal. + #[must_use] + pub fn output(&self) -> Vec { + self.output + .lock() + .expect("PTY output mutex poisoned") + .clone() + } + /// Poll-wait for pmacs to exit, up to `timeout`. Returns the /// exit status on success, `None` on timeout (and leaves the /// child running for the caller to clean up). @@ -103,16 +126,19 @@ pub fn spawn_pmacs_in_pty(args: &[&str], envs: &[(&str, &Path)], rows: u16, cols let writer = pair.master.take_writer().expect("take_writer"); let mut reader = pair.master.try_clone_reader().expect("try_clone_reader"); - // Drain reader to /dev/null so pmacs's writes never block on a - // backed-up terminal output buffer. We don't need to inspect the - // bytes; the tests assert on exit status and side effects, not - // on screen content. + let output = Arc::new(Mutex::new(Vec::new())); + let captured = output.clone(); + // Drain and retain reader bytes so terminal-mode restoration can be + // asserted without ever exposing pmacs to the test runner's own TTY. let reader_thread = thread::spawn(move || { let mut buf = [0u8; 4096]; loop { match reader.read(&mut buf) { Ok(0) | Err(_) => return, - Ok(_) => {} + Ok(read) => captured + .lock() + .expect("PTY output mutex poisoned") + .extend_from_slice(&buf[..read]), } } }); @@ -121,6 +147,7 @@ pub fn spawn_pmacs_in_pty(args: &[&str], envs: &[(&str, &Path)], rows: u16, cols child, writer, _reader_thread: reader_thread, - _master: pair.master, + output, + master: pair.master, } } diff --git a/tests/vterm_stage2_acceptance.rs b/tests/vterm_stage2_acceptance.rs new file mode 100644 index 0000000..1ead710 --- /dev/null +++ b/tests/vterm_stage2_acceptance.rs @@ -0,0 +1,508 @@ +//! Stage 2 terminal/TUI integration and real-host acceptance. + +mod common; + +use std::fs; +use std::path::Path; +use std::thread; +use std::time::{Duration, Instant}; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use mlua::{AnyUserData, Table, Value}; +use pmacs::cell::{CellCoord, CellSize, Glyph}; +use pmacs::editor::EditorState; +use pmacs::lua_bindings::BufferIdLua; +use pmacs::protocol::FrontendId; +use pmacs::statusline::{ + StatuslineEvaluationOutcome, StatuslineEvaluationTarget, evaluate_statusline, +}; +use pmacs::terminal::{TerminalProcessState, TerminalSpec, TerminalViewKey}; +use pmacs::window::WindowId; + +use common::pty::{PmacsPty, spawn_pmacs_in_pty}; + +fn tick_until( + state: &mut EditorState, + timeout: Duration, + mut done: impl FnMut(&EditorState) -> bool, +) { + let deadline = Instant::now() + timeout; + loop { + state.tick_processes(); + if done(state) { + return; + } + assert!(Instant::now() < deadline, "terminal condition timed out"); + thread::sleep(Duration::from_millis(10)); + } +} + +fn lua_string(value: &str) -> String { + format!("{:?}", value) +} + +fn snapshot_text(snapshot: &pmacs::terminal::TerminalSnapshot) -> String { + let mut text = String::new(); + for cell in &snapshot.cells { + match &cell.glyph { + Glyph::Char(ch) => text.push(*ch), + Glyph::Cluster(bytes) => text.push_str(&String::from_utf8_lossy(bytes)), + Glyph::Continuation => {} + } + } + text +} + +#[test] +fn lua_surface_is_strict_fresh_transactional_and_context_safe() { + let mut state = EditorState::new(); + let command_lua = lua_string("/bin/sh"); + + let baseline_sessions = state.terminal_manager.borrow().len(); + let baseline_buffers = state.core.borrow().registry.borrow().ids().len(); + { + let lua = state.lua_host.lua(); + let error = lua + .load(format!( + "return pmacs.terminal.open {{ command = {command_lua}, unknown = true }}" + )) + .eval::() + .expect_err("unknown open field must fail"); + assert!(error.to_string().contains("unknown field `unknown`")); + } + assert_eq!(state.terminal_manager.borrow().len(), baseline_sessions); + assert_eq!( + state.core.borrow().registry.borrow().ids().len(), + baseline_buffers + ); + + { + let lua = state.lua_host.lua(); + let kind: String = lua + .load(format!( + r#" + TERM_BUFFER = pmacs.terminal.open {{ + command = {command_lua}, + args = {{ "-c", "printf 'copy-me\\n'; sleep 30" }}, + rows = 4, + cols = 30, + }} + local first = pmacs.terminal.state(TERM_BUFFER) + first.process.kind = "poisoned" + first.injected = true + local second = pmacs.terminal.state(TERM_BUFFER) + assert(second.injected == nil) + assert(pmacs.terminal.resize == nil) + return second.process.kind + "# + )) + .eval() + .expect("open terminal and read fresh state"); + assert_eq!(kind, "running"); + } + + let buffer_id = { + let userdata: AnyUserData = state + .lua_host + .lua() + .globals() + .get("TERM_BUFFER") + .expect("terminal buffer global"); + userdata + .borrow::() + .expect("BufferId userdata") + .0 + }; + let buffer_name = state + .core + .borrow() + .registry + .borrow() + .get(buffer_id) + .expect("terminal identity buffer") + .name() + .to_owned(); + assert_eq!(buffer_name, "*terminal:sh*"); + tick_until(&mut state, Duration::from_secs(5), |state| { + state + .terminal_manager + .borrow() + .snapshot(buffer_id) + .is_some_and(|snapshot| snapshot_text(&snapshot).contains("copy-me")) + }); + + let frontend_id = FrontendId::LOCAL; + let window_id = state.core.borrow().active_window_id(); + assert!(state.sync_terminal_layout(frontend_id, CellSize::new(8, 30))); + let snapshots = state.prepare_terminal_views(frontend_id, CellSize::new(8, 30)); + assert!(snapshots.contains_key(&window_id)); + let modeline = evaluate_statusline( + state.lua_host.lua(), + &state.core, + &state.statusline_registry, + StatuslineEvaluationTarget::Grid { frontend_id }, + ); + let StatuslineEvaluationOutcome::Ready(windows) = modeline.outcome else { + panic!("terminal statusline provider must evaluate successfully"); + }; + assert!( + windows + .iter() + .flat_map(|window| &window.right) + .any(|segment| segment.text.starts_with("TERM")), + "built-in terminal statusline provider must report live process state" + ); + let snapshot = snapshots.get(&window_id).expect("active terminal snapshot"); + let snapshot_text = snapshot_text(snapshot); + let start = snapshot_text + .find("copy-me") + .unwrap_or_else(|| panic!("copy probe missing from projected snapshot: {snapshot_text:?}")); + let cols = usize::try_from(snapshot.size.cols).expect("column count fits"); + let row = u32::try_from(start / cols).expect("row fits"); + let col = u32::try_from(start % cols).expect("column fits"); + let view_key = TerminalViewKey::new(frontend_id, window_id, buffer_id); + { + let mut manager = state.terminal_manager.borrow_mut(); + assert!(manager.begin_selection(view_key, snapshot.size, CellCoord::new(row, col))); + assert!(manager.finish_selection(view_key, snapshot.size, CellCoord::new(row, col + 7))); + } + state.dispatch_key( + frontend_id, + KeyEvent::new(KeyCode::Char('w'), KeyModifiers::ALT), + ); + let (clipboard_frontend, clipboard) = state + .core + .borrow_mut() + .take_pending_clipboard() + .expect("terminal copy must queue host clipboard bytes"); + assert_eq!(clipboard_frontend, frontend_id); + assert_eq!(clipboard, b"copy-me"); + { + let lua = state.lua_host.lua(); + let status: Table = lua + .load(format!( + r#" + local first = pmacs.terminal.view_state {{ + frontend = 1, window = {}, buffer = TERM_BUFFER, active = true + }} + first.injected = true + local second = pmacs.terminal.view_state {{ + frontend = 1, window = {}, buffer = TERM_BUFFER, active = true + }} + assert(second.injected == nil) + return second + "#, + window_id.raw(), + window_id.raw() + )) + .eval() + .expect("fresh exact view state"); + assert!(status.get::("at_bottom").expect("at_bottom")); + + let (ok, error): (bool, String) = lua + .load( + r#" + local ok, err = pcall(function() pmacs.terminal.scroll(1) end) + return ok, tostring(err) + "#, + ) + .eval() + .expect("pcall implicit scroll"); + assert!(!ok); + assert!(error.contains("interactive frontend context")); + } + + state + .terminal_manager + .borrow_mut() + .terminate(buffer_id, &mut state.process_supervisor.borrow_mut()) + .expect("terminate terminal child"); + tick_until(&mut state, Duration::from_secs(5), |state| { + state + .terminal_manager + .borrow() + .snapshot(buffer_id) + .is_some_and(|snapshot| !matches!(snapshot.process, TerminalProcessState::Running)) + }); + state + .core + .borrow_mut() + .kill_buffer(buffer_id) + .expect("kill retained terminal buffer"); + state.tick_processes(); + assert_eq!(state.terminal_manager.borrow().len(), baseline_sessions); + assert_eq!( + state.core.borrow().registry.borrow().ids().len(), + baseline_buffers + ); + + state.process_supervisor.borrow_mut().shutdown(); + let _error = state + .lua_host + .lua() + .load(format!( + "return pmacs.terminal.open {{ command = {command_lua} }}" + )) + .eval::() + .expect_err("closed supervisor must make spawn fail transactionally"); + assert_eq!(state.terminal_manager.borrow().len(), baseline_sessions); + assert_eq!( + state.core.borrow().registry.borrow().ids().len(), + baseline_buffers + ); +} + +#[test] +fn shared_screen_keeps_view_scroll_selection_and_controller_independent() { + let mut state = EditorState::new(); + let mut spec = TerminalSpec::new("/bin/sh"); + spec.args = vec![ + "-c".into(), + "i=0; while [ $i -lt 30 ]; do printf 'row%02d\\n' \"$i\"; i=$((i+1)); done; sleep 30" + .into(), + ]; + spec.rows = 6; + spec.cols = 24; + let buffer_id = state + .terminal_manager + .borrow_mut() + .open( + spec, + &mut state.core.borrow_mut(), + &mut state.process_supervisor.borrow_mut(), + ) + .expect("open terminal"); + tick_until(&mut state, Duration::from_secs(5), |state| { + state + .terminal_manager + .borrow() + .snapshot(buffer_id) + .is_some_and(|snapshot| snapshot_text(&snapshot).contains("row29")) + }); + + let size = CellSize::new(4, 24); + let first = TerminalViewKey::new(FrontendId(11), WindowId::next(), buffer_id); + let second = TerminalViewKey::new(FrontendId(22), WindowId::next(), buffer_id); + { + let mut manager = state.terminal_manager.borrow_mut(); + let first_tail = manager + .snapshot_for_view(first, size) + .expect("first snapshot"); + let second_tail = manager + .snapshot_for_view(second, size) + .expect("second snapshot"); + assert_eq!(first_tail.title, second_tail.title); + assert_eq!(first_tail.process, second_tail.process); + + assert!(manager.scroll_lines(first, 3)); + let first_status = manager.view_status(first).expect("first status"); + let second_status = manager.view_status(second).expect("second status"); + assert_eq!(first_status.scroll_offset, 3); + assert_eq!(second_status.scroll_offset, 0); + + assert!(manager.begin_selection(first, size, CellCoord::new(0, 0))); + assert!(manager.finish_selection(first, size, CellCoord::new(0, 4))); + assert!( + manager + .view_status(first) + .expect("selected status") + .selection + ); + assert!( + !manager + .view_status(second) + .expect("passive status") + .selection + ); + assert!( + !manager + .copy_selection(first) + .expect("copied selection") + .is_empty() + ); + assert!(manager.copy_selection(second).is_none()); + + assert!(manager.claim_controller(first)); + assert!(manager.claim_controller(second)); + assert_eq!( + manager.controller_view_for_frontend(FrontendId(11)), + None, + "one terminal session has one most-recent controller" + ); + assert_eq!( + manager.controller_view_for_frontend(FrontendId(22)), + Some(second) + ); + manager.detach_frontend(FrontendId(11)); + assert!(manager.view_status(first).is_none()); + assert!(manager.view_status(second).is_some()); + assert!( + manager + .controller_view_for_frontend(FrontendId(11)) + .is_none() + ); + assert_eq!( + manager.controller_view_for_frontend(FrontendId(22)), + Some(second) + ); + } + + state + .terminal_manager + .borrow_mut() + .terminate(buffer_id, &mut state.process_supervisor.borrow_mut()) + .expect("terminate terminal child"); +} + +fn wait_for_output(pty: &PmacsPty, needle: &[u8], timeout: Duration) { + let deadline = Instant::now() + timeout; + loop { + if pty + .output() + .windows(needle.len()) + .any(|window| window == needle) + { + return; + } + if Instant::now() >= deadline { + let output = pty.output(); + let start = output.len().saturating_sub(4_000); + panic!( + "host output never contained {:?}; tail: {}", + String::from_utf8_lossy(needle), + output[start..].escape_ascii() + ); + } + thread::sleep(Duration::from_millis(20)); + } +} + +fn wait_for_file(path: &Path, timeout: Duration) -> Vec { + let deadline = Instant::now() + timeout; + loop { + if let Ok(bytes) = fs::read(path) { + return bytes; + } + assert!( + Instant::now() < deadline, + "file was not published: {}", + path.display() + ); + thread::sleep(Duration::from_millis(20)); + } +} + +#[test] +fn real_tui_terminal_smoke_restores_host_after_output_input_resize_scroll_copy_and_bell() { + let temp = tempfile::TempDir::new().expect("tempdir"); + let config_root = temp.path().join("config"); + let config_dir = config_root.join("pmacs"); + let state_root = temp.path().join("state"); + let input_path = temp.path().join("child-input"); + let size_path = temp.path().join("child-size"); + fs::create_dir_all(&config_dir).expect("config dir"); + + let probe = format!( + concat!( + "import os\n", + "def read_exact(count):\n", + " data = b''\n", + " while len(data) < count:\n", + " data += os.read(0, count - len(data))\n", + " return data\n", + "def read_until(marker):\n", + " data = b''\n", + " while marker not in data:\n", + " data += os.read(0, 4096)\n", + "os.write(1, b'\\x1b[?1049h\\x1b[2J')\n", + "for i in range(20): os.write(1, b'alt%02d\\n' % i)\n", + "os.write(1, b'VTERM_ALT_READY')\n", + "read_until(b'ALT_GATE\\n')\n", + "os.write(1, b'\\x1b[?1049l')\n", + "for i in range(40): os.write(1, b'main%02d\\n' % i)\n", + "os.write(1, b'VTERM_MAIN_READY\\x07')\n", + "data = read_exact(18)\n", + "open({:?}, 'wb').write(data)\n", + "size = os.get_terminal_size(0)\n", + "open({:?}, 'w').write(f'{{size.lines}} {{size.columns}}\\n')\n" + ), + input_path.to_str().expect("UTF-8 input path"), + size_path.to_str().expect("UTF-8 size path") + ); + let init = format!( + r#" + local terminal_buffer = pmacs.terminal.open {{ + command = "/bin/sh", + args = {{ "-c", "exec /usr/bin/python3 -c \"$1\"", "pmacs-vterm-probe", {} }}, + rows = 10, + cols = 40, + scrollback_rows = 200, + }} + local ticks = 0 + pmacs.hook.add("process.after-tick", function() + ticks = ticks + 1 + if ticks == 120 then + pmacs.terminal.send(terminal_buffer, "ALT_GATE\n") + elseif ticks == 180 then + pmacs.terminal.send(terminal_buffer, "VTERM_INPUT_SMOKE\n") + end + end) + "#, + lua_string(&probe) + ); + fs::write(config_dir.join("init.lua"), init).expect("write init.lua"); + + let mut pty = spawn_pmacs_in_pty( + &[], + &[ + ("XDG_CONFIG_HOME", config_root.as_path()), + ("PMACS_STATE_HOME", state_root.as_path()), + ("TERM", Path::new("xterm-256color")), + ], + 24, + 80, + ); + wait_for_output(&pty, b"VTERM_ALT_READY", Duration::from_secs(10)); + + pty.resize(30, 90).expect("resize host PTY"); + thread::sleep(Duration::from_millis(150)); + pty.write_input(b"\x1bv").expect("terminal page-up binding"); + // Inject editor-owned scrolling, selection, and copy gestures while the + // real terminal child is active; focused tests pin their exact state. + pty.write_input(b"\x1b[<4;2;2M\x1b[<36;8;2M\x1b[<4;8;2m") + .expect("terminal selection drag"); + pty.write_input(b"\x1bw").expect("copy selection binding"); + wait_for_output(&pty, b"\x1b]52;c;", Duration::from_secs(5)); + let input = wait_for_file(&input_path, Duration::from_secs(5)); + assert_eq!(input, b"VTERM_INPUT_SMOKE\n"); + pty.write_input(b"\x1b[200~PASTE_AFTER_EXIT\x1b[201~") + .expect("route a real host paste event"); + let size = String::from_utf8(wait_for_file(&size_path, Duration::from_secs(5))) + .expect("UTF-8 stty size"); + assert_eq!(size.trim(), "28 90", "child PTY must receive cell geometry"); + thread::sleep(Duration::from_millis(200)); + pty.write_input(b"\x03\x18\x03").expect("quit pmacs"); + + let status = pty + .wait_for_exit(Duration::from_secs(5)) + .expect("pmacs should exit after terminal smoke"); + assert!(status.success(), "pmacs exit status: {status:?}"); + thread::sleep(Duration::from_millis(50)); + let output = pty.output(); + assert!( + output.windows(8).any(|window| window == b"\x1b[?1049h"), + "pmacs must enter its host alternate screen" + ); + assert!( + output.windows(8).any(|window| window == b"\x1b[?1049l"), + "pmacs must restore the host main screen" + ); + assert!( + output.contains(&0x07), + "one active-terminal BEL must reach the local host" + ); + assert!( + output.windows(8).any(|window| window == b"\x1b[?2004l"), + "pmacs must disable host bracketed paste on exit" + ); +} From da8f6aeae4ad5189b8f56e175100db8e89b451b7 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 21 Jul 2026 21:38:08 -0400 Subject: [PATCH 08/15] fix(vterm): harden integrated Stage 2 behavior Resolve post-main integration drift in authenticated routing, terminal view projection, Lua installation, and inherited acceptance callers. Preserve the terminal statusline provider alongside the landed Themes provider and record the final Stage 2 gate evidence. Co-authored-by: OpenAI Codex --- docs/agent-handoff.md | 50 +++++++++++++++++++------ src/daemon.rs | 5 ++- src/editor.rs | 27 +++++++------ src/lua_bindings/mod.rs | 12 ++++-- src/terminal/view.rs | 14 +++---- tests/statusline_segments_acceptance.rs | 18 +++++++-- tests/theme_faces_acceptance.rs | 8 +++- tests/vterm_stage1_acceptance.rs | 10 ----- tests/vterm_stage2_acceptance.rs | 15 +++++--- 9 files changed, 103 insertions(+), 56 deletions(-) diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index bb2fb8d..4f7fc3a 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -1,9 +1,9 @@ # Agent handoff — cross-machine continuity -**Last updated: 2026-07-21, after the config registry (#127) and Vterm -Stage 1 terminal core (#126) both landed on `main`, atop completed -Themes Arc 4 (#120/#124/#125). Vterm Stages 2 and 3 are not -implemented.** +**Last updated: 2026-07-21, after the config registry (#127), Vterm +Stage 1 terminal core (#126), and handoff refresh (#128) landed on +`main`, atop completed Themes Arc 4 (#120/#124/#125). Vterm Stage 2 is +implemented on `vterm-tui`; Stage 3 is not implemented.** This file is the bridge between development machines. If you are an agent reading this on a fresh clone: this document plus the `docs/*-framing.md` @@ -17,7 +17,8 @@ commands, read `docs/active-work.md` immediately after this file. ## 1. Where the project stands (2026-07-21) -- `main` @ `2e37c04` (config registry #127), protocol **v18** +- `main` @ `f1a2f75` (handoff refresh #128 atop config registry #127), + protocol **v18** (`SUPPORTED=[6..18]`; v16 = `ThemeFacts`, v17 = `FontFacts`, v18 = `StatuslineSegments`). - **Config registry LANDED — #127** (`docs/config-registry-framing.md` @@ -211,7 +212,7 @@ commands, read `docs/active-work.md` immediately after this file. resize. Review round 2 rejects C0/C1 controls before they enter screen cells, preserves the released button code in SGR mouse reports, removes dead screen paths, and clears stale round-trip state during prune. Stage 2 - must uniquify default terminal buffer names. + now uniquifies default terminal buffer names transactionally. - Exact CUU/CUD and out-of-range DECSTBM clamping, combining across controls, xterm alternate-screen details, legacy non-SGR mouse, printable ASCII and CSI-dispatch allocation fast paths, and scrollback-cap naming are explicit @@ -225,12 +226,37 @@ commands, read `docs/active-work.md` immediately after this file. is a clean behavioral bite. The parser dispatch has its independent clean behavioral bite; the original `main`/crate-root bite remains explicitly weaker compile-time API evidence. - - Stage 2 reviews require a durable focus/input resize owner, owning - `FrontendId` for the global `C-c` continuation, and local clipboard/BEL - signal drainage. Stage 3 additionally owns `pmacs-gpu/src/attach.rs`, - authenticated source routing, protocol-owned wire types/limits, and a - deliberate complete-frame limit decision: 16 MiB is insufficient; use a - measured legal-worst cap or aggregate bound, never silent chunking. + - Stage 3 owns `pmacs-gpu/src/attach.rs`, authenticated source routing, + protocol-owned wire types/limits, and a deliberate complete-frame limit + decision: 16 MiB is insufficient; use a measured legal-worst cap or + aggregate bound, never silent chunking. + - **Stage 2 TUI is implemented on `vterm-tui`** (`docs/vterm-framing.md` + Revision 7, criteria 15–27). `TerminalViewKey` keys per-frontend/window + projection state over one shared process/screen; logical row anchors retain + scroll/selection through reflow. A most-recent authenticated controller + owns input and cell resize, with release on focus/switch/kill/detach. + - The strict `pmacs.terminal` Lua surface owns open/state/view/send/terminate + and context-implicit scroll/copy commands. Buffer-local terminal keymaps + resolve before raw child transport; fixed `C-c` remains the terminal escape + and owns its continuation per frontend. Copy drains through the acting + frontend's clipboard path; active BELs drain once locally and per daemon + frontend, while historical/passive bells are baseline-suppressed. + - TUI composition paints owned terminal cells/styles only inside each + window's content rectangle, suppresses document overlays, and keeps sibling + splits independent. Daemon key/mouse/paste/focus/resize/detach routing uses + the authenticated connection source rather than client-claimed IDs. + `builtin/runtime/terminal.lua` provides the terminal command, view commands, + and pure `ui.modeline.terminal` process/scroll segment. + - `tests/vterm_stage2_acceptance.rs` maps Lua transactionality, shared-view + isolation, clipboard/modeline behavior, and a hermetic real `/bin/sh` TUI + PTY smoke. Stage 2 changes no wire schema or GPU renderer; protocol remains + v18 until Stage 3. + - Integrated Stage 2 final gate: `cargo fmt --check`; strict workspace + Clippy; 1,742 default + 1,918 CRDT library tests (3 ignored each); + Stage 1 acceptance 9 default + 10 CRDT; Stage 2 acceptance 3 default + + 3 CRDT; statusline acceptance 7 default + 8 CRDT; M4 114 passed + (3 ignored, 1 filtered); required GPU 109; workspace 2,869 passed + across 81 suites (19 ignored, 1 filtered); `git diff --check` clean. - **PARKED: kill-ring browser + persistence.** Revision 2 framing is preserved on branch `kill-ring-browser`, but its `0efb5cd` scout is stale and must be repeated before implementation. No PR or implementation is diff --git a/src/daemon.rs b/src/daemon.rs index e47bff2..390fc11 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1406,7 +1406,6 @@ fn dispatcher_loop( /// initial-full-grid analogue — it emits nothing until the frontend /// declares a viewport); every other session keeps the M5.3 /// force-full-grid grid path. -#[allow(clippy::too_many_arguments)] fn take_pending_terminal_bell( editor: &EditorState, frontend_id: FrontendId, @@ -1446,6 +1445,10 @@ fn take_pending_terminal_bell( } } +#[allow( + clippy::too_many_arguments, + reason = "one session bootstrap transaction" +)] fn handle_session_established( editor: &mut EditorState, render_states: &mut HashMap, diff --git a/src/editor.rs b/src/editor.rs index 7aa879e..6d1d5f4 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -301,7 +301,7 @@ impl EditorState { let process_supervisor = crate::lua_bindings::make_process_supervisor(lua_host.lua()) .expect("install pmacs.process"); let terminal_manager = - crate::lua_bindings::make_terminal_manager(lua_host.lua(), process_supervisor.clone()) + crate::lua_bindings::make_terminal_manager(lua_host.lua(), &process_supervisor) .expect("install pmacs.terminal"); lua_host .eval( @@ -759,6 +759,10 @@ impl EditorState { /// (`pmacs.frontend.id()`). Sets [`EditorCore::active_frontend`] /// before any command body runs, so observers always see a fresh /// value. + #[allow( + clippy::too_many_lines, + reason = "single input-precedence state machine" + )] pub fn dispatch_key(&mut self, frontend_id: FrontendId, key: KeyEvent) { if !matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) { return; @@ -1579,6 +1583,10 @@ impl EditorState { /// (the click neither activates the window nor positions the /// cursor; that gesture is reserved for future binding to /// "switch to this window" without disturbing buffer state). + #[allow( + clippy::too_many_lines, + reason = "shared document/terminal mouse router" + )] pub fn dispatch_mouse( &mut self, frontend_id: FrontendId, @@ -1722,9 +1730,7 @@ impl EditorState { ) { use crossterm::event::{MouseButton, MouseEventKind}; - let Some(kind) = terminal_mouse_kind(event.kind) else { - return; - }; + let kind = terminal_mouse_kind(event.kind); let modifiers = terminal_modifiers(event.modifiers); let shift = modifiers.contains(TerminalModifiers::SHIFT); let (at_bottom, modes, screen_size) = { @@ -2313,8 +2319,7 @@ fn process_event(state: &mut EditorState, ev: Event, term_size: crate::cell::Cel } Event::FocusGained => state.dispatch_focus(frontend_id, true), Event::FocusLost => state.dispatch_focus(frontend_id, false), - Event::Key(_) => {} - Event::Resize(_, _) => {} + Event::Key(_) | Event::Resize(_, _) => {} } } @@ -2538,10 +2543,10 @@ impl CompletionPopupKey { /// Vec-backed [`crate::cell::CellGrid`] without going through a /// `RenderState`. #[allow(clippy::too_many_lines, reason = "linear paint pipeline")] -pub fn paint_frame( +pub fn paint_frame( state: &EditorState, frontend_id: FrontendId, - terminal_snapshots: &HashMap, + terminal_snapshots: &HashMap, grid: &mut crate::cell::CellGrid<'_>, term_size: CellSize, ) -> Option { @@ -3602,14 +3607,14 @@ fn terminal_modifiers(modifiers: KeyModifiers) -> TerminalModifiers { crate::protocol::crossterm_translate::mods_from_crossterm(modifiers) } -fn terminal_mouse_kind(kind: crossterm::event::MouseEventKind) -> Option { +fn terminal_mouse_kind(kind: crossterm::event::MouseEventKind) -> TerminalMouseKind { use crossterm::event::{MouseButton, MouseEventKind}; let button = |button| match button { MouseButton::Left => TerminalMouseButton::Left, MouseButton::Right => TerminalMouseButton::Right, MouseButton::Middle => TerminalMouseButton::Middle, }; - Some(match kind { + match kind { MouseEventKind::Down(value) => TerminalMouseKind::Down(button(value)), MouseEventKind::Up(value) => TerminalMouseKind::Up(button(value)), MouseEventKind::Drag(value) => TerminalMouseKind::Drag(button(value)), @@ -3618,7 +3623,7 @@ fn terminal_mouse_kind(kind: crossterm::event::MouseEventKind) -> Option TerminalMouseKind::ScrollDown, MouseEventKind::ScrollLeft => TerminalMouseKind::ScrollLeft, MouseEventKind::ScrollRight => TerminalMouseKind::ScrollRight, - }) + } } fn key_event_to_chord(key: KeyEvent) -> Option { diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index e10ab8d..60b8b86 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -8136,10 +8136,10 @@ pub fn make_process_supervisor(lua: &Lua) -> mlua::Result mlua::Result { let manager = Rc::new(RefCell::new(crate::terminal::TerminalManager::new())); - install_terminal(lua, &manager, &supervisor)?; + install_terminal(lua, &manager, supervisor)?; Ok(manager) } @@ -8248,6 +8248,10 @@ fn terminal_view_key_from_context( ))) } +#[allow( + clippy::too_many_lines, + reason = "single strict Lua module installation" +)] fn install_terminal( lua: &Lua, manager: &crate::terminal::SharedTerminalManager, @@ -8262,7 +8266,7 @@ fn install_terminal( terminal.set( "_open", lua.create_function(move |lua, spec: Table| -> mlua::Result { - let spec = parse_terminal_spec(spec)?; + let spec = parse_terminal_spec(&spec)?; let core = lua .app_data_ref::() .map(|core| core.clone()) @@ -8467,7 +8471,7 @@ fn install_terminal( pmacs.set("terminal", terminal) } -fn parse_terminal_spec(table: Table) -> mlua::Result { +fn parse_terminal_spec(table: &Table) -> mlua::Result { const FIELDS: &[&str] = &[ "command", "args", diff --git a/src/terminal/view.rs b/src/terminal/view.rs index fb3bedf..1cb5117 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -353,8 +353,7 @@ impl TerminalManager { let Some(state) = self.views.get_mut(&key) else { return false; }; - let changed = state.selection.take().is_some() || state.drag.take().is_some(); - changed + state.selection.take().is_some() || state.drag.take().is_some() } /// Current child input modes for one session. @@ -608,12 +607,11 @@ fn project_snapshot( let rows = retained_rows(projection); let geometry = view_geometry(&rows, state, viewport_size.rows); let mut cells = vec![Cell::default(); viewport_size.area() as usize]; - for retained_row in geometry.start..rows.len() { + for (retained_row, &row) in rows.iter().enumerate().skip(geometry.start) { let Some(target_row) = viewport_row(&geometry, viewport_size.rows as usize, retained_row) else { continue; }; - let row = rows[retained_row]; let copy_cols = row.cells.len().min(viewport_size.cols as usize); let target = target_row * viewport_size.cols as usize; cells[target..target + copy_cols].clone_from_slice(&row.cells[..copy_cols]); @@ -624,13 +622,12 @@ fn project_snapshot( .and_then(|selection| normalized_selection(&rows, selection)) .map_or_else(Vec::new, |(start, end)| { let mut spans = Vec::new(); - for retained_row in start.row..=end.row { + for (retained_row, &row) in rows.iter().enumerate().take(end.row + 1).skip(start.row) { let Some(target_row) = viewport_row(&geometry, viewport_size.rows as usize, retained_row) else { continue; }; - let row = rows[retained_row]; let start_col = if retained_row == start.row { start.col } else { @@ -687,8 +684,7 @@ fn is_default_blank(cell: &Cell) -> bool { fn copy_selection_bytes(rows: &[&TerminalRow], selection: TerminalSelection) -> Option> { let (start, end) = normalized_selection(rows, selection)?; let mut out = Vec::new(); - for row_index in start.row..=end.row { - let row = rows[row_index]; + for (row_index, &row) in rows.iter().enumerate().take(end.row + 1).skip(start.row) { let from = if row_index == start.row { start.col } else { 0 }; let mut to = if row_index == end.row { end.col.saturating_add(glyph_width(row, end.col)) @@ -815,7 +811,7 @@ mod tests { #[test] fn copy_joins_soft_wraps_trims_default_blanks_and_separates_hard_rows() { - let rows = vec![ + let rows = [ row(1, 0, "ab ", true), row(1, 3, "cd ", false), row(2, 0, "e ", false), diff --git a/tests/statusline_segments_acceptance.rs b/tests/statusline_segments_acceptance.rs index b2f22aa..1d0d896 100644 --- a/tests/statusline_segments_acceptance.rs +++ b/tests/statusline_segments_acceptance.rs @@ -57,7 +57,13 @@ fn paint(state: &EditorState, rows: u32, cols: u32) -> Vec { stride: cols, size: CellSize::new(rows, cols), }; - let _ = pmacs::editor::paint_frame(state, &mut grid, CellSize::new(rows, cols)); + let _ = pmacs::editor::paint_frame( + state, + FrontendId::LOCAL, + &std::collections::HashMap::new(), + &mut grid, + CellSize::new(rows, cols), + ); cells } @@ -124,8 +130,14 @@ fn a01_04_registry_contract_limits_epochs_and_results() { assert!(baseline_mode.ends_with(" L1:C1 All ")); let initial = state.statusline_registry.borrow().providers(); - assert_eq!(initial.len(), 1, "builtin lsp provider is discoverable"); - assert_eq!(initial[0].name, "lsp"); + assert_eq!( + initial + .iter() + .map(|provider| provider.name.as_str()) + .collect::>(), + ["terminal", "lsp"], + "built-in providers are discoverable" + ); let before_epochs = { let registry = state.statusline_registry.borrow(); (registry.layout_epoch(), registry.face_set_epoch()) diff --git a/tests/theme_faces_acceptance.rs b/tests/theme_faces_acceptance.rs index c591839..c81ace7 100644 --- a/tests/theme_faces_acceptance.rs +++ b/tests/theme_faces_acceptance.rs @@ -92,7 +92,13 @@ fn paint_full_frame(state: &EditorState, rows: u32, cols: u32) -> Vec { stride: cols, size: CellSize::new(rows, cols), }; - let _cursor = pmacs::editor::paint_frame(state, &mut grid, CellSize::new(rows, cols)); + let _cursor = pmacs::editor::paint_frame( + state, + FrontendId::LOCAL, + &std::collections::HashMap::new(), + &mut grid, + CellSize::new(rows, cols), + ); backing } diff --git a/tests/vterm_stage1_acceptance.rs b/tests/vterm_stage1_acceptance.rs index b01d5b5..d1a4a63 100644 --- a/tests/vterm_stage1_acceptance.rs +++ b/tests/vterm_stage1_acceptance.rs @@ -104,16 +104,6 @@ fn strict_owned_spec_rejects_before_spawn_and_is_mutation_independent() { lua_processes, 0, "terminal-owned ProcessId must not be exposed through pmacs.process" ); - let terminal_module_absent: bool = state - .lua_host - .lua() - .load("return pmacs.terminal == nil") - .eval() - .expect("terminal module absence"); - assert!( - terminal_module_absent, - "Stage 1 must not publish an unrenderable interactive Lua terminal API" - ); let process_id = state .terminal_manager diff --git a/tests/vterm_stage2_acceptance.rs b/tests/vterm_stage2_acceptance.rs index 1ead710..c777010 100644 --- a/tests/vterm_stage2_acceptance.rs +++ b/tests/vterm_stage2_acceptance.rs @@ -38,7 +38,7 @@ fn tick_until( } fn lua_string(value: &str) -> String { - format!("{:?}", value) + format!("{value:?}") } fn snapshot_text(snapshot: &pmacs::terminal::TerminalSnapshot) -> String { @@ -54,6 +54,10 @@ fn snapshot_text(snapshot: &pmacs::terminal::TerminalSnapshot) -> String { } #[test] +#[allow( + clippy::too_many_lines, + reason = "cross-surface Lua transaction scenario" +)] fn lua_surface_is_strict_fresh_transactional_and_context_safe() { let mut state = EditorState::new(); let command_lua = lua_string("/bin/sh"); @@ -181,7 +185,7 @@ fn lua_surface_is_strict_fresh_transactional_and_context_safe() { let lua = state.lua_host.lua(); let status: Table = lua .load(format!( - r#" + r" local first = pmacs.terminal.view_state {{ frontend = 1, window = {}, buffer = TERM_BUFFER, active = true }} @@ -191,7 +195,7 @@ fn lua_surface_is_strict_fresh_transactional_and_context_safe() { }} assert(second.injected == nil) return second - "#, + ", window_id.raw(), window_id.raw() )) @@ -201,10 +205,10 @@ fn lua_surface_is_strict_fresh_transactional_and_context_safe() { let (ok, error): (bool, String) = lua .load( - r#" + r" local ok, err = pcall(function() pmacs.terminal.scroll(1) end) return ok, tostring(err) - "#, + ", ) .eval() .expect("pcall implicit scroll"); @@ -393,6 +397,7 @@ fn wait_for_file(path: &Path, timeout: Duration) -> Vec { } #[test] +#[allow(clippy::too_many_lines, reason = "one real-host lifecycle scenario")] fn real_tui_terminal_smoke_restores_host_after_output_input_resize_scroll_copy_and_bell() { let temp = tempfile::TempDir::new().expect("tempdir"); let config_root = temp.path().join("config"); From d2fca0c3ad4ded88cf52846e7d5937e278ab4ac3 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 21 Jul 2026 21:39:17 -0400 Subject: [PATCH 09/15] docs(active-work): record gated Vterm Stage 2 lane Replace the obsolete framing-only recovery entry with the integrated branch head, exact verification evidence, and portable worktree command. --- docs/active-work.md | 44 +++++++++++++++++++++++++------------------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index c30e04e..bcfa477 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -14,7 +14,7 @@ backlog. machine-local: `origin` may name this canonical URL, a release mirror, or something else, and therefore has no authority by name alone. - Canonical base at this snapshot: - `githubsucks/main` @ `2e37c04` (#127 merged; protocol v18). + `githubsucks/main` @ `f1a2f75` (#128 merged; protocol v18). - On the transfer source, `origin/main` named a release mirror at `d3fa632` and lagged badly. On the current destination, `origin` names the canonical URL. This difference is why all recovery begins by @@ -48,33 +48,39 @@ git worktree list git status --short --branch ``` -The first command must expose `2e37c04` or a newer intentional main. +The first command must expose `f1a2f75` or a newer intentional main. If it does not, stop and repair the remote/fetch configuration. -## Vterm Stage 2 framing lane +## Vterm Stage 2 implementation lane -- Portable branch: `githubsucks/vterm-framing` -- Approved framing head: `fb4f8f0` -- Base: canonical `main` @ `643d1e1` (Vterm Stage 1 / PR #126 merged). - `main` has since advanced to `2e37c04` (config registry #127, no - runtime overlap with vterm); cut the Stage 2 lane from current `main`, - not from `643d1e1`. -- State: `docs/vterm-framing.md` Revision 7 is framing-only, reviewed, and - approved for implementation. It closes the final `at_bottom`, terminal - `C-c` binding-reachability, and context-implicit Lua failure-mode findings. - There is no Stage 2 runtime implementation or PR yet. -- Next lane: create `pmacs-vterm-tui` / `vterm-tui` from current canonical - `main`, carry the approved framing as its first commit, then implement and - gate Stage 2. Do not implement on `vterm-framing`. +- Portable branch: `githubsucks/vterm-tui` +- Current local head: `da8f6ae` +- Base: canonical `main` @ `f1a2f75` (handoff refresh #128 atop config + registry #127 and Vterm Stage 1 #126). +- State: `docs/vterm-framing.md` Revision 7 criteria 15–27 are implemented. + The lane composes per-frontend/window terminal views in the TUI, installs + the strict `pmacs.terminal` API and terminal-local bindings, drains + clipboard/BEL through the authenticated frontend, and routes daemon + terminal input by connection source. Protocol remains v18; Stage 2 changes + neither the wire schema nor the GPU renderer. +- Implementation commits: `39e07cb`, `7c39535`, `0a846d9`, `0dacac7`, + `dc92257`, merge `0ddff24`, and final integration hardening `da8f6ae`. +- Final from-start verification: `cargo fmt --check`; strict workspace + Clippy; 1,742 default + 1,918 CRDT library tests (3 ignored each); + Stage 1 acceptance 9 default + 10 CRDT; Stage 2 acceptance 3 default + + 3 CRDT; statusline acceptance 7 default + 8 CRDT; M4 114 passed + (3 ignored, 1 filtered); required GPU 109; workspace 2,869 passed across + 81 suites (19 ignored, 1 filtered); `git diff --check` clean. +- Next: push the branch and open the Stage 2 PR against canonical `main`. Recovery worktree on a machine that does not already own the branch: ```sh git worktree add --track \ - -b vterm-framing \ - ../pmacs-vterm-framing \ - githubsucks/vterm-framing + -b vterm-tui \ + ../pmacs-vterm-tui \ + githubsucks/vterm-tui ``` ## Parked lane: kill-ring browser + persistence From 6725e9a5987f274805224babcc1612133a2ece53 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 21 Jul 2026 21:40:48 -0400 Subject: [PATCH 10/15] docs(active-work): record Vterm Stage 2 PR Record the published branch checkpoint and PR #130 as the active review lane. Preserve the explicit no-merge-without-authorization boundary. --- docs/active-work.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index bcfa477..a8d34fa 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -55,9 +55,11 @@ If it does not, stop and repair the remote/fetch configuration. ## Vterm Stage 2 implementation lane - Portable branch: `githubsucks/vterm-tui` -- Current local head: `da8f6ae` +- Feature head: `da8f6ae`; first published ledger checkpoint: `d2fca0c` - Base: canonical `main` @ `f1a2f75` (handoff refresh #128 atop config registry #127 and Vterm Stage 1 #126). +- PR: #130, , open against + canonical `main`. - State: `docs/vterm-framing.md` Revision 7 criteria 15–27 are implemented. The lane composes per-frontend/window terminal views in the TUI, installs the strict `pmacs.terminal` API and terminal-local bindings, drains @@ -72,7 +74,7 @@ If it does not, stop and repair the remote/fetch configuration. 3 CRDT; statusline acceptance 7 default + 8 CRDT; M4 114 passed (3 ignored, 1 filtered); required GPU 109; workspace 2,869 passed across 81 suites (19 ignored, 1 filtered); `git diff --check` clean. -- Next: push the branch and open the Stage 2 PR against canonical `main`. +- Next: user review rounds. Do not merge without explicit authorization. Recovery worktree on a machine that does not already own the branch: From 8702791de96baf7354bcca23cb351f9a07732a5b Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 22 Jul 2026 08:28:18 -0400 Subject: [PATCH 11/15] fix(vterm): honor terminal escape and view contracts Require the fixed C-c escape before editor-local terminal bindings, reject context-implicit Lua operations from document windows, and make controller replacement atomic per frontend. Borrow screen rows during view projection instead of deep-cloning retained history, preserve view anchors through zero-area layouts, and remove redundant detach paths. Add focused child-input coverage for unescaped bound keys and C-c C-c, plus controller, zero-area, context-error, and clone-free projection assertions. Co-authored-by: OpenAI Codex --- src/daemon.rs | 5 - src/editor.rs | 83 ++++++++-------- src/lua_bindings/mod.rs | 14 ++- src/terminal/screen.rs | 81 +++++++++++++++ src/terminal/session.rs | 6 ++ src/terminal/view.rs | 141 ++++++++++++++++---------- tests/vterm_stage2_acceptance.rs | 165 ++++++++++++++++++++++++++++++- 7 files changed, 389 insertions(+), 106 deletions(-) diff --git a/src/daemon.rs b/src/daemon.rs index 390fc11..f153625 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1302,7 +1302,6 @@ fn dispatcher_loop( last_active_buffer_sent.remove(fid); terminal_bell_baselines.remove(fid); editor.detach_frontend_input(*fid); - editor.terminal_manager.borrow_mut().detach_frontend(*fid); session_registry.unregister_session(*fid); editor .statusline_registry @@ -1743,10 +1742,6 @@ fn handle_dispatcher_event( last_active_buffer_sent.remove(&frontend_id); terminal_bell_baselines.remove(&frontend_id); editor.detach_frontend_input(frontend_id); - editor - .terminal_manager - .borrow_mut() - .detach_frontend(frontend_id); session_registry.unregister_session(frontend_id); editor .statusline_registry diff --git a/src/editor.rs b/src/editor.rs index 6d1d5f4..693a56e 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -825,17 +825,6 @@ impl EditorState { .dispatchers .get(&frontend_id) .is_some_and(|state| state.terminal_escape); - let terminal_local_binding = terminal_key.is_some_and(|view_key| { - chord.is_some_and(|chord| { - let stack = self.lua_host.keymaps().borrow(); - stack.buffers.get(&view_key.buffer_id).is_some_and(|map| { - !matches!( - map.lookup(&[chord]), - crate::keymap_tree::Resolution::Unbound - ) - }) - }) - }); if let Some(view_key) = terminal_key { if escaped { self.dispatchers @@ -856,23 +845,21 @@ impl EditorState { self.claim_terminal_controller(view_key); return; } - if !terminal_local_binding { - let Some((terminal_key, modifiers)) = terminal_key_from_crossterm(key) else { - return; - }; - let modes = self - .terminal_manager - .borrow() - .modes_for_view(view_key) - .unwrap_or_default(); - if let Some(bytes) = - crate::terminal::input::encode_key(terminal_key, modifiers, modes) - { - self.claim_terminal_controller(view_key); - self.send_terminal_bytes(view_key.buffer_id, &bytes); - } + let Some((terminal_key, modifiers)) = terminal_key_from_crossterm(key) else { return; + }; + let modes = self + .terminal_manager + .borrow() + .modes_for_view(view_key) + .unwrap_or_default(); + if let Some(bytes) = + crate::terminal::input::encode_key(terminal_key, modifiers, modes) + { + self.claim_terminal_controller(view_key); + self.send_terminal_bytes(view_key.buffer_id, &bytes); } + return; } } @@ -954,11 +941,6 @@ impl EditorState { fn claim_terminal_controller(&self, key: TerminalViewKey) { let mut manager = self.terminal_manager.borrow_mut(); - if let Some(previous) = manager.controller_view_for_frontend(key.frontend_id) - && previous != key - { - let _ = manager.release_controller(previous); - } let _ = manager.register_view(key); let _ = manager.claim_controller(key); } @@ -1070,6 +1052,7 @@ impl EditorState { .get(&key.window_id) .copied() else { + let _ = self.terminal_manager.borrow_mut().release_controller(key); return false; }; placement.content @@ -1115,19 +1098,33 @@ impl EditorState { let core = self.core.borrow(); let placements = window_placements(&core, frontend_id, term_size); let mut live = HashSet::new(); + if let Some(view) = core.views.get(&frontend_id) { + for window_id in view.layout.iter_ids() { + let Some(window) = core.windows.get(&window_id) else { + continue; + }; + if self.terminal_manager.borrow().is_terminal(window.buffer_id) { + live.insert(TerminalViewKey::new( + frontend_id, + window_id, + window.buffer_id, + )); + } + } + } let mut sizes = Vec::new(); for (window_id, placement) in placements { let Some(window) = core.windows.get(&window_id) else { continue; }; - if placement.content.size.rows == 0 || placement.content.size.cols == 0 { + let key = TerminalViewKey::new(frontend_id, window_id, window.buffer_id); + if !live.contains(&key) + || placement.content.size.rows == 0 + || placement.content.size.cols == 0 + { continue; } - let key = TerminalViewKey::new(frontend_id, window_id, window.buffer_id); - if self.terminal_manager.borrow().is_terminal(window.buffer_id) { - live.insert(key); - sizes.push((key, placement.content.size)); - } + sizes.push((key, placement.content.size)); } (live, sizes) }; @@ -1155,10 +1152,14 @@ impl EditorState { { messages.push(InstanceMessage::Signal(InstanceSignal::Bell)); } - if let Some((target, bytes)) = self.core.borrow_mut().take_pending_clipboard() - && target == frontend_id - { - messages.push(InstanceMessage::Signal(InstanceSignal::Clipboard(bytes))); + if let Some((target, bytes)) = self.core.borrow_mut().take_pending_clipboard() { + debug_assert_eq!( + target, frontend_id, + "local signal drain received a non-local clipboard target" + ); + if target == frontend_id { + messages.push(InstanceMessage::Signal(InstanceSignal::Clipboard(bytes))); + } } messages } diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index 60b8b86..44b7707 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -8162,6 +8162,7 @@ fn terminal_command_frontend(lua: &Lua, core: &SharedCore) -> crate::protocol::F fn active_terminal_view_key( lua: &Lua, core: &SharedCore, + manager: &crate::terminal::SharedTerminalManager, operation: &str, ) -> mlua::Result { let frontend_id = lua @@ -8178,6 +8179,11 @@ fn active_terminal_view_key( "pmacs.terminal.{operation}: invoking frontend has no active window" )) })?; + if !manager.borrow().is_terminal(window.buffer_id) { + return Err(mlua::Error::external(format!( + "pmacs.terminal.{operation}: invoking frontend's active window is not a terminal" + ))); + } Ok(crate::terminal::TerminalViewKey::new( frontend_id, core.views @@ -8417,7 +8423,7 @@ fn install_terminal( } }); let core = terminal_shared_core(lua, "scroll")?; - let key = active_terminal_view_key(lua, &core, "scroll")?; + let key = active_terminal_view_key(lua, &core, &manager, "scroll")?; Ok(manager.borrow_mut().scroll_lines(key, lines)) })?, )?; @@ -8434,7 +8440,7 @@ fn install_terminal( ) })?; let core = terminal_shared_core(lua, "_scroll_page")?; - let key = active_terminal_view_key(lua, &core, "_scroll_page")?; + let key = active_terminal_view_key(lua, &core, &manager, "_scroll_page")?; Ok(manager.borrow_mut().scroll_page(key, direction)) })?, )?; @@ -8446,7 +8452,7 @@ fn install_terminal( "scroll_to_bottom", lua.create_function(move |lua, ()| { let core = terminal_shared_core(lua, "scroll_to_bottom")?; - let key = active_terminal_view_key(lua, &core, "scroll_to_bottom")?; + let key = active_terminal_view_key(lua, &core, &manager, "scroll_to_bottom")?; Ok(manager.borrow_mut().scroll_to_bottom(key)) })?, )?; @@ -8458,7 +8464,7 @@ fn install_terminal( "copy_selection", lua.create_function(move |lua, ()| { let core = terminal_shared_core(lua, "copy_selection")?; - let key = active_terminal_view_key(lua, &core, "copy_selection")?; + let key = active_terminal_view_key(lua, &core, &manager, "copy_selection")?; let Some(bytes) = manager.borrow_mut().copy_selection(key) else { return Ok(false); }; diff --git a/src/terminal/screen.rs b/src/terminal/screen.rs index 7d7cbdb..67c8bd7 100644 --- a/src/terminal/screen.rs +++ b/src/terminal/screen.rs @@ -116,6 +116,39 @@ pub struct ScreenProjection { pub generation: u64, } +/// Borrowed, publication-consistent row projection for in-process views. +#[allow(missing_docs)] +#[derive(Clone, Copy)] +pub(crate) struct BorrowedScreenProjection<'a> { + pub alternate_active: bool, + pub history_head: &'a [TerminalRow], + pub history_tail: &'a [TerminalRow], + pub visible_rows: &'a [TerminalRow], + pub cursor: Option, + pub title: Option<&'a str>, + pub generation: u64, +} + +impl BorrowedScreenProjection<'_> { + pub(crate) fn history_len(self) -> usize { + self.history_head.len() + self.history_tail.len() + } +} + +impl ScreenProjection { + pub(crate) fn as_borrowed(&self) -> BorrowedScreenProjection<'_> { + BorrowedScreenProjection { + alternate_active: self.alternate_active, + history_head: &self.history, + history_tail: &[], + visible_rows: &self.visible_rows, + cursor: self.cursor, + title: self.title.as_deref(), + generation: self.generation, + } + } +} + #[derive(Clone, Copy, Debug, Default)] struct Cursor { row: usize, @@ -538,6 +571,30 @@ impl TerminalScreen { } } + /// Borrow one publication-consistent row projection without cloning cells. + pub(crate) fn projection_ref(&self) -> BorrowedScreenProjection<'_> { + if self.modes.synchronized_output { + return self.published.as_borrowed(); + } + let (history_head, history_tail) = if self.alt_active { + (&[][..], &[][..]) + } else { + self.main.history.as_slices() + }; + BorrowedScreenProjection { + alternate_active: self.alt_active, + history_head, + history_tail, + visible_rows: &self.active().rows, + cursor: self + .modes + .cursor_visible + .then(|| CellCoord::new(self.cursor.row as u32, self.cursor.col as u32)), + title: self.title.as_deref(), + generation: self.generation, + } + } + pub fn modes(&self) -> TerminalModes { self.modes } @@ -1768,6 +1825,30 @@ mod tests { assert!(s.alternate_active()); } + #[test] + fn borrowed_projection_reuses_live_and_published_row_storage() { + let mut s = screen(2, 4); + s.apply_event(AnsiEvent::Text("main".into())); + s.apply_event(AnsiEvent::LineFeed); + s.apply_event(AnsiEvent::LineFeed); + let (live_history, _) = s.main.history.as_slices(); + let live_history_ptr = live_history.as_ptr(); + let live_visible_ptr = s.main.rows.as_ptr(); + let projection = s.projection_ref(); + assert_eq!(projection.history_head.as_ptr(), live_history_ptr); + assert_eq!(projection.visible_rows.as_ptr(), live_visible_ptr); + + s.apply_event(AnsiEvent::SetMode { + mode: TerminalMode::SynchronizedOutput, + enabled: true, + }); + let published_history_ptr = s.published.history.as_ptr(); + let published_visible_ptr = s.published.visible_rows.as_ptr(); + let projection = s.projection_ref(); + assert_eq!(projection.history_head.as_ptr(), published_history_ptr); + assert_eq!(projection.visible_rows.as_ptr(), published_visible_ptr); + } + #[test] fn main_reflows_soft_wrap_and_alt_clips() { let mut s = screen(2, 4); diff --git a/src/terminal/session.rs b/src/terminal/session.rs index c7761db..686214b 100644 --- a/src/terminal/session.rs +++ b/src/terminal/session.rs @@ -406,10 +406,16 @@ impl TerminalManager { } /// Give an exact registered view durable PTY control for its session. + /// + /// A frontend controls at most one session. Claiming another registered + /// view atomically releases that frontend's previous session first. pub fn claim_controller(&mut self, key: TerminalViewKey) -> bool { if !self.views.contains_key(&key) || !self.sessions.contains_key(&key.buffer_id) { return false; } + self.controllers.retain(|buffer_id, controller| { + controller.frontend_id != key.frontend_id || *buffer_id == key.buffer_id + }); self.controllers .insert(key.buffer_id, TerminalController::from_view(key)); true diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 1cb5117..7740cce 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -6,7 +6,7 @@ use crate::buffer::BufferId; use crate::cell::{Cell, CellCoord, CellSize, Glyph, Style}; use crate::protocol::FrontendId; -use crate::terminal::screen::{ScreenProjection, TerminalModes, TerminalRow}; +use crate::terminal::screen::{BorrowedScreenProjection, TerminalModes, TerminalRow}; use crate::terminal::session::{TerminalManager, TerminalSelectionSpan, TerminalSnapshot}; use crate::terminal::{MAX_TERMINAL_COLS, MAX_TERMINAL_ROWS, MAX_TERMINAL_VISIBLE_CELLS}; use crate::window::WindowId; @@ -119,7 +119,7 @@ impl TerminalManager { return None; } let session = self.sessions.get(&key.buffer_id)?; - let projection = session.screen.projection(); + let projection = session.screen.projection_ref(); let pid = session.pid; let process = session.process.clone(); let bell_count = session.screen.bell_count(); @@ -129,12 +129,12 @@ impl TerminalManager { last_bell_count: bell_count, ..TerminalViewState::default() }); - normalize_state(state, &projection); + normalize_state(state, projection); state.viewport_size = Some(viewport_size); Some(project_snapshot( key.buffer_id, viewport_size, - &projection, + projection, state, pid, process, @@ -157,16 +157,16 @@ impl TerminalManager { let Some(session) = self.sessions.get(&key.buffer_id) else { return false; }; - let projection = session.screen.projection(); + let projection = session.screen.projection_ref(); let bell_count = session.screen.bell_count(); let state = self.views.entry(key).or_insert_with(|| TerminalViewState { alternate_active: Some(projection.alternate_active), last_bell_count: bell_count, ..TerminalViewState::default() }); - normalize_state(state, &projection); + normalize_state(state, projection); state.viewport_size = Some(viewport_size); - let rows = retained_rows(&projection); + let rows = retained_rows(projection); if rows.is_empty() { return false; } @@ -184,7 +184,7 @@ impl TerminalManager { state.top = if next == tail_start && state.selection.is_none() { None } else { - Some(row_lead(rows[next])) + Some(row_lead(rows.get(next).expect("bounded retained row"))) }; true } @@ -215,10 +215,10 @@ impl TerminalManager { /// Return fresh geometric status for one registered view. #[must_use] pub fn view_status(&mut self, key: TerminalViewKey) -> Option { - let projection = self.sessions.get(&key.buffer_id)?.screen.projection(); + let projection = self.sessions.get(&key.buffer_id)?.screen.projection_ref(); let state = self.views.get_mut(&key)?; - normalize_state(state, &projection); - let rows = retained_rows(&projection); + normalize_state(state, projection); + let rows = retained_rows(projection); let size = state.viewport_size?; let geometry = view_geometry(&rows, state, size.rows); Some(TerminalViewStatus { @@ -245,11 +245,11 @@ impl TerminalManager { #[must_use] pub fn copy_selection(&mut self, key: TerminalViewKey) -> Option> { let session = self.sessions.get(&key.buffer_id)?; - let projection = session.screen.projection(); + let projection = session.screen.projection_ref(); let state = self.views.get_mut(&key)?; - normalize_state(state, &projection); + normalize_state(state, projection); let selection = state.selection?; - let rows = retained_rows(&projection); + let rows = retained_rows(projection); copy_selection_bytes(&rows, selection) } @@ -263,15 +263,15 @@ impl TerminalManager { let Some(session) = self.sessions.get(&key.buffer_id) else { return false; }; - let projection = session.screen.projection(); + let projection = session.screen.projection_ref(); let bell_count = session.screen.bell_count(); let state = self.views.entry(key).or_insert_with(|| TerminalViewState { alternate_active: Some(projection.alternate_active), last_bell_count: bell_count, ..TerminalViewState::default() }); - normalize_state(state, &projection); - let rows = retained_rows(&projection); + normalize_state(state, projection); + let rows = retained_rows(projection); let geometry = view_geometry(&rows, state, viewport_size.rows); let Some(anchor) = anchor_at(&rows, &geometry, viewport_size, coord) else { state.selection = None; @@ -280,7 +280,7 @@ impl TerminalManager { }; state.selection_froze_top = state.top.is_none(); if state.top.is_none() { - state.top = rows.get(geometry.start).copied().map(row_lead); + state.top = rows.get(geometry.start).map(row_lead); } state.selection = Some(TerminalSelection { anchor, @@ -300,15 +300,15 @@ impl TerminalManager { let Some(session) = self.sessions.get(&key.buffer_id) else { return false; }; - let projection = session.screen.projection(); + let projection = session.screen.projection_ref(); let Some(state) = self.views.get_mut(&key) else { return false; }; - normalize_state(state, &projection); + normalize_state(state, projection); if state.drag.is_none() { return false; } - let rows = retained_rows(&projection); + let rows = retained_rows(projection); let geometry = view_geometry(&rows, state, viewport_size.rows); let Some(head) = anchor_at(&rows, &geometry, viewport_size, coord) else { return false; @@ -412,6 +412,46 @@ struct ViewGeometry { scroll_offset: u32, } +#[derive(Clone, Copy)] +struct RetainedRows<'a> { + projection: BorrowedScreenProjection<'a>, +} + +impl<'a> RetainedRows<'a> { + fn len(self) -> usize { + self.projection.history_len() + self.projection.visible_rows.len() + } + + fn is_empty(self) -> bool { + self.len() == 0 + } + + fn get(self, index: usize) -> Option<&'a TerminalRow> { + let head_len = self.projection.history_head.len(); + if index < head_len { + return self.projection.history_head.get(index); + } + let index = index - head_len; + let tail_len = self.projection.history_tail.len(); + if index < tail_len { + return self.projection.history_tail.get(index); + } + self.projection.visible_rows.get(index - tail_len) + } + + fn first(self) -> Option<&'a TerminalRow> { + self.get(0) + } + + fn iter(self) -> impl Iterator { + self.projection + .history_head + .iter() + .chain(self.projection.history_tail) + .chain(self.projection.visible_rows) + } +} + fn valid_viewport(size: CellSize) -> bool { size.rows > 0 && size.cols > 0 @@ -420,12 +460,8 @@ fn valid_viewport(size: CellSize) -> bool { && size.area() as usize <= MAX_TERMINAL_VISIBLE_CELLS } -fn retained_rows(projection: &ScreenProjection) -> Vec<&TerminalRow> { - projection - .history - .iter() - .chain(projection.visible_rows.iter()) - .collect() +fn retained_rows(projection: BorrowedScreenProjection<'_>) -> RetainedRows<'_> { + RetainedRows { projection } } fn row_lead(row: &TerminalRow) -> LogicalCellAnchor { @@ -435,7 +471,7 @@ fn row_lead(row: &TerminalRow) -> LogicalCellAnchor { } } -fn resolve_anchor(rows: &[&TerminalRow], anchor: LogicalCellAnchor) -> Option { +fn resolve_anchor(rows: &RetainedRows<'_>, anchor: LogicalCellAnchor) -> Option { rows.iter().enumerate().find_map(|(row_index, row)| { if row.logical_line_id != anchor.logical_line_id { return None; @@ -461,15 +497,15 @@ fn canonical_col(row: &TerminalRow, mut col: usize) -> usize { col } -fn anchor_for(rows: &[&TerminalRow], resolved: ResolvedCell) -> LogicalCellAnchor { - let row = rows[resolved.row]; +fn anchor_for(rows: &RetainedRows<'_>, resolved: ResolvedCell) -> LogicalCellAnchor { + let row = rows.get(resolved.row).expect("resolved retained row"); LogicalCellAnchor { logical_line_id: row.logical_line_id, cell_offset: row.cell_offset.saturating_add(resolved.col as u32), } } -fn clamp_or_clear(rows: &[&TerminalRow], anchor: LogicalCellAnchor) -> Option { +fn clamp_or_clear(rows: &RetainedRows<'_>, anchor: LogicalCellAnchor) -> Option { if let Some(resolved) = resolve_anchor(rows, anchor) { return Some(anchor_for(rows, resolved)); } @@ -477,7 +513,7 @@ fn clamp_or_clear(rows: &[&TerminalRow], anchor: LogicalCellAnchor) -> Option) { if state .alternate_active .is_some_and(|active| active != projection.alternate_active) @@ -505,7 +541,7 @@ fn normalize_state(state: &mut TerminalViewState, projection: &ScreenProjection) } fn view_geometry( - rows: &[&TerminalRow], + rows: &RetainedRows<'_>, state: &TerminalViewState, viewport_rows: u32, ) -> ViewGeometry { @@ -550,7 +586,7 @@ fn viewport_row( } fn anchor_at( - rows: &[&TerminalRow], + rows: &RetainedRows<'_>, geometry: &ViewGeometry, viewport_size: CellSize, coord: CellCoord, @@ -565,7 +601,7 @@ fn anchor_at( let retained_row = geometry .start .saturating_add(viewport_row - geometry.top_padding); - let row = *rows.get(retained_row)?; + let row = rows.get(retained_row)?; if coord.col as usize >= row.cells.len() { return None; } @@ -577,7 +613,7 @@ fn anchor_at( } fn normalized_selection( - rows: &[&TerminalRow], + rows: &RetainedRows<'_>, selection: TerminalSelection, ) -> Option<(ResolvedCell, ResolvedCell)> { let mut start = resolve_anchor(rows, selection.anchor)?; @@ -599,7 +635,7 @@ fn glyph_width(row: &TerminalRow, col: usize) -> usize { fn project_snapshot( buffer_id: BufferId, viewport_size: CellSize, - projection: &ScreenProjection, + projection: BorrowedScreenProjection<'_>, state: &TerminalViewState, pid: u32, process: crate::terminal::session::TerminalProcessState, @@ -607,7 +643,7 @@ fn project_snapshot( let rows = retained_rows(projection); let geometry = view_geometry(&rows, state, viewport_size.rows); let mut cells = vec![Cell::default(); viewport_size.area() as usize]; - for (retained_row, &row) in rows.iter().enumerate().skip(geometry.start) { + for (retained_row, row) in rows.iter().enumerate().skip(geometry.start) { let Some(target_row) = viewport_row(&geometry, viewport_size.rows as usize, retained_row) else { continue; @@ -622,7 +658,7 @@ fn project_snapshot( .and_then(|selection| normalized_selection(&rows, selection)) .map_or_else(Vec::new, |(start, end)| { let mut spans = Vec::new(); - for (retained_row, &row) in rows.iter().enumerate().take(end.row + 1).skip(start.row) { + for (retained_row, row) in rows.iter().enumerate().take(end.row + 1).skip(start.row) { let Some(target_row) = viewport_row(&geometry, viewport_size.rows as usize, retained_row) else { @@ -652,7 +688,7 @@ fn project_snapshot( let cursor = if geometry.scroll_offset == 0 { projection.cursor.and_then(|cursor| { - let retained_row = projection.history.len().saturating_add(cursor.row as usize); + let retained_row = projection.history_len().saturating_add(cursor.row as usize); let row = viewport_row(&geometry, viewport_size.rows as usize, retained_row)?; (cursor.col < viewport_size.cols).then(|| CellCoord::new(row as u32, cursor.col)) }) @@ -665,7 +701,7 @@ fn project_snapshot( size: viewport_size, cells, cursor, - title: projection.title.clone(), + title: projection.title.map(str::to_owned), screen_generation: projection.generation, selection, scroll_offset: geometry.scroll_offset, @@ -681,10 +717,10 @@ fn is_default_blank(cell: &Cell) -> bool { && cell.attachment.is_none() } -fn copy_selection_bytes(rows: &[&TerminalRow], selection: TerminalSelection) -> Option> { +fn copy_selection_bytes(rows: &RetainedRows<'_>, selection: TerminalSelection) -> Option> { let (start, end) = normalized_selection(rows, selection)?; let mut out = Vec::new(); - for (row_index, &row) in rows.iter().enumerate().take(end.row + 1).skip(start.row) { + for (row_index, row) in rows.iter().enumerate().take(end.row + 1).skip(start.row) { let from = if row_index == start.row { start.col } else { 0 }; let mut to = if row_index == end.row { end.col.saturating_add(glyph_width(row, end.col)) @@ -714,6 +750,7 @@ fn copy_selection_bytes(rows: &[&TerminalRow], selection: TerminalSelection) -> #[cfg(test)] mod tests { use super::*; + use crate::terminal::screen::ScreenProjection; use crate::terminal::session::TerminalProcessState; fn row(id: u64, offset: u32, text: &str, soft_wrapped: bool) -> TerminalRow { @@ -755,7 +792,7 @@ mod tests { let snapshot = project_snapshot( BufferId::next(), CellSize::new(4, 5), - &source, + source.as_borrowed(), &TerminalViewState::default(), 42, TerminalProcessState::Running, @@ -800,7 +837,7 @@ mod tests { let snapshot = project_snapshot( BufferId::next(), CellSize::new(3, 3), - &source, + source.as_borrowed(), &state, 1, TerminalProcessState::Running, @@ -816,9 +853,10 @@ mod tests { row(1, 3, "cd ", false), row(2, 0, "e ", false), ]; - let refs: Vec<&TerminalRow> = rows.iter().collect(); + let source = projection(Vec::new(), rows.into()); + let retained = retained_rows(source.as_borrowed()); let bytes = copy_selection_bytes( - &refs, + &retained, TerminalSelection { anchor: LogicalCellAnchor { logical_line_id: 1, @@ -854,9 +892,10 @@ mod tests { cell_offset: 0, soft_wrapped: false, }; - let refs = vec![&wide]; + let source = projection(Vec::new(), vec![wide]); + let retained = retained_rows(source.as_borrowed()); let continuation = resolve_anchor( - &refs, + &retained, LogicalCellAnchor { logical_line_id: 9, cell_offset: 1, @@ -865,7 +904,7 @@ mod tests { .expect("continuation resolves"); assert_eq!(continuation.col, 0); let bytes = copy_selection_bytes( - &refs, + &retained, TerminalSelection { anchor: LogicalCellAnchor { logical_line_id: 9, @@ -910,7 +949,7 @@ mod tests { alternate_active: Some(false), ..TerminalViewState::default() }; - normalize_state(&mut state, &source); + normalize_state(&mut state, source.as_borrowed()); assert_eq!(state.top, None); assert_eq!(state.selection, None); assert_eq!(state.alternate_active, Some(true)); diff --git a/tests/vterm_stage2_acceptance.rs b/tests/vterm_stage2_acceptance.rs index c777010..86d1201 100644 --- a/tests/vterm_stage2_acceptance.rs +++ b/tests/vterm_stage2_acceptance.rs @@ -61,6 +61,7 @@ fn snapshot_text(snapshot: &pmacs::terminal::TerminalSnapshot) -> String { fn lua_surface_is_strict_fresh_transactional_and_context_safe() { let mut state = EditorState::new(); let command_lua = lua_string("/bin/sh"); + let baseline_buffer_id = state.core.borrow().active_buffer_id(); let baseline_sessions = state.terminal_manager.borrow().len(); let baseline_buffers = state.core.borrow().registry.borrow().ids().len(); @@ -170,6 +171,29 @@ fn lua_surface_is_strict_fresh_transactional_and_context_safe() { assert!(manager.begin_selection(view_key, snapshot.size, CellCoord::new(row, col))); assert!(manager.finish_selection(view_key, snapshot.size, CellCoord::new(row, col + 7))); } + assert!( + state + .prepare_terminal_views(frontend_id, CellSize::new(1, 1)) + .is_empty(), + "zero-area terminal placements paint no snapshot" + ); + assert!( + state + .terminal_manager + .borrow() + .view_state(view_key) + .is_some_and(|view| view.selection.is_some()), + "a transient zero-area placement must retain existing view anchors" + ); + assert!( + state + .prepare_terminal_views(frontend_id, CellSize::new(8, 30)) + .contains_key(&window_id) + ); + state.dispatch_key( + frontend_id, + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + ); state.dispatch_key( frontend_id, KeyEvent::new(KeyCode::Char('w'), KeyModifiers::ALT), @@ -216,6 +240,46 @@ fn lua_surface_is_strict_fresh_transactional_and_context_safe() { assert!(error.contains("interactive frontend context")); } + state + .lua_host + .lua() + .load( + r#" + pmacs.command.define { + name = "test.terminal-context", + description = "Exercise context-implicit terminal failure", + fn = function() pmacs.terminal.scroll(1) end, + } + pmacs.keymap.bind { + scope = "global", sequence = "", command = "test.terminal-context" + } + "#, + ) + .exec() + .expect("install context failure probe"); + state + .core + .borrow_mut() + .switch_active_buffer_for(frontend_id, baseline_buffer_id) + .expect("switch to document buffer"); + state.dispatch_key( + frontend_id, + KeyEvent::new(KeyCode::F(12), KeyModifiers::NONE), + ); + assert!( + state + .core + .borrow() + .status + .contains("active window is not a terminal"), + "context-implicit terminal command must raise a named Lua error" + ); + state + .core + .borrow_mut() + .switch_active_buffer_for(frontend_id, buffer_id) + .expect("restore terminal buffer"); + state .terminal_manager .borrow_mut() @@ -257,6 +321,7 @@ fn lua_surface_is_strict_fresh_transactional_and_context_safe() { } #[test] +#[allow(clippy::too_many_lines, reason = "shared view and controller scenario")] fn shared_screen_keeps_view_scroll_selection_and_controller_independent() { let mut state = EditorState::new(); let mut spec = TerminalSpec::new("/bin/sh"); @@ -283,10 +348,24 @@ fn shared_screen_keeps_view_scroll_selection_and_controller_independent() { .snapshot(buffer_id) .is_some_and(|snapshot| snapshot_text(&snapshot).contains("row29")) }); + let mut replacement_spec = TerminalSpec::new("/bin/sh"); + replacement_spec.args = vec!["-c".into(), "sleep 30".into()]; + replacement_spec.rows = 4; + replacement_spec.cols = 24; + let replacement_buffer = state + .terminal_manager + .borrow_mut() + .open( + replacement_spec, + &mut state.core.borrow_mut(), + &mut state.process_supervisor.borrow_mut(), + ) + .expect("open replacement terminal"); let size = CellSize::new(4, 24); let first = TerminalViewKey::new(FrontendId(11), WindowId::next(), buffer_id); let second = TerminalViewKey::new(FrontendId(22), WindowId::next(), buffer_id); + let replacement = TerminalViewKey::new(FrontendId(11), WindowId::next(), replacement_buffer); { let mut manager = state.terminal_manager.borrow_mut(); let first_tail = manager @@ -295,6 +374,9 @@ fn shared_screen_keeps_view_scroll_selection_and_controller_independent() { let second_tail = manager .snapshot_for_view(second, size) .expect("second snapshot"); + manager + .snapshot_for_view(replacement, size) + .expect("replacement snapshot"); assert_eq!(first_tail.title, second_tail.title); assert_eq!(first_tail.process, second_tail.process); @@ -327,12 +409,14 @@ fn shared_screen_keeps_view_scroll_selection_and_controller_independent() { assert!(manager.copy_selection(second).is_none()); assert!(manager.claim_controller(first)); - assert!(manager.claim_controller(second)); + assert!(manager.claim_controller(replacement)); assert_eq!( manager.controller_view_for_frontend(FrontendId(11)), - None, - "one terminal session has one most-recent controller" + Some(replacement), + "claiming another session atomically replaces a frontend's controller" ); + assert!(manager.controller(buffer_id).is_none()); + assert!(manager.claim_controller(second)); assert_eq!( manager.controller_view_for_frontend(FrontendId(22)), Some(second) @@ -356,6 +440,75 @@ fn shared_screen_keeps_view_scroll_selection_and_controller_independent() { .borrow_mut() .terminate(buffer_id, &mut state.process_supervisor.borrow_mut()) .expect("terminate terminal child"); + state + .terminal_manager + .borrow_mut() + .terminate( + replacement_buffer, + &mut state.process_supervisor.borrow_mut(), + ) + .expect("terminate replacement terminal child"); +} + +#[test] +fn terminal_escape_gates_local_bindings_and_double_escape_sends_interrupt() { + let temp = tempfile::TempDir::new().expect("tempdir"); + let ready_path = temp.path().join("ready"); + let input_path = temp.path().join("input"); + let probe = format!( + concat!( + "import os, tty\n", + "tty.setraw(0)\n", + "open({:?}, 'wb').write(b'1')\n", + "data = b''\n", + "while len(data) < 5: data += os.read(0, 5 - len(data))\n", + "open({:?}, 'wb').write(data)\n", + ), + ready_path.to_str().expect("UTF-8 ready path"), + input_path.to_str().expect("UTF-8 input path") + ); + let mut state = EditorState::new(); + state + .lua_host + .lua() + .load(format!( + r#" + return pmacs.terminal.open {{ + command = "/usr/bin/python3", + args = {{ "-c", {} }}, + rows = 4, + cols = 20, + }} + "#, + lua_string(&probe) + )) + .eval::() + .expect("open raw input probe"); + assert_eq!(wait_for_file(&ready_path, Duration::from_secs(5)), b"1"); + + let frontend_id = FrontendId::LOCAL; + state.dispatch_key( + frontend_id, + KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT), + ); + state.dispatch_key( + frontend_id, + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + ); + state.dispatch_key( + frontend_id, + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + ); + state.dispatch_key( + frontend_id, + KeyEvent::new(KeyCode::Char('w'), KeyModifiers::ALT), + ); + + assert_eq!( + wait_for_file(&input_path, Duration::from_secs(5)), + b"\x1bv\x03\x1bw", + "unescaped local bindings reach the child; C-c C-c sends one literal interrupt" + ); } fn wait_for_output(pty: &PmacsPty, needle: &[u8], timeout: Duration) { @@ -471,12 +624,14 @@ fn real_tui_terminal_smoke_restores_host_after_output_input_resize_scroll_copy_a pty.resize(30, 90).expect("resize host PTY"); thread::sleep(Duration::from_millis(150)); - pty.write_input(b"\x1bv").expect("terminal page-up binding"); + pty.write_input(b"\x03\x1bv") + .expect("escaped terminal page-up binding"); // Inject editor-owned scrolling, selection, and copy gestures while the // real terminal child is active; focused tests pin their exact state. pty.write_input(b"\x1b[<4;2;2M\x1b[<36;8;2M\x1b[<4;8;2m") .expect("terminal selection drag"); - pty.write_input(b"\x1bw").expect("copy selection binding"); + pty.write_input(b"\x03\x1bw") + .expect("escaped copy selection binding"); wait_for_output(&pty, b"\x1b]52;c;", Duration::from_secs(5)); let input = wait_for_file(&input_path, Duration::from_secs(5)); assert_eq!(input, b"VTERM_INPUT_SMOKE\n"); From 2e332b579ce58de26aadda2a198324dd2ff15adf Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 22 Jul 2026 08:29:33 -0400 Subject: [PATCH 12/15] docs(vterm): record PR 130 review round 1 Align the durable handoff with the approved escape-prefix contract, record the performance and lifecycle hardening, update the Stage 2 verification map, and publish exact final gate evidence in the active-work ledger. --- docs/active-work.md | 21 +++++++++++++-------- docs/agent-handoff.md | 40 +++++++++++++++++++++++++--------------- docs/vterm-framing.md | 3 ++- 3 files changed, 40 insertions(+), 24 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index a8d34fa..cae4034 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -55,7 +55,7 @@ If it does not, stop and repair the remote/fetch configuration. ## Vterm Stage 2 implementation lane - Portable branch: `githubsucks/vterm-tui` -- Feature head: `da8f6ae`; first published ledger checkpoint: `d2fca0c` +- Feature head after review round 1: `8702791` - Base: canonical `main` @ `f1a2f75` (handoff refresh #128 atop config registry #127 and Vterm Stage 1 #126). - PR: #130, , open against @@ -66,15 +66,20 @@ If it does not, stop and repair the remote/fetch configuration. clipboard/BEL through the authenticated frontend, and routes daemon terminal input by connection source. Protocol remains v18; Stage 2 changes neither the wire schema nor the GPU renderer. +- Review round 1: addressed. Dispatch now requires `C-c` before terminal-local + editor bindings; non-terminal context operations error; controller + replacement is atomic per frontend; zero-area layouts retain view anchors; + view projection borrows retained rows instead of deep-cloning scrollback. - Implementation commits: `39e07cb`, `7c39535`, `0a846d9`, `0dacac7`, - `dc92257`, merge `0ddff24`, and final integration hardening `da8f6ae`. -- Final from-start verification: `cargo fmt --check`; strict workspace - Clippy; 1,742 default + 1,918 CRDT library tests (3 ignored each); - Stage 1 acceptance 9 default + 10 CRDT; Stage 2 acceptance 3 default + - 3 CRDT; statusline acceptance 7 default + 8 CRDT; M4 114 passed - (3 ignored, 1 filtered); required GPU 109; workspace 2,869 passed across + `dc92257`, merge `0ddff24`, integration hardening `da8f6ae`, and first-review + fixes `8702791`. +- Review-round final verification: `cargo fmt --check`; strict workspace + Clippy; 1,743 default + 1,919 CRDT library tests (3 ignored each); + Stage 1 acceptance 9 default + 10 CRDT; Stage 2 acceptance 4 default + + 4 CRDT; statusline acceptance 7 default + 8 CRDT; M4 114 passed + (3 ignored, 1 filtered); required GPU 109; workspace 2,871 passed across 81 suites (19 ignored, 1 filtered); `git diff --check` clean. -- Next: user review rounds. Do not merge without explicit authorization. +- Next: second user review. Do not merge without explicit authorization. Recovery worktree on a machine that does not already own the branch: diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index 4f7fc3a..a66d0cd 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -1,9 +1,10 @@ # Agent handoff — cross-machine continuity -**Last updated: 2026-07-21, after the config registry (#127), Vterm -Stage 1 terminal core (#126), and handoff refresh (#128) landed on -`main`, atop completed Themes Arc 4 (#120/#124/#125). Vterm Stage 2 is -implemented on `vterm-tui`; Stage 3 is not implemented.** +**Last updated: 2026-07-22, after Vterm Stage 2 PR #130 review round 1 +was addressed on `vterm-tui`; config registry (#127), Vterm Stage 1 +terminal core (#126), and handoff refresh (#128) are landed on `main`, +atop completed Themes Arc 4 (#120/#124/#125). Vterm Stage 3 is not +implemented.** This file is the bridge between development machines. If you are an agent reading this on a fresh clone: this document plus the `docs/*-framing.md` @@ -233,12 +234,15 @@ commands, read `docs/active-work.md` immediately after this file. - **Stage 2 TUI is implemented on `vterm-tui`** (`docs/vterm-framing.md` Revision 7, criteria 15–27). `TerminalViewKey` keys per-frontend/window projection state over one shared process/screen; logical row anchors retain - scroll/selection through reflow. A most-recent authenticated controller - owns input and cell resize, with release on focus/switch/kill/detach. + scroll/selection through reflow. One authenticated frontend controls at + most one session, with atomic replacement and release on + focus/switch/kill/detach. - The strict `pmacs.terminal` Lua surface owns open/state/view/send/terminate - and context-implicit scroll/copy commands. Buffer-local terminal keymaps - resolve before raw child transport; fixed `C-c` remains the terminal escape - and owns its continuation per frontend. Copy drains through the acting + and context-implicit scroll/copy commands; the latter error unless the + invoking frontend's active window is a terminal. Fixed `C-c` is the + per-frontend terminal escape: only its next key reaches terminal-local + editor bindings, while unescaped bound keys pass through to the child. + `C-c C-c` sends one literal interrupt. Copy drains through the acting frontend's clipboard path; active BELs drain once locally and per daemon frontend, while historical/passive bells are baseline-suppressed. - TUI composition paints owned terminal cells/styles only inside each @@ -251,12 +255,18 @@ commands, read `docs/active-work.md` immediately after this file. isolation, clipboard/modeline behavior, and a hermetic real `/bin/sh` TUI PTY smoke. Stage 2 changes no wire schema or GPU renderer; protocol remains v18 until Stage 3. - - Integrated Stage 2 final gate: `cargo fmt --check`; strict workspace - Clippy; 1,742 default + 1,918 CRDT library tests (3 ignored each); - Stage 1 acceptance 9 default + 10 CRDT; Stage 2 acceptance 3 default + - 3 CRDT; statusline acceptance 7 default + 8 CRDT; M4 114 passed - (3 ignored, 1 filtered); required GPU 109; workspace 2,869 passed - across 81 suites (19 ignored, 1 filtered); `git diff --check` clean. + - PR #130 review round 1 (`8702791`) aligned dispatch with the approved + escape-prefix contract, closed the non-terminal Lua error path, made + controller replacement atomic, retained zero-area view anchors, removed + duplicate detach work, and replaced per-view deep scrollback clones with + borrowed live/published row projections. Focused child-input coverage pins + both unescaped bound-key passthrough and `C-c C-c`. + - Review-round final gate: `cargo fmt --check`; strict workspace Clippy; + 1,743 default + 1,919 CRDT library tests (3 ignored each); Stage 1 + acceptance 9 default + 10 CRDT; Stage 2 acceptance 4 default + 4 CRDT; + statusline acceptance 7 default + 8 CRDT; M4 114 passed (3 ignored, + 1 filtered); required GPU 109; workspace 2,871 passed across 81 suites + (19 ignored, 1 filtered); `git diff --check` clean. - **PARKED: kill-ring browser + persistence.** Revision 2 framing is preserved on branch `kill-ring-browser`, but its `0efb5cd` scout is stale and must be repeated before implementation. No PR or implementation is diff --git a/docs/vterm-framing.md b/docs/vterm-framing.md index 4ac13f9..f10ad74 100644 --- a/docs/vterm-framing.md +++ b/docs/vterm-framing.md @@ -1150,8 +1150,9 @@ coverage remains beside the owning implementation. The criteria map as follows: paste_and_focus_are_exact, unsupported_keys_are_invisible}` and the real-TUI input/paste path. - **19:** `editor::tests::dispatch_prefix_state_is_independent_per_frontend`, + `terminal_escape_gates_local_bindings_and_double_escape_sends_interrupt`, `lua_surface_is_strict_fresh_transactional_and_context_safe`, and the - real-TUI terminal-escape/quit path. + real-TUI escaped editor-binding/quit path. - **20:** `terminal::input::tests::sgr_mouse_modes_modifiers_and_coordinates` plus the real-TUI editor-owned scroll/drag/copy path. - **21:** `terminal::view::tests::{copy_joins_soft_wraps_trims_default_blanks_and_separates_hard_rows, From b9a7e40855bf24c85aacf38b43ed304c79b24837 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 22 Jul 2026 09:09:44 -0400 Subject: [PATCH 13/15] fix(vterm): harden view anchors and interactive authority Clamp anchors into partially evicted wrapped lines, require authenticated interactive origins, and avoid per-mouse cell snapshots. Restore dispatcher rationale, named context errors, and focused regressions for the corrected contracts. Co-authored-by: OpenAI Codex --- docs/vterm-framing.md | 9 ++++ src/editor.rs | 46 +++++++++++++---- src/editor_core.rs | 2 +- src/lua_bindings/mod.rs | 79 +++++++++++++++++++++------- src/terminal/screen.rs | 3 ++ src/terminal/view.rs | 89 +++++++++++++++++++++++++++++--- tests/m9_6_acceptance.rs | 24 +++++---- tests/vterm_stage2_acceptance.rs | 40 ++++++++++++++ 8 files changed, 245 insertions(+), 47 deletions(-) diff --git a/docs/vterm-framing.md b/docs/vterm-framing.md index f10ad74..84b50aa 100644 --- a/docs/vterm-framing.md +++ b/docs/vterm-framing.md @@ -873,6 +873,12 @@ that frontend's dispatcher, terminal views, controller claims, and bell baseline. These are Stage 2 changes to existing v18 grid behavior; no protocol bump or semantic/GPU terminal surface is added in this PR. +One intentional Stage 2 boundary remains until Stage 3: an authenticated v18 +semantic frontend can send a key while its active buffer is a terminal, claim +that terminal's controller, and feed the PTY, but cannot display the screen. +The next accepted TUI terminal input reclaims control; v19 removes the invisible +interval by adding the semantic terminal surface. + ## 6. Stage 3 — protocol v19 and GPU integration ### 6.1 Wire additions @@ -1234,6 +1240,9 @@ Not part of these three PRs: alternate-screen switches; - legacy X10 mouse byte encoding when a child enables mouse tracking without SGR mode; Stage 2 sends no report for that unsupported combination; +- bracketed-paste payload filtering: Stage 2 forwards exact paste bytes as + framed, so embedded `ESC[201~` can terminate the wrapper early; xterm-style + filtering/escaping requires a separate input-policy decision; - nonstandard `CSI 3 K` ignore semantics (the current core clears the line); - the ASCII fast path that avoids grapheme-candidate allocation and segmentation for every printable character after another ASCII character, diff --git a/src/editor.rs b/src/editor.rs index 693a56e..368b1c2 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -719,6 +719,12 @@ impl EditorState { } /// Whether `frontend_id` may optimistically self-insert its next key. + /// + /// `false` while a prefix, terminal escape, modal surface, or round-trip + /// buffer owns input. The daemon publishes this as `DispatchIdle`; returning + /// `true` while one of those surfaces is active would let a CRDT frontend + /// edit the document locally while the daemon routes the same key elsewhere + /// (M10.10, Q#SR5, Q#CM1, Q#QR1, Arc 1b Q#P6). #[must_use] pub fn dispatch_idle_for(&self, frontend_id: FrontendId) -> bool { if self @@ -735,7 +741,7 @@ impl EditorState { && !core.menu_is_open() && core .active_window_for(frontend_id) - .is_some_and(|window| !core.active_buffer_round_trips_for(window.buffer_id)) + .is_some_and(|window| !core.buffer_round_trips(window.buffer_id)) } /// Local-frontend compatibility wrapper. @@ -767,6 +773,9 @@ impl EditorState { if !matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) { return; } + // Authenticate every path through this input event, including modal + // callbacks such as M-x minibuffer acceptance. + let _origin = self.interactive_origin.enter(frontend_id); let chord = key_event_to_chord(key); { let mut core = self.core.borrow_mut(); @@ -782,6 +791,11 @@ impl EditorState { } } + // Modal surfaces beat both terminal transport and the completion popup. + // Menu/search/query-replace/minibuffer are full keymap shadows shared by + // grid and semantic input; each returns before the ordinary post-command + // edit check, so shadow handlers own any required hook fan-out (Q#CM1, + // Q#SR5, Q#QR1). // Global modal surfaces own input before terminal transport. if self.core.borrow().menu_is_open() { if let Some(chord) = chord { @@ -808,6 +822,9 @@ impl EditorState { return; } + // Completion is the one partial modal shadow (Q#C3): only its control + // chords are intercepted. A pending per-frontend prefix owns those + // chords instead, and ordinary keys continue to terminal/keymap dispatch. let dispatcher_pending = self .dispatchers .get(&frontend_id) @@ -820,6 +837,8 @@ impl EditorState { return; } + // Terminal transport precedes ordinary buffer/global bindings. `C-c` + // opens a fixed one-key editor escape; all unescaped keys go to the child. let terminal_key = self.active_terminal_key(frontend_id); let escaped = self .dispatchers @@ -878,9 +897,10 @@ impl EditorState { let pre_revision = self.active_buffer_revision(); match action { + // Kill ring Q#KR2: stamp the authenticated frontend before the + // body so nested interactive calls inherit the same origin. Action::Run { command, .. } => { self.core.borrow_mut().rotate_command(frontend_id, &command); - let _origin = self.interactive_origin.enter(frontend_id); if let Err(e) = self .lua_host .invoke_command(&command, mlua::MultiValue::new()) @@ -889,10 +909,14 @@ impl EditorState { format!("error in {command}: {}", first_line(&e.to_string())); } } + // A prefix is rendered from dispatcher state; dismissing the + // popup prevents its partial shadow from stealing continuation. Action::Pending { .. } => { self.core.borrow_mut().completion_popup_close(); } Action::Unbound { sequence } => { + // Self-insert is an interactive command boundary (Q#KR2). + // Arm Q#AP9 typed-edit metadata only across this dispatch. if let Some(ch) = printable_char(&sequence) { self.core .borrow_mut() @@ -900,12 +924,12 @@ impl EditorState { self.core.borrow_mut().typed_edit_arm(frontend_id, ch); let mut args = mlua::MultiValue::new(); args.push_back(mlua::Value::Integer(ch as i64)); - let _origin = self.interactive_origin.enter(frontend_id); if let Err(e) = self.lua_host.invoke_command("buffer.self-insert", args) { self.core.borrow_mut().status = format!("self-insert failed: {}", first_line(&e.to_string())); } } else { + // Emacs `undefined` is still a command boundary (Q#KR2). self.core.borrow_mut().break_command_chain(frontend_id); self.core.borrow_mut().status = format!("{}: not bound", display_sequence(&sequence)); @@ -1736,14 +1760,12 @@ impl EditorState { let shift = modifiers.contains(TerminalModifiers::SHIFT); let (at_bottom, modes, screen_size) = { let mut manager = self.terminal_manager.borrow_mut(); - let Some(snapshot) = manager.snapshot_for_view(key, viewport_size) else { + let Some(status) = manager.view_status_for_size(key, viewport_size) else { return; }; let modes = manager.modes_for_view(key).unwrap_or_default(); - let screen_size = manager - .snapshot(key.buffer_id) - .map_or(viewport_size, |snapshot| snapshot.size); - (snapshot.at_bottom, modes, screen_size) + let screen_size = manager.screen_size_for_view(key).unwrap_or(viewport_size); + (status.at_bottom, modes, screen_size) }; if !shift @@ -2543,11 +2565,15 @@ impl CompletionPopupKey { /// future non-crossterm frontend) can drive it directly against a /// Vec-backed [`crate::cell::CellGrid`] without going through a /// `RenderState`. +#[allow( + clippy::implicit_hasher, + reason = "the public renderer contract uses the canonical snapshot HashMap" +)] #[allow(clippy::too_many_lines, reason = "linear paint pipeline")] -pub fn paint_frame( +pub fn paint_frame( state: &EditorState, frontend_id: FrontendId, - terminal_snapshots: &HashMap, + terminal_snapshots: &HashMap, grid: &mut crate::cell::CellGrid<'_>, term_size: CellSize, ) -> Option { diff --git a/src/editor_core.rs b/src/editor_core.rs index 4625de9..ded8143 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -2828,7 +2828,7 @@ impl EditorCore { /// Whether an explicit buffer requires daemon-owned round-trip input. #[must_use] - pub fn active_buffer_round_trips_for(&self, buffer_id: BufferId) -> bool { + pub fn buffer_round_trips(&self, buffer_id: BufferId) -> bool { self.round_trip_buffers.contains(&buffer_id) } diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index 44b7707..d35967b 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -5176,6 +5176,26 @@ fn install_buffer_kill(lua: &Lua, core: &SharedCore) -> mlua::Result<()> { Ok(()) } +fn rotate_interactive_command(lua: &Lua, name: &str) -> mlua::Result<()> { + let origin = lua + .app_data_ref::() + .ok_or_else(|| { + mlua::Error::external( + "pmacs.command.invoke_interactive: interactive frontend context is unavailable", + ) + })?; + let frontend_id = origin.current().ok_or_else(|| { + mlua::Error::external( + "pmacs.command.invoke_interactive: requires an active interactive frontend context", + ) + })?; + let core = lua.app_data_ref::().ok_or_else(|| { + mlua::Error::external("pmacs.command.invoke_interactive: editor core is unavailable") + })?; + core.borrow_mut().rotate_command(frontend_id, name); + Ok(()) +} + fn install_command_module(lua: &Lua, commands: &SharedCommandRegistry) -> mlua::Result
{ let command = lua.create_table()?; @@ -5248,18 +5268,7 @@ fn install_command_module(lua: &Lua, commands: &SharedCommandRegistry) -> mlua:: command.set( "invoke_interactive", lua.create_function(move |lua, (name, args): (String, Variadic)| { - let frontend_id = lua.app_data_ref::().map(|core| { - let mut core = core.borrow_mut(); - let frontend_id = core.active_frontend; - core.rotate_command(frontend_id, &name); - frontend_id - }); - let origin = lua - .app_data_ref::() - .map(|origin| origin.clone()); - let _origin_guard = frontend_id - .zip(origin.as_ref()) - .map(|(frontend_id, origin)| origin.enter(frontend_id)); + rotate_interactive_command(lua, &name)?; let body = { let r = cmds.borrow(); r.get(&name) @@ -8194,6 +8203,41 @@ fn active_terminal_view_key( )) } +fn terminal_context_integer(context: &Table, field: &str) -> mlua::Result { + match context.raw_get::(field)? { + Value::Integer(value) => u64::try_from(value).map_err(|_| { + mlua::Error::external(format!( + "pmacs.terminal.view_state: `{field}` must be nonnegative" + )) + }), + Value::Nil => Err(mlua::Error::external(format!( + "pmacs.terminal.view_state: missing field `{field}`" + ))), + other => Err(mlua::Error::external(format!( + "pmacs.terminal.view_state: `{field}` must be an integer, got {}", + other.type_name() + ))), + } +} + +fn terminal_context_buffer(context: &Table) -> mlua::Result { + match context.raw_get::("buffer")? { + Value::UserData(buffer) => buffer + .borrow::() + .map(|buffer| buffer.0) + .map_err(|_| { + mlua::Error::external("pmacs.terminal.view_state: `buffer` must be a buffer id") + }), + Value::Nil => Err(mlua::Error::external( + "pmacs.terminal.view_state: missing field `buffer`", + )), + other => Err(mlua::Error::external(format!( + "pmacs.terminal.view_state: `buffer` must be a buffer id, got {}", + other.type_name() + ))), + } +} + fn terminal_view_key_from_context( core: &SharedCore, context: &Table, @@ -8219,14 +8263,9 @@ fn terminal_view_key_from_context( "pmacs.terminal.view_state: unknown field `{field}`" ))); } - let frontend_raw = context.get::("frontend")?; - let frontend_id = crate::protocol::FrontendId(u64::try_from(frontend_raw).map_err(|_| { - mlua::Error::external("pmacs.terminal.view_state: `frontend` must be nonnegative") - })?); - let window_raw = u64::try_from(context.get::("window")?).map_err(|_| { - mlua::Error::external("pmacs.terminal.view_state: `window` must be nonnegative") - })?; - let buffer_id = context.get::("buffer")?.0; + let frontend_id = crate::protocol::FrontendId(terminal_context_integer(context, "frontend")?); + let window_raw = terminal_context_integer(context, "window")?; + let buffer_id = terminal_context_buffer(context)?; let core = core.borrow(); let Some(view) = core.views.get(&frontend_id) else { diff --git a/src/terminal/screen.rs b/src/terminal/screen.rs index 67c8bd7..11c92ef 100644 --- a/src/terminal/screen.rs +++ b/src/terminal/screen.rs @@ -120,6 +120,7 @@ pub struct ScreenProjection { #[allow(missing_docs)] #[derive(Clone, Copy)] pub(crate) struct BorrowedScreenProjection<'a> { + pub size: CellSize, pub alternate_active: bool, pub history_head: &'a [TerminalRow], pub history_tail: &'a [TerminalRow], @@ -138,6 +139,7 @@ impl BorrowedScreenProjection<'_> { impl ScreenProjection { pub(crate) fn as_borrowed(&self) -> BorrowedScreenProjection<'_> { BorrowedScreenProjection { + size: self.size, alternate_active: self.alternate_active, history_head: &self.history, history_tail: &[], @@ -582,6 +584,7 @@ impl TerminalScreen { self.main.history.as_slices() }; BorrowedScreenProjection { + size: self.size, alternate_active: self.alt_active, history_head, history_tail, diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 7740cce..b8cd2ad 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -212,15 +212,29 @@ impl TerminalManager { self.scroll_view(key, size, rows.saturating_mul(direction.signum())) } - /// Return fresh geometric status for one registered view. + /// Register or refresh one view at `viewport_size` and return its geometry + /// without allocating an owned cell snapshot. #[must_use] - pub fn view_status(&mut self, key: TerminalViewKey) -> Option { - let projection = self.sessions.get(&key.buffer_id)?.screen.projection_ref(); - let state = self.views.get_mut(&key)?; + pub(crate) fn view_status_for_size( + &mut self, + key: TerminalViewKey, + viewport_size: CellSize, + ) -> Option { + if !valid_viewport(viewport_size) { + return None; + } + let session = self.sessions.get(&key.buffer_id)?; + let projection = session.screen.projection_ref(); + let bell_count = session.screen.bell_count(); + let state = self.views.entry(key).or_insert_with(|| TerminalViewState { + alternate_active: Some(projection.alternate_active), + last_bell_count: bell_count, + ..TerminalViewState::default() + }); normalize_state(state, projection); + state.viewport_size = Some(viewport_size); let rows = retained_rows(projection); - let size = state.viewport_size?; - let geometry = view_geometry(&rows, state, size.rows); + let geometry = view_geometry(&rows, state, viewport_size.rows); Some(TerminalViewStatus { at_bottom: geometry.scroll_offset == 0, scroll_offset: geometry.scroll_offset, @@ -228,6 +242,21 @@ impl TerminalManager { }) } + /// Return the publication-consistent child grid size for one view. + #[must_use] + pub(crate) fn screen_size_for_view(&self, key: TerminalViewKey) -> Option { + self.sessions + .get(&key.buffer_id) + .map(|session| session.screen.projection_ref().size) + } + + /// Return fresh geometric status for one registered view. + #[must_use] + pub fn view_status(&mut self, key: TerminalViewKey) -> Option { + let size = self.views.get(&key)?.viewport_size?; + self.view_status_for_size(key, size) + } + /// Clear selection and resume live-tail following for one view. pub fn scroll_to_bottom(&mut self, key: TerminalViewKey) -> bool { let Some(state) = self.views.get_mut(&key) else { @@ -510,7 +539,10 @@ fn clamp_or_clear(rows: &RetainedRows<'_>, anchor: LogicalCellAnchor) -> Option< return Some(anchor_for(rows, resolved)); } let first = rows.first()?; - (anchor.logical_line_id < first.logical_line_id).then(|| row_lead(first)) + (anchor.logical_line_id < first.logical_line_id + || (anchor.logical_line_id == first.logical_line_id + && anchor.cell_offset < first.cell_offset)) + .then(|| row_lead(first)) } fn normalize_state(state: &mut TerminalViewState, projection: BorrowedScreenProjection<'_>) { @@ -920,6 +952,49 @@ mod tests { assert_eq!(bytes, "界".as_bytes()); } + #[test] + fn partially_evicted_wrapped_anchor_clamps_to_first_surviving_cell() { + let source = projection( + vec![row(7, 4, "tail", true)], + vec![row(8, 0, "next", false)], + ); + let first_survivor = LogicalCellAnchor { + logical_line_id: 7, + cell_offset: 4, + }; + let mut state = TerminalViewState { + top: Some(LogicalCellAnchor { + logical_line_id: 7, + cell_offset: 1, + }), + selection: Some(TerminalSelection { + anchor: LogicalCellAnchor { + logical_line_id: 7, + cell_offset: 2, + }, + head: LogicalCellAnchor { + logical_line_id: 8, + cell_offset: 1, + }, + }), + ..TerminalViewState::default() + }; + + normalize_state(&mut state, source.as_borrowed()); + + assert_eq!(state.top, Some(first_survivor)); + assert_eq!( + state.selection, + Some(TerminalSelection { + anchor: first_survivor, + head: LogicalCellAnchor { + logical_line_id: 8, + cell_offset: 1, + }, + }) + ); + } + #[test] fn alternate_switch_clears_view_anchors_and_selection() { let source = ScreenProjection { diff --git a/tests/m9_6_acceptance.rs b/tests/m9_6_acceptance.rs index 39c4182..4b5166d 100644 --- a/tests/m9_6_acceptance.rs +++ b/tests/m9_6_acceptance.rs @@ -26,8 +26,11 @@ use std::path::PathBuf; use std::time::{Duration, Instant}; +use crossterm::event::{KeyCode, KeyModifiers}; use pmacs::editor::EditorState; +use pmacs::frontend::KeyEvent; use pmacs::lua_bindings::PackageInstallOverride; +use pmacs::protocol::FrontendId; use tempfile::TempDir; fn fake_mcp_path() -> String { @@ -813,17 +816,19 @@ fn m9_6_mx_palette_invokes_mcp_tool_through_minibuffer_reentry() { .lua_host .eval( Some("type-cmd-name"), - r#" - pmacs.minibuffer.set_contents("m9_6-echo") - pmacs.minibuffer.accept() - "#, + r#"pmacs.minibuffer.set_contents("m9_6-echo")"#, ) .expect("accept command name"); + state.dispatch_key( + FrontendId::LOCAL, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); + // Step 3: the tool's argument prompt must now be active. This is // the re-entrant minibuffer behavior: outer session was taken, // inner session was begun, and the begin happened from inside - // the outer accept's on_accept callback. + // the outer accept's authenticated dispatch callback. let inner_active: bool = state .lua_host .lua() @@ -841,12 +846,13 @@ fn m9_6_mx_palette_invokes_mcp_tool_through_minibuffer_reentry() { .lua_host .eval( Some("type-arg"), - r#" - pmacs.minibuffer.set_contents("through M-x") - pmacs.minibuffer.accept() - "#, + r#"pmacs.minibuffer.set_contents("through M-x")"#, ) .expect("accept arg"); + state.dispatch_key( + FrontendId::LOCAL, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ); assert!( pump_until_status_contains(&mut state, "through M-x", Duration::from_secs(2)), "M-x → arg-prompt → dispatch must reach status; status={:?}", diff --git a/tests/vterm_stage2_acceptance.rs b/tests/vterm_stage2_acceptance.rs index 86d1201..017a838 100644 --- a/tests/vterm_stage2_acceptance.rs +++ b/tests/vterm_stage2_acceptance.rs @@ -238,6 +238,46 @@ fn lua_surface_is_strict_fresh_transactional_and_context_safe() { .expect("pcall implicit scroll"); assert!(!ok); assert!(error.contains("interactive frontend context")); + + let (ok, error): (bool, String) = lua + .load( + r" + local ok, err = pcall(function() + pmacs.command.invoke_interactive('terminal.scroll-up') + end) + return ok, tostring(err) + ", + ) + .eval() + .expect("pcall ambient interactive invoke"); + assert!(!ok); + assert!(error.contains("active interactive frontend context")); + + for (field, source) in [ + ( + "frontend", + "pmacs.terminal.view_state { window = 1, buffer = TERM_BUFFER, active = true }", + ), + ( + "window", + "pmacs.terminal.view_state { frontend = 1, buffer = TERM_BUFFER, active = true }", + ), + ( + "buffer", + "pmacs.terminal.view_state { frontend = 1, window = 1, active = true }", + ), + ] { + let (_, error): (bool, String) = lua + .load(format!( + "local ok, err = pcall(function() {source} end); return ok, tostring(err)" + )) + .eval() + .expect("pcall incomplete explicit context"); + assert!( + error.contains(&format!("missing field `{field}`")), + "missing `{field}` surfaced as {error:?}" + ); + } } state From 49efa805b5931deedd7f78845157379f2f34e4a5 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 22 Jul 2026 09:10:30 -0400 Subject: [PATCH 14/15] docs(vterm): record PR 130 review round 2 Update the durable handoff and active-work ledger with the second-review fix checkpoint and exact final gate evidence. Co-authored-by: OpenAI Codex --- docs/active-work.md | 21 +++++++++++++-------- docs/agent-handoff.md | 15 +++++++++++---- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index cae4034..c1212a9 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -1,6 +1,6 @@ # Active work — cross-machine resume ledger -**Snapshot: 2026-07-21.** This file records volatile work that has not +**Snapshot: 2026-07-22.** This file records volatile work that has not landed on `main`. Read it after `docs/agent-handoff.md`. Remove completed entries when their PR merges; do not let this become a second permanent backlog. @@ -55,7 +55,7 @@ If it does not, stop and repair the remote/fetch configuration. ## Vterm Stage 2 implementation lane - Portable branch: `githubsucks/vterm-tui` -- Feature head after review round 1: `8702791` +- Feature head after review round 2: `b9a7e40` - Base: canonical `main` @ `f1a2f75` (handoff refresh #128 atop config registry #127 and Vterm Stage 1 #126). - PR: #130, , open against @@ -70,16 +70,21 @@ If it does not, stop and repair the remote/fetch configuration. editor bindings; non-terminal context operations error; controller replacement is atomic per frontend; zero-area layouts retain view anchors; view projection borrows retained rows instead of deep-cloning scrollback. +- Review round 2: addressed. Partial eviction clamps anchors to the first + surviving wrapped-line cell; `invoke_interactive` now inherits only an + authenticated dispatch origin; explicit context failures are named; terminal + mouse routing reads geometry without cloning cells; the framing records the + v18 semantic-controller boundary and bracketed-paste injection deferral. - Implementation commits: `39e07cb`, `7c39535`, `0a846d9`, `0dacac7`, - `dc92257`, merge `0ddff24`, integration hardening `da8f6ae`, and first-review - fixes `8702791`. -- Review-round final verification: `cargo fmt --check`; strict workspace - Clippy; 1,743 default + 1,919 CRDT library tests (3 ignored each); + `dc92257`, merge `0ddff24`, integration hardening `da8f6ae`, first-review + fixes `8702791`, and second-review fixes `b9a7e40`. +- Second-review final verification: `cargo fmt --check`; strict workspace + Clippy; 1,744 default + 1,920 CRDT library tests (3 ignored each); Stage 1 acceptance 9 default + 10 CRDT; Stage 2 acceptance 4 default + 4 CRDT; statusline acceptance 7 default + 8 CRDT; M4 114 passed - (3 ignored, 1 filtered); required GPU 109; workspace 2,871 passed across + (3 ignored, 1 filtered); required GPU 109; workspace 2,872 passed across 81 suites (19 ignored, 1 filtered); `git diff --check` clean. -- Next: second user review. Do not merge without explicit authorization. +- Next: user review. Do not merge without explicit authorization. Recovery worktree on a machine that does not already own the branch: diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index a66d0cd..9eada35 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -1,6 +1,6 @@ # Agent handoff — cross-machine continuity -**Last updated: 2026-07-22, after Vterm Stage 2 PR #130 review round 1 +**Last updated: 2026-07-22, after Vterm Stage 2 PR #130 review round 2 was addressed on `vterm-tui`; config registry (#127), Vterm Stage 1 terminal core (#126), and handoff refresh (#128) are landed on `main`, atop completed Themes Arc 4 (#120/#124/#125). Vterm Stage 3 is not @@ -261,11 +261,18 @@ commands, read `docs/active-work.md` immediately after this file. duplicate detach work, and replaced per-view deep scrollback clones with borrowed live/published row projections. Focused child-input coverage pins both unescaped bound-key passthrough and `C-c C-c`. - - Review-round final gate: `cargo fmt --check`; strict workspace Clippy; - 1,743 default + 1,919 CRDT library tests (3 ignored each); Stage 1 + - PR #130 review round 2 (`b9a7e40`) clamps anchors into the first + surviving cell when eviction cuts through a wrapped logical line, prevents + ambient `active_frontend` from minting interactive Lua authority, names + malformed explicit-context fields, restores dispatcher rationale, and + removes owned cell snapshots from terminal mouse routing. The framing now + records the transient v18 semantic-controller boundary and bracketed-paste + injection deferral. + - Second-review final gate: `cargo fmt --check`; strict workspace Clippy; + 1,744 default + 1,920 CRDT library tests (3 ignored each); Stage 1 acceptance 9 default + 10 CRDT; Stage 2 acceptance 4 default + 4 CRDT; statusline acceptance 7 default + 8 CRDT; M4 114 passed (3 ignored, - 1 filtered); required GPU 109; workspace 2,871 passed across 81 suites + 1 filtered); required GPU 109; workspace 2,872 passed across 81 suites (19 ignored, 1 filtered); `git diff --check` clean. - **PARKED: kill-ring browser + persistence.** Revision 2 framing is preserved on branch `kill-ring-browser`, but its `0efb5cd` scout is stale From 506912a06908c47d8d660bb5d943801d57fb6f45 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 22 Jul 2026 10:29:54 -0400 Subject: [PATCH 15/15] docs(vterm): record current-main integration gates Record the resolved mode-system integration head, exact post-integration verification, and isolated M4 timeout reruns before the authorized merge. --- docs/active-work.md | 23 +++++++++++++---------- docs/agent-handoff.md | 16 ++++++++++------ 2 files changed, 23 insertions(+), 16 deletions(-) diff --git a/docs/active-work.md b/docs/active-work.md index 0349d14..909bf85 100644 --- a/docs/active-work.md +++ b/docs/active-work.md @@ -55,7 +55,7 @@ If it does not, stop and repair the remote/fetch configuration. ## Vterm Stage 2 implementation lane - Portable branch: `githubsucks/vterm-tui` -- Feature head after review round 2: `b9a7e40` +- Integrated feature head: `3f0252f` (review-fix head `b9a7e40`) - Base: originally canonical `main` @ `f1a2f75`; current canonical `main` @ `d5d9b9c` (mode system wiring #129 and handoff #131) is integrated before merge. @@ -78,15 +78,18 @@ If it does not, stop and repair the remote/fetch configuration. v18 semantic-controller boundary and bracketed-paste injection deferral. - Implementation commits: `39e07cb`, `7c39535`, `0a846d9`, `0dacac7`, `dc92257`, merge `0ddff24`, integration hardening `da8f6ae`, first-review - fixes `8702791`, and second-review fixes `b9a7e40`. -- Second-review final verification: `cargo fmt --check`; strict workspace - Clippy; 1,744 default + 1,920 CRDT library tests (3 ignored each); - Stage 1 acceptance 9 default + 10 CRDT; Stage 2 acceptance 4 default + - 4 CRDT; statusline acceptance 7 default + 8 CRDT; M4 114 passed - (3 ignored, 1 filtered); required GPU 109; workspace 2,872 passed across - 81 suites (19 ignored, 1 filtered); `git diff --check` clean. -- Next: integrate canonical `main`, rerun gates, then merge PR #130 as - authorized. + fixes `8702791`, second-review fixes `b9a7e40`, and current-main integration + `3f0252f`. +- Post-integration verification: `cargo fmt --check`; strict workspace + Clippy; 1,753 default + 1,929 CRDT library tests (3 ignored each); + mode-system acceptance 1 default + 1 CRDT; Stage 1 acceptance 9 default + + 10 CRDT; Stage 2 acceptance 4 default + 4 CRDT; statusline acceptance + 7 default + 8 CRDT; M4 114 passed (3 ignored, 1 filtered); required GPU + 109; workspace 2,882 passed across 82 suites (19 ignored, 1 filtered); + `git diff --check` clean. The first parallel M4 attempt timed out after + partial progress; a serial isolation pass and the immediate exact parallel + rerun both passed, and the workspace sweep also passed. +- Next: push the integrated head and merge PR #130 as authorized. Recovery worktree on a machine that does not already own the branch: diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index 7589299..ef07948 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -286,12 +286,16 @@ commands, read `docs/active-work.md` immediately after this file. removes owned cell snapshots from terminal mouse routing. The framing now records the transient v18 semantic-controller boundary and bracketed-paste injection deferral. - - Second-review final gate: `cargo fmt --check`; strict workspace Clippy; - 1,744 default + 1,920 CRDT library tests (3 ignored each); Stage 1 - acceptance 9 default + 10 CRDT; Stage 2 acceptance 4 default + 4 CRDT; - statusline acceptance 7 default + 8 CRDT; M4 114 passed (3 ignored, - 1 filtered); required GPU 109; workspace 2,872 passed across 81 suites - (19 ignored, 1 filtered); `git diff --check` clean. + - Current-main integration (`3f0252f`) preserves per-frontend terminal + dispatch while applying the landed mode-scoped keymap, and exposes the + `mode`, `terminal`, and `lsp` statusline providers together. + - Post-integration gate: `cargo fmt --check`; strict workspace Clippy; + 1,753 default + 1,929 CRDT library tests (3 ignored each); mode-system + acceptance 1 default + 1 CRDT; Stage 1 acceptance 9 default + 10 CRDT; + Stage 2 acceptance 4 default + 4 CRDT; statusline acceptance 7 default + + 8 CRDT; M4 114 passed (3 ignored, 1 filtered); required GPU 109; + workspace 2,882 passed across 82 suites (19 ignored, 1 filtered); + `git diff --check` clean. - **PARKED: kill-ring browser + persistence.** Revision 2 framing is preserved on branch `kill-ring-browser`, but its `0efb5cd` scout is stale and must be repeated before implementation. No PR or implementation is