pmacs/docs/vterm-framing.md

67 KiB
Raw Blame History

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; 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 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 readers, stdin writes, resize, exit/restart state, and final drain. src/ansi.rs already owns a streaming UTF-8/CSI/OSC parser, but deliberately emits only the line-oriented subset compile-mode needs. Vterm does not replace either subsystem. It extends their contracts and adds the missing terminal screen state machine.

Arc 5 stage 2 ships as three separately reviewed PRs:

  1. terminal core — full-screen VT events, TerminalScreen, internal session ownership/contracts, and headless real-PTY acceptance;
  2. TUI integration — terminal-window composition, input, resize, scrollback, selection, and copy;
  3. GPU integration — protocol v19 terminal messages, semantic-daemon routing, and a native GPU terminal renderer.

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 7 — landed Stage 1 and reviewed Stage 2 contract

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, https://github.com/levineuwirth/pmacs/pull/126. That landed stage is deliberately headless; this Stage 2 branch adds the Lua and TUI surfaces while leaving GPU/protocol integration for Stage 3.

0.1 Public seam and ownership

src/terminal/session.rs exports:

  • owned TerminalSpec { command, args, cwd, env, name, rows, cols, scrollback_rows }, with new and strict pre-side-effect validate;
  • TerminalProcessState::{Running, Exited(i32), Signaled(String), Crashed(String)};
  • owned TerminalSnapshot { buffer_id, size, cells, cursor, title, screen_generation, selection, scroll_offset, at_bottom, pid, process };
  • SharedTerminalManager = Rc<RefCell<TerminalManager>>;
  • TerminalManager::{new, len, is_empty, open, is_terminal, process_id, snapshot, tick, send, resize, terminate, prune, shutdown}.

The Stage 1 snapshot(BufferId) is intentionally context-free: selection=[], scroll_offset=0, and at_bottom=true. Stage 2 adds per-(FrontendId, WindowId, BufferId) state and an owned snapshot_for_view(...); it must not add a second screen.

EditorState owns the one shared manager. Tick order is supervisor tick → terminal-owned PID drain/watchdog/prune → process.after-tick; LSP, MCP, and ordinary Lua process events retain their existing owners. ProcessSpec gained an AnsiParserProfile: ProcessSpec::new and Lua ansi=true stay LineOriented, while terminal sessions explicitly request FullScreen. Synchronous unpublished terminal spawn failure emits no orphan process event.

Terminal identity buffers are pathless, clean, empty, and buffer-read-only. The guard runs before direct edits, split Lua begin/skip-intercept edits, undo/redo, and local/remote CRDT content import. Attaching an immutable empty CRDT for semantic identity remains valid.

0.2 Stage 1 acceptance mapping

All fourteen Stage 1 criteria are implemented:

  1. whole/split parser equivalence: screen::tests::parser_split_points_produce_identical_screen plus the ANSI split matrix;
  2. malformed/truncated/over-cap recovery: the 44 ansi::tests, including split UTF-8, ignored CSI/OSC/DCS, and forward-progress cases;
  3. cursor/region/edit exactness: cursor_erase_insert_delete_scroll_and_margins_mutate_exact_regions;
  4. pending wrap/wide/combining invariants: wide overwrite, split ZWJ/RI/ modifier/variation, right-edge, and continuation tests;
  5. alternate/synchronized publication: alternate_screen_preserves_main_and_has_no_history, synchronized_output_gates_snapshot_and_finish_releases, and watchdog;
  6. DEC G0/G1 + SI/SO: acs_and_device_replies_are_exact;
  7. SGR fidelity and ignored attributes: ANSI SGR/color/underline tests plus screen operation coverage;
  8. resize semantics: soft-wrap reflow, wide-boundary/cursor/hard-break tests, alternate clipping, and atomic invalid resize;
  9. dual history limits: history_obeys_row_and_cell_caps;
  10. bounded DA/DSR/CPR and unsupported-output safety: acs_and_device_replies_are_exact plus parser ignore cases;
  11. strict owned specifications: strict_owned_spec_rejects_before_spawn_and_is_mutation_independent;
  12. real adversarial PTY/final drain: final_output_precedes_exact_nonzero_annotation_and_buffer_is_retained splits ESC/CSI writes, observes addressed alternate-screen output while running, unblocks raw stdin through send, restores the main screen, and proves final output precedes the exact PID/outcome annotation; zero, non-zero, signal, wrapped, and one-row annotations are separately pinned;
  13. lifecycle cleanup: transactional failure, live buffer-kill prune/reap, and TERM-ignoring editor shutdown acceptance;
  14. read-only/CRDT invariants: default + CRDT shared acceptance and focused buffer unit tests cover every mutation route and empty bootstrap.

0.3 Final gates and bite

The initial delivery gate run fixed missing acceptance-crate documentation; PR CI then exposed Darwin's numeric strsignal suffix, fixed in 962944b. Review round 1's first Clippy pass found only identical LF/IND match arms, consolidated in bf972a7. Review round 2 added one screen unit and one shared acceptance case; the complete sequence restarted from gate 1:

  • cargo fmt --check: clean;
  • cargo clippy --workspace --all-targets -- -D warnings: clean;
  • default library: 1,661 passed, 3 ignored;
  • CRDT library: 1,837 passed, 3 ignored;
  • Stage 1 acceptance: 9 default + 10 CRDT passed;
  • M4 acceptance: 114 passed, 3 ignored, 1 basedpyright filtered;
  • required GPU: 109 passed;
  • workspace: 2,769 passed across 79 suites, 19 ignored, 1 filtered;
  • git diff --check: clean.

scripts/bite main src/lib.rs --test vterm_stage1_acceptance returned bite: OK: the swapped pre-stage crate root cannot compile the new terminal API. This is explicitly the helper's weaker compile-time API bite, not a clean behavioral assertion failure.

scripts/bite HEAD^ src/ansi.rs --lib parser_split_points_produce_identical_screen returned bite: OK with a clean behavioral assertion failure: the pre-dispatch parser left the cursor at row zero/column four instead of applying NEL/RI/IND and landing at row one/column zero.

scripts/bite HEAD^ src/terminal/screen.rs --test vterm_stage1_acceptance 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 Stage 2 re-scout resolutions

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. 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

The first external review found no ownership, mutation-guard, parser-cap, or security regressions. This round resolves its three merge-adjacent findings:

  • ESC D (IND), ESC E (NEL), and ESC M (RI) are typed full-screen operations. RI scrolls down only at the top margin; IND/NEL scroll up at the bottom margin, with NEL additionally returning to column zero. The parser's every-byte-split matrix and focused screen-margin test pin the complete path.
  • Terminal children no longer inherit an arbitrary host TERM; absent a caller override, their process environment gets TERM=xterm-256color.
  • The TERM-ignoring shutdown acceptance uses kill(pid, 0) through nix instead of Linux-only /proc, so macOS now exercises the assertion.
  • Resize retains every surviving application tab stop and installs default stops only in newly added columns.

spawn_ansi_parser intentionally calls AnsiParser::finish() on channel disconnect for both profiles. For existing line-oriented compile/REPL consumers, EOF therefore delivers trailing partial text and required synthetic style/alternate-screen balancing that older code dropped. This is an intentional latent-bug fix and an observable compatibility contract.

The Stage 2 Lua open surface must uniquify colliding default buffer names (*terminal:sh*, *terminal:sh*<2>, and so on) before terminal creation becomes user-visible.

0.6 Stage 1 review round 2

The second external review found no ownership, lifecycle, mutation-guard, transactional-spawn, final-drain, parser-cap, or reflow defects and judged Stage 1 merge-ready. Its renderer-boundary hardening and cheap cleanups are resolved before Stage 2:

  • TerminalScreen::write_text drops every char::is_control() value before grapheme segmentation, so parser-produced C1 and direct-event C0/C1 bytes cannot enter copyable or renderable cells. Unit and shared acceptance tests pin a byte-identical blank snapshot.
  • SGR mouse release reports retain the released left/middle/right button code and use the lowercase m final.
  • The dead line_feed mode parameter and contradictory wide-grapheme branch are removed; all logical-line ID allocation saturates consistently; and terminal prune clears stale round-trip input membership.

Out-of-range DECSTBM bottom clamping, CSI-intermediate clone removal, and a separately named configuration-time scrollback-row cap remain explicit deferrals in §11.

1. Problem and ownership boundary

Pmacs can supervise a PTY and can parse enough ANSI to turn command output into a line-oriented compilation buffer. It cannot host nvim, htop, a shell using cursor motion, or any other application whose output means “mutate a terminal screen” rather than “append text.” The current parser intentionally recognizes and discards cursor addressing, alternate-screen state, scrolling, and most terminal modes. The editor renderer only knows an ordinary rope and its views.

The missing abstraction is a real terminal state machine:

PTY bytes -> AnsiParser -> terminal operations -> TerminalScreen
                                               -> response bytes -> PTY stdin
Frontend input -> terminal input encoder ------------------------^ 

TerminalScreen -> TUI cell composition
               -> protocol v19 TerminalFrame -> GPU cell layout

Ownership is explicit:

  • ProcessSupervisor owns the child, PTY file descriptors, bounded worker pipeline, signal delivery, and final drain.
  • AnsiParser owns byte-stream framing and escape-sequence decoding. It does not own a screen.
  • TerminalScreen owns main/alternate grids, cursor and modes, scroll regions, tab stops, and scrollback.
  • TerminalManager owns the mapping from a special editor BufferId to one PTY process plus one TerminalScreen, and owns per-window/per-frontend scroll/selection state.
  • The ordinary buffer is an identity and lifecycle anchor only. Terminal screen contents are never mirrored into its rope.
  • TUI and GPU frontends own final glyph drawing. They consume cells; they do not reinterpret ANSI or maintain a second VT state machine.

This keeps the existing semantics-down boundary intact. Document text remains a CRDT/rope semantic surface. A terminal is inherently a cell protocol, so its new wire family carries terminal cells, not daemon-formatted document text and not pixels.

2. Ground truth in the current tree

The implementation must preserve these existing contracts:

  • ProcessMode::Pty, write_stdin, resize_pty, group-directed signals, bounded output channels, and the TERM/KILL final drain already exist in src/process.rs.
  • ProcessSpec::ansi_events moves parsing onto a bounded worker and emits ProcessEventKind::Ansi; raw pipe/LSP consumers retain their byte contract.
  • AnsiParser is stateful across arbitrary feed boundaries, has a per-state escape-sequence cap, safely recovers malformed sequences, carries truecolor and underline color, and resets after finish().
  • The parser currently emits Text, SetStyle, line-oriented controls, Erase, SetTitle, and alternate-screen markers. Cursor motion and most CSI/DEC operations are parsed but discarded by design.
  • Alternate-screen suppression is currently parser-global and load-bearing for compile/REPL consumers. Vterm therefore requires an explicit parser profile: LineOriented preserves today's suppression and event contract; FullScreen emits all screen/mode operations. ProcessSpec selects the profile when ansi_events is enabled; existing Lua process specs default to LineOriented, while terminal sessions construct FullScreen specs.
  • EditorState::tick_processes is the main-thread process drain point. LSP/MCP and Lua packages drain events only for process IDs they own.
  • EditorCore::round_trip_buffers already disables optimistic frontend edits for special buffer-local input surfaces.
  • Grid frontends already receive generic CellDelta; a terminal window can be composed into that grid without a new grid protocol.
  • Semantic frontends hold document text through CRDT snapshots and receive byte-anchored style/decorations. They require a new terminal-specific message because an empty terminal identity buffer contains no screen text.
  • Desktop persistence saves file-backed buffers only. A pathless terminal buffer is already omitted; no special persistence exception is required.

3. Terminal core model

3.1 Types and files

Stage 1 adds a src/terminal/ module rather than growing editor.rs or ansi.rs into a second monolith:

  • screen.rs: TerminalScreen, TerminalGrid, TerminalRow, cursor, modes, scrollback, resize, snapshots;
  • input.rs: normalized key/mouse/paste to VT byte encoding;
  • session.rs: TerminalManager, TerminalSession, process ownership and event application;
  • view.rs (stage 2): per-context viewport/selection helpers and TUI composition.

src/ansi.rs remains the one escape parser. It gains terminal operations; it does not depend on editor state or TerminalScreen.

The public core shape is:

pub struct TerminalManager { /* BufferId -> TerminalSession */ }

pub struct TerminalSelectionSpan {
    pub row: u32,
    pub start_col: u32, // inclusive
    pub end_col: u32,   // exclusive
}

pub enum TerminalProcessState {
    Running,
    Exited(i32),
    Signaled(String),
    Crashed(String),
}

pub struct TerminalSnapshot {
    pub buffer_id: BufferId,
    pub size: CellSize,
    pub cells: Vec<Cell>,       // row-major visible slice
    pub cursor: Option<CellCoord>,
    pub title: Option<String>,
    pub screen_generation: u64,
    pub selection: Vec<TerminalSelectionSpan>,
    pub scroll_offset: u32,
    pub at_bottom: bool,
    pub pid: u32,
    pub process: TerminalProcessState,
}

A snapshot is owned data taken only after all parser events for the current main-thread tick are applied. Renderers never borrow the mutable screen across Lua, editor-core, or process-supervisor calls.

3.2 Parser extension

AnsiEvent gains enough operations to drive a VT-style screen:

  • printable text, BEL, CR, LF/VT/FF, BS, HT, and tab-stop set/clear;
  • relative and absolute cursor movement (CUU/CUD/CUF/CUB, CNL/CPL, CHA/HPA, VPA, CUP/HVP);
  • erase display/line/characters;
  • insert/delete characters and lines;
  • scroll up/down and set/reset scrolling margins;
  • save/restore cursor for both DEC and CSI spellings;
  • main/alternate screen enter/exit (47, 1047, 1049);
  • mode set/reset for insert, origin, autowrap, application cursor, application keypad, cursor visibility, bracketed paste, focus reporting, synchronized output (?2026), and supported mouse reporting modes;
  • SGR and title changes;
  • DEC G0/G1 character-set selection plus SI/SO, including the line-drawing characters full-screen TUIs depend on;
  • device-status/device-attribute queries represented as typed requests. The session, not the parser, writes bounded response bytes to PTY stdin.

Unknown, private, or malformed sequences stay non-fatal and bounded. Parser state always makes forward progress. DCS/APC/PM payloads remain ignored under the same per-sequence cap; they must not leak into visible text.

The parser-profile split is compatibility-critical. Full-screen support must not make compile-mode start appending alternate-screen text or receiving cursor events it does not understand. AnsiParser::new() and the existing Lua ansi = true process option retain the line-oriented profile; terminal session construction is the only initial full-screen caller. On finish(), LineOriented preserves today's synthetic alternate-screen/style balancing. FullScreen flushes pending text but does not invent an alternate-screen exit or clear cells. The session manager, after applying the actual final parser events, adds the process-exit annotation described in §4.1. Parser internals reset in both profiles.

The shared Cell::Style remains unchanged in this arc. Existing support covers indexed/truecolor foreground/background, bold, italic, underline variants, underline color, and reverse. Faint, conceal, blink, and strikethrough remain ignored rather than being mapped to an unrelated attribute. Extending the shared style would change every cell-carrying postcard encoding and is a separate protocol-wide decision, not hidden vterm scope.

3.3 Screen invariants

TerminalScreen maintains two grids:

  • main screen with bounded scrollback;
  • alternate screen with no scrollback and an independently saved cursor.

Every physical row records a monotonic logical-line ID, its cell offset within that logical line, and whether it ended in a soft autowrap. This is necessary for copy and resize: hard line breaks become \n; soft-wrapped physical rows are joined into one logical line. Per-view top/selection anchors use (line_id, cell_offset), not a physical row index, so reflow can remap them.

Core invariants:

  • cells.len() == rows * cols; every row has exactly cols cells.
  • Cursor and scrolling margins are always in bounds after every operation.
  • A wide grapheme occupies a leading glyph plus Glyph::Continuation; an overwrite, erase, insert, delete, or resize never leaves an orphaned half.
  • Combining codepoints extend the preceding grapheme when one exists; at the left edge they combine with a space cell. Cluster byte length is bounded.
  • Autowrap uses the pending-wrap rule: writing the last column arms a wrap; the following printable grapheme performs it. Cursor motion/control clears the pending wrap where VT behavior requires.
  • Origin mode interprets absolute row addressing relative to the active scrolling region.
  • Insert/delete/scroll operations affect only their defined region and fill exposed cells with the current erase style.
  • Entering 1049 saves the main cursor and clears the alternate grid; leaving it restores the main grid/cursor. Repeated set/reset is idempotent.
  • Resizing the main screen reflows soft-wrapped logical lines, preserves hard breaks and logical-line IDs, and maps the cursor to the corresponding logical offset. The alternate screen is never reflowed: it is clipped/padded in place, matching a full-screen application's expectation that it will repaint after SIGWINCH.
  • Scrollback eviction removes the oldest complete physical rows. It clamps a per-view anchor whose logical line was evicted to the oldest retained row. The default cap is 10,000 rows and an independent 4,000,000-cell budget; either limit may trigger eviction.

Synchronized output is a publication gate, not a second screen. Operations continue mutating TerminalScreen, but snapshots retain the last published generation until ?2026l. Exit/truncation releases the final state. A bounded one-second watchdog also releases and clears synchronization if a buggy child never resets it, preventing a permanently frozen editor surface.

Hard limits are shared constants, not frontend-local guesses:

  • maximum rows: 512;
  • maximum columns: 512;
  • maximum visible cells: 262,144;
  • maximum UTF-8 bytes in one grapheme cluster: 256;
  • maximum retained history cells: 4,000,000;
  • maximum parser control-string payload: the existing 1 KiB cap.

Invalid creation/resize arguments reject atomically. A rejected resize leaves the process and prior screen unchanged.

3.4 Device responses

The core responds to the small query set required by normal shells and TUIs:

  • primary and secondary device attributes;
  • operating-status report;
  • cursor-position report, using current origin semantics.

Responses are fixed templates plus checked decimal coordinates and pass through the existing bounded write_stdin queue. OSC 52 clipboard writes, window manipulation, palette mutation, hyperlinks, sixel, and arbitrary DCS responses are not honored. Child output is untrusted; it cannot directly set the host clipboard or execute an editor command.

4. Session, buffer, and Lua contract

4.1 Session lifecycle

One terminal session owns exactly one process ID, one screen, and one identity buffer. A buffer may appear in several windows/frontends, but there is never a second parser or screen for it.

Creation order is transactional:

  1. validate and copy the complete TerminalSpec;
  2. create the read-only identity buffer;
  3. spawn a raw PTY with ansi_events = FullScreen;
  4. install the session in TerminalManager;
  5. mark the buffer round-trip-only.

Stage 1 exposes that operation as a Rust manager contract for headless tests and future callers, but deliberately registers no interactive command: opening an unrenderable blank terminal buffer would be a broken partial feature. Stage 2's Lua binding calls the same operation and switches the caller's active window only after it succeeds. On synchronous spawn failure, the temporary buffer is removed and no session entry remains. The terminal process ID is not exposed through pmacs.process; only TerminalManager drains its events, so two consumers cannot steal batches from each other.

EditorState::tick_processes becomes:

  1. supervisor tick();
  2. terminal manager drains only its owned process IDs, applies all ANSI batches, queues device responses, and records terminal exit state;
  3. the existing process.after-tick hook runs;
  4. LSP/MCP retain their existing later ticks.

At process exit, final drained output is applied first. The manager then writes one synthetic, default-style hard line into the active terminal screen: Process <PID> exited normally with code 0 for zero; Process <PID> exited abnormally with code <code> for a non-zero code; or Process <PID> exited abnormally with signal <signal> for signal termination. It emits CRLF first when needed so the annotation never overwrites child text. The annotation is terminal-owned (not parser output and not rope text), visible and copyable like the rest of the screen. The buffer remains until the user kills it. Killing the buffer terminates a still-live process/session. A periodic prune handles all buffer-removal paths, including Lua callers that bypass the friendly terminal close API. Editor shutdown uses the existing supervisor shutdown path and cannot restart a terminal.

The wire-visible process state has only the four variants above: synchronous spawn failure never publishes a session, and terminate remains Running until the supervisor reports its final outcome. Crash/signal strings are sanitized to one line and bounded before snapshots or Lua metadata are built.

4.2 Buffer semantics

A terminal buffer is pathless, unmodified, and read-only. Read-only is a buffer-owned invariant, not only an intercept view: Buffer gains a flag and typed error checked by ordinary edits, intercept-skipping host edits, undo/ redo, and local/remote CRDT content mutation. The terminal manager sets it before publishing the session. The rope stays empty for the session lifetime.

Consequences are deliberate:

  • buffer lists and window switching see a normal named buffer;
  • normal save/autosave/LSP/syntax/CRDT editing does not apply;
  • desktop save omits it because it has no file path;
  • terminal scrollback does not participate in ordinary document search;
  • copy reads from terminal rows, never from a hidden mirror rope;
  • killing the buffer is the single editor-lifecycle teardown signal.

Semantic bootstrap may attach an immutable empty CRDT state and send its BufferSnapshot so v18/v19 mirrors can track the buffer identity and CursorByte without decode/state failure. No terminal contents enter that CRDT, and local/remote CRDT edit validation rejects the read-only buffer. The stage-3 terminal frame is the authoritative visible semantic surface; a forged remote operation cannot mutate the empty identity rope.

No hidden “text projection” is maintained. Two representations would drift on cursor rewrites, erase operations, alternate-screen swaps, and resize.

4.3 Lua API

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.

local buffer = pmacs.terminal.open {
  command = os.getenv('SHELL') or '/bin/sh',
  args = {},
  cwd = nil,                 -- inherits instance cwd
  env = {},
  name = nil,                -- default: *terminal:<command>*
  rows = 24,
  cols = 80,
  scrollback_rows = 10000,
}

pmacs.terminal.is_terminal(buffer)       -- boolean
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)             -- boolean; interactive origin required
pmacs.terminal.scroll_to_bottom()        -- boolean; interactive origin required
pmacs.terminal.copy_selection()          -- boolean; interactive origin required

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.

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:

{
  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:

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, cursor, and status

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.

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.

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.

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 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.

terminal.lua registers a right-side provider named terminal, priority 10, face ui.modeline.terminal. It emits TERM while running, TERM:<code> on normal exit, TERM:<signal> on signal, or TERM:ERR on crash, and appends ↑<scroll_offset> only when scroll_offset > 0. An unset child face inherits the existing modeline foreground through the Arc 4 contract.

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. 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 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 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, F1F12, 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.

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.

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 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

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 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.

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.

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.

5.4 Durable control and resize

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.

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

Protocol v19 appends, never inserts, these final variants:

InstanceMessage::TerminalFrame {
    buffer_id: BufferId,
    size: CellSize,
    cells: Vec<Cell>,
    cursor: Option<CellCoord>,
    title: Option<String>,
    screen_generation: u64,
    selection: Vec<TerminalSelectionSpan>,
    scroll_offset: u32,
    at_bottom: bool,
    pid: u32,
    process: TerminalProcessState,
}

FrontendEvent::TerminalResize {
    frontend_id: FrontendId,
    buffer_id: BufferId,
    size: CellSize,
}

FrontendEvent::TerminalPointer {
    frontend_id: FrontendId,
    buffer_id: BufferId,
    coord: CellCoord,
    kind: MouseKind,
    mods: Modifiers,
}

TerminalFrame is a complete visible-grid replacement. Empty is not a clear sentinel: valid terminal sizes are non-zero and cells.len() must equal area. Complete replacement is chosen over a second diff/cache protocol for the first GPU stage. screen_generation advances on screen/process/title mutation; scroll/selection have their own per-context epochs. The producer caches and compares the complete context payload, so a view-only change still sends even when screen_generation is unchanged, while an identical payload is silent.

All terminal frame fields are untrusted at the GPU boundary. Validation checks shared row/column/area limits, exact area, cursor bounds, title length, selection ordering/non-overlap/bounds, cluster UTF-8 and cluster-byte limits, continuation structure, and attachment absence. Invalid input is rejected atomically and the last valid terminal frame remains painted.

The daemon routes terminal resize/pointer events by authenticated session source. Claimed frontend and buffer must match the source's active terminal window. A mismatch is dropped without resizing, selecting, or writing PTY input.

The protocol remains compatible with v18 where structurally possible:

  • v18 grid peers need no new message and continue to receive composed CellDelta terminal windows;
  • v18 semantic peers receive the immutable empty identity snapshot but no terminal variant. They cannot display the terminal screen; terminal use from those peers is unsupported, while normal document editing remains supported;
  • v19 frontends gate the new outbound event variants on negotiated version;
  • postcard byte pins cover the old final variants plus the newly appended discriminants.

6.2 Semantic producer

When a semantic frontend's active buffer is a terminal, its producer emits TerminalFrame plus the existing global theme/font/statusline facts that still apply. It suppresses document-only style spans, decorations, inlays, block adornments, folds, file summaries, line numbers, and document cursor layout for that buffer. On switching back to a document, existing caches are invalidated so the first document frame is a full authoritative resync.

The terminal frame is scoped to the authenticated frontend/window context, because scrollback offset and selection are per view. A frame for one split or frontend must never overwrite another context's baseline.

A GPU frontend reports its terminal viewport in cells, computed from its own font metrics and pixel allocation. No pixel dimensions, glyph advances, or DPI cross the daemon boundary. The daemon accepts a resize only from the controlling active frontend defined in §5.4.

6.3 GPU renderer

The GPU keeps a dedicated terminal render mode keyed by active buffer_id. It does not synthesize rope text from cells. Layout rules:

  • one terminal column equals the active monospace cell advance;
  • Glyph::Continuation consumes a column and draws nothing;
  • clusters shape as one cell origin with the declared one/two-column footprint;
  • terminal foreground/background/reverse/style resolve from the cell, not syntax or UI faces;
  • selection spans resolve through ui.selection over child cells; cursor placement comes from terminal snapshot state;
  • rows never wrap in the frontend; clipping is by terminal cell bounds;
  • status band remains outside the terminal grid;
  • theme/font changes invalidate terminal shaping and geometry caches;
  • a font-size or window-size change recomputes the cell viewport and sends one TerminalResize after suppression of identical sizes.

Terminal mouse hit-testing is frontend-local pixel -> terminal cell. The GPU sends TerminalPointer, never a fake source byte offset.

7. Four-agent execution plan

The vterm roster is fixed at four agents for all three stages. Do not add review/scout agents; reuse these owners so state-machine decisions stay coherent.

Owner Stable scope Primary files
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, invariants, and method signatures before another lane edits callers.
  • Strict file ownership. src/editor.rs passes from lead to TUI only after 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 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 lead.

Per-stage utilization:

  • 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 landed on main as PR #126 at merge 643d1e1. Continue one clean PR at a time:

  1. Revision 7 framing review is complete and the implementation contract is approved; Stage 2 is implemented on vterm-tui, then gated and opened as the second PR;
  2. merge Stage 2 only when the user says;
  3. create pmacs-vterm-gpu, branch vterm-gpu, from the then-current main; implement, gate, and 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 base-branch deletion/auto-close risk and makes each PR's gate evidence honest.

9. Acceptance

Stage 1 — terminal core

  1. Feed every supported CSI/OSC/DEC sequence, including IND/NEL/RI, at every byte split; whole-feed and split-feed screens are identical. RI and forward-index operations additionally pin exact scrolling-margin behavior.
  2. Split UTF-8, malformed UTF-8, truncated escape, over-cap control string, and unknown private sequences recover without panic, unbounded growth, or visible escape leakage.
  3. Cursor absolute/relative movement, save/restore, origin mode, margins, insert/delete character/line, erase, and scroll mutate only the specified cells.
  4. Autowrap pending state, wide glyphs, combining clusters, overwrite, erase, and clipping never create orphan continuations.
  5. Main/alternate screen swaps preserve the main grid and cursor; alternate output never enters scrollback. Synchronized output publishes no intermediate frame and releases on reset, EOF, and watchdog expiry.
  6. DEC line drawing through G0/G1 + SI/SO renders the expected Unicode box glyphs across split feeds.
  7. SGR indexed/truecolor/underline/reverse survives screen operations; ignored attributes leave supported fields unchanged.
  8. Main-screen resize reflows only soft wraps, preserves cursor/logical-line identity and application tab stops, and adds defaults only in new columns; alternate-screen resize clips/pads without reflow.
  9. Scrollback obeys both row and cell budgets, evicts oldest rows, and keeps the visible grid exact.
  10. DA/DSR/CPR query events produce bounded exact response bytes; unsupported OSC/DCS cannot write stdin or clipboard.
  11. Strict owned TerminalSpec values reject before spawn and are mutation-independent after spawn.
  12. A real PTY child prints cursor-addressed/alternate-screen content in adversarial chunks; final output lands first, then exact normal/non-zero/ signal process annotations are visible and copyable before exit state.
  13. Spawn failure leaves no buffer/session/process residue. Killing a live terminal buffer terminates it; editor shutdown leaves no child/reader.
  14. Every rope mutation path (ordinary, intercept-skipping, undo/redo, local or remote CRDT edit/import) rejects the read-only terminal buffer and leaves its rope/revision/modified state unchanged; immutable empty CRDT bootstrap remains valid.

Stage 2 — TUI

  1. 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.
  2. A terminal window paints exact cells/styles inside its content rectangle; top/right padding, clipping, modeline, sibling splits, outside cells, and suppression of document/peer overlays are exact and control-free.
  3. 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.
  4. Printable, Ctrl, Alt, navigation/function keys, application cursor, focus, unknown keys, and paste produce exact PTY bytes; bracketed wrappers appear only when enabled.
  5. 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.
  6. 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.
  7. 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.
  8. 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.
  9. 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.
  10. 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.
  11. Two frontends and sibling splits over one terminal retain independent bottom/scroll/selection snapshots while sharing one process, screen, title, process outcome, and controller.
  12. 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.
  13. 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 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, terminal_escape_gates_local_bindings_and_double_escape_sends_interrupt, lua_surface_is_strict_fresh_transactional_and_context_safe, and the 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, 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

  1. Protocol v19 appends all new variants after v18 pins; v18 grid traffic round-trips unchanged and new outbound variants are version-gated.
  2. 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.
  3. Semantic terminal activation suppresses document-only messages; switching back forces a complete document resync.
  4. Two frontends/splits on one terminal keep independent scroll/selection snapshots; only the active controlling context resizes or writes input.
  5. Forged frontend/buffer IDs in terminal resize/pointer events cannot affect another terminal or process.
  6. Headless GPU rendering pins background rectangles, indexed/truecolor, reverse, wide/combining cells, clipping, cursor visibility, status-band separation, and no frontend wrapping.
  7. Font/window resize emits cell dimensions, never pixels, and identical resize requests are suppressed.
  8. Theme/font/terminal generation changes invalidate exactly the affected caches; an unchanged terminal frame produces no redraw message.
  9. 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.

10. Gates and bite verification

Every PR runs the standing full gates from AGENTS.md, sequentially, plus its stage acceptance suite. Stage 2 includes a real hermetic TUI PTY smoke; stage 3 includes PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu and the real-daemon GPU probe.

New behavioral acceptance must be bite-verified against the immediate pre-stage tree with scripts/bite where the swapped files compile. Protocol v19 tests additionally pin postcard bytes and verify the older-version daemon filter; a test that merely fails to decode on old code is not a useful bite.

11. Explicit deferrals

Not part of these three PRs:

  • terminal image protocols (sixel, kitty graphics, iTerm images);
  • OSC 52 host clipboard writes and OSC 8 hyperlink interaction;
  • faint, blink, conceal, and strikethrough additions to shared Style;
  • kitty keyboard protocol, key release events, media keys, and IME preedit;
  • cursor-shape/blink rendering and numeric-keypad distinction absent from the current normalized input/cursor protocol;
  • RIS (ESC c), DECALN (ESC # 8), and DEC cursor save/restore mode ?1048;
  • CUU/CUD region clamping when the cursor starts inside scroll margins while origin mode is disabled;
  • combining a character into the preceding cell across intervening SGR or cursor-control events;
  • DECSTBM clamping when an explicit bottom margin exceeds the current screen height; the current core leaves the existing scrolling region unchanged;
  • exact xterm ?1047 clear-on-exit and scroll-margin preservation across 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, and avoiding the per-sequence intermediates clone in CSI dispatch;
  • cleanup of the defensive impossible-state path where a terminal spawn returns a process without a running PID, and borrow-tolerant EditorState drop; normal spawn/rollback/prune/shutdown paths remain covered;
  • a separately named configuration-time scrollback-row cap; the current validation conservatively reuses the history-cell cap before the runtime row and cell budgets enforce the effective limit;
  • shell integration, prompt marks, command semantic zones, and cwd reporting;
  • ordinary document search over terminal history;
  • terminal session persistence/reconnect across editor restart;
  • reparenting a live terminal process into a second daemon instance;
  • user-configurable escape key and scrollback policy.

Deferral means graceful ignore or documented absence, never escape leakage, panic, unbounded allocation, or child leak.

12. Resolved decisions

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.
  3. Exit: retain the buffer and append the process PID/outcome line from §4.1.
  4. Compatibility: additive v19; v18 grid remains supported, v18 semantic has 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.
  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.