Merge pull request #130 from levineuwirth/vterm-tui

feat(vterm): compose terminal views in the TUI
This commit is contained in:
Levi Neuwirth 2026-07-22 14:48:04 +00:00 committed by GitHub
commit 86fc1bccae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 4564 additions and 535 deletions

View File

@ -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,
}

View File

@ -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` @ `b4b925d` (mode system wiring #129 merged;
`githubsucks/main` @ `d5d9b9c` (mode system handoff #131 merged;
protocol v18).
- On the transfer source, `origin/main` named a release mirror at
`d3fa632` and lagged badly. On the current destination, `origin` names
@ -49,34 +49,55 @@ git worktree list
git status --short --branch
```
The first command must expose `b4b925d` or a newer intentional main.
The first command must expose `d5d9b9c` or a newer intentional main.
If it does not, stop and repair the remote/fetch configuration.
## Vterm Stage 2 implementation lane
## Vterm Stage 2 framing 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 `b4b925d` (config registry #127, the #128
documentation merge, and mode system wiring #129); 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`
- 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.
- PR: #130, <https://github.com/levineuwirth/pmacs/pull/130>, open against
canonical `main` and explicitly authorized for merge.
- State: `docs/vterm-framing.md` Revision 7 criteria 1527 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.
- 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.
- 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`, first-review
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:
```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

View File

@ -1,9 +1,10 @@
# Agent handoff — cross-machine continuity
**Last updated: 2026-07-22, after mode system wiring (#129) landed on
`main`, atop the config registry (#127), Vterm Stage 1 terminal core
(#126), and completed Themes Arc 4 (#120/#124/#125). Vterm Stages 2 and 3
are not implemented.**
**Last updated: 2026-07-22, after Vterm Stage 2 PR #130 review round 2
was addressed and approved for merge; mode system wiring (#129) and its
handoff (#131), config registry (#127), Vterm Stage 1 terminal core (#126),
and completed Themes Arc 4 (#120/#124/#125) are landed on `main`. 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`
@ -17,7 +18,7 @@ commands, read `docs/active-work.md` immediately after this file.
## 1. Where the project stands (2026-07-22)
- `main` @ `b4b925d` (mode system wiring #129), protocol **v18**
- `main` @ `d5d9b9c` (mode system handoff #131), protocol **v18**
(`SUPPORTED=[6..18]`; v16 = `ThemeFacts`, v17 = `FontFacts`, v18 =
`StatuslineSegments`).
- **Config registry LANDED — #127** (`docs/config-registry-framing.md`
@ -230,7 +231,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
@ -244,12 +245,57 @@ 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 1527). `TerminalViewKey` keys per-frontend/window
projection state over one shared process/screen; logical row anchors retain
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; 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
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.
- 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`.
- 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.
- 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

View File

@ -80,16 +80,18 @@ preference at protocol v17; and composable per-window
`pmacs.statusline` providers transported to semantic/GPU frontends by
protocol-v18 `StatuslineSegments`.
### Arc 5 — Terminal, staged — VTERM CORE LANDED
### 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 landed in #126**: compatibility parser
profiles, bounded VT screen/scrollback/reflow state, input encoders,
internal `TerminalManager`, read-only identity buffers, process lifecycle,
control-free renderer-boundary cells, and headless real-PTY acceptance.
- **Vterm Stage 2 TUI is next**: terminal-window composition, input/resize,
per-context scroll/selection/copy, and the Lua surface.
profiles, bounded VT screen/scrollback/reflow state, IND/NEL/RI, input
encoders, internal `TerminalManager`, read-only identity buffers, process
lifecycle, control-free renderer-boundary cells, and headless real-PTY
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 follows Stage 2**: additive protocol v19
complete frames, authenticated daemon routing, and native GPU terminal
rendering. Its framing must resolve the 16 MiB transport cap's incompatibility

View File

@ -1,18 +1,22 @@
# Vterm — framing (Arc 5 stage 2, three-PR delivery)
**Revision 5 — 2026-07-21. Status: Stage 1 landed on `main` through PR #126
at merge `643d1e1`. Stages 2 and 3 are not implemented.**
**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 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
@ -34,14 +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, fully gated, and
landed on `main` as merge `643d1e1`. Initial feature commit `bbc1f33`,
first-review fixes through `bf972a7`, and second-review hardening `9797ada`
shipped through PR #126, <https://github.com/levineuwirth/pmacs/pull/126>.
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,
<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
@ -145,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
@ -540,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 {
@ -556,138 +591,293 @@ 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:<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.
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, 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.
- 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 F1F12 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.
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
@ -803,21 +993,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
@ -825,23 +1016,25 @@ 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 landed on `main` through PR #126 at merge `643d1e1`. Continue the
approved sequential plan:
Stage 1 landed on `main` as PR #126 at merge `643d1e1`. Continue one clean PR
at a time:
1. create `pmacs-vterm-tui`, branch `vterm-tui`, from post-#126 `main`;
implement, gate, and open the second PR;
2. after Stage 2 merges, create `pmacs-vterm-gpu`, branch `vterm-gpu`, from
the new `main`; implement, gate, and open the third PR.
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
@ -891,49 +1084,126 @@ 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 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
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.
@ -970,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,
@ -991,7 +1264,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.
@ -1000,5 +1274,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.

View File

@ -869,6 +869,10 @@ 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<FrontendId, crate::buffer::BufferId> = 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
@ -1063,10 +1067,15 @@ 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
@ -1082,6 +1091,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
@ -1089,7 +1106,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 })
@ -1283,6 +1300,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.detach_frontend_input(*fid);
session_registry.unregister_session(*fid);
editor
.statusline_registry
@ -1328,6 +1347,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 +1364,7 @@ fn dispatcher_loop(
&mut term_sizes,
&mut last_dispatch_idle_sent,
&mut last_active_buffer_sent,
&mut terminal_bell_baselines,
&mut session_registry,
);
}
@ -1352,6 +1373,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
@ -1375,7 +1405,49 @@ 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,
baselines: &mut HashMap<FrontendId, (crate::buffer::BufferId, u64)>,
) -> 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
}
}
}
#[allow(
clippy::too_many_arguments,
reason = "one session bootstrap transaction"
)]
fn handle_session_established(
editor: &mut EditorState,
render_states: &mut HashMap<FrontendId, RenderState>,
@ -1460,6 +1532,7 @@ fn handle_dispatcher_event(
term_sizes: &mut HashMap<FrontendId, CellSize>,
last_dispatch_idle_sent: &mut HashMap<FrontendId, bool>,
last_active_buffer_sent: &mut HashMap<FrontendId, crate::buffer::BufferId>,
terminal_bell_baselines: &mut HashMap<FrontendId, (crate::buffer::BufferId, u64)>,
session_registry: &mut SessionRegistry,
) {
match event {
@ -1597,6 +1670,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,
@ -1619,7 +1698,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
@ -1627,7 +1708,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 +1722,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 +1740,8 @@ 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.detach_frontend_input(frontend_id);
session_registry.unregister_session(frontend_id);
editor
.statusline_registry
@ -2461,16 +2544,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 +2570,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 +2578,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);
@ -2970,6 +3059,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<FrontendId, crate::semantic_render::SemanticRenderState> =
HashMap::new();
let mut streams: HashMap<FrontendId, UnixStream> = 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
@ -3160,6 +3362,7 @@ mod tests {
apply_semantic_input_event(
&mut editor,
fid,
FrontendEvent::Key(KeyEvent {
frontend_id: fid,
key: Key::Char('X'),
@ -3236,6 +3439,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'),

File diff suppressed because it is too large Load Diff

View File

@ -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<u8>) {
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<u8>) {
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
@ -2821,6 +2826,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 buffer_round_trips(&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
@ -2889,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;
@ -2911,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)
}
}
// ---------------------------------------------------------------------------

View File

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

View File

@ -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<WindowId, TerminalSnapshot>,
other_presences: &[crate::overlay_paint::OtherPresence],
) -> Vec<InstanceMessage> {
if self.size.rows < 2 || self.size.cols == 0 {
@ -107,7 +112,7 @@ 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 +191,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 +200,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 +210,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 +222,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 +236,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 +245,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 +255,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 +265,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 +275,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 +320,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 +346,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 +358,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 +385,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 +394,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 +407,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");

View File

@ -54,6 +54,7 @@ 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::InteractiveCommandOrigin;
use crate::editor_core::EditorCore;
use crate::highlight::SyntaxHighlightView;
use crate::hook::{Hook, HookRegistry};
@ -5208,6 +5209,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::<crate::editor::InteractiveCommandOrigin>()
.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::<SharedCore>().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<Table> {
let command = lua.create_table()?;
@ -5280,11 +5301,7 @@ fn install_command_module(lua: &Lua, commands: &SharedCommandRegistry) -> mlua::
command.set(
"invoke_interactive",
lua.create_function(move |lua, (name, args): (String, Variadic<Value>)| {
if let Some(core) = lua.app_data_ref::<SharedCore>() {
let mut core = core.borrow_mut();
let fid = core.active_frontend;
core.rotate_command(fid, &name);
}
rotate_interactive_command(lua, &name)?;
let body = {
let r = cmds.borrow();
r.get(&name)
@ -8189,6 +8206,607 @@ pub fn make_process_supervisor(lua: &Lua) -> mlua::Result<SharedProcessSuperviso
Ok(supervisor)
}
// ---------------------------------------------------------------------------
// pmacs.terminal: owned terminal session surface (Arc 5 Stage 2)
// ---------------------------------------------------------------------------
/// Build the shared terminal registry and install strict raw Lua primitives.
pub fn make_terminal_manager(
lua: &Lua,
supervisor: &SharedProcessSupervisor,
) -> mlua::Result<crate::terminal::SharedTerminalManager> {
let manager = Rc::new(RefCell::new(crate::terminal::TerminalManager::new()));
install_terminal(lua, &manager, supervisor)?;
Ok(manager)
}
fn terminal_shared_core(lua: &Lua, operation: &str) -> mlua::Result<SharedCore> {
lua.app_data_ref::<SharedCore>()
.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::<InteractiveCommandOrigin>()
.and_then(|origin| origin.current())
.unwrap_or_else(|| core.borrow().active_frontend)
}
fn active_terminal_view_key(
lua: &Lua,
core: &SharedCore,
manager: &crate::terminal::SharedTerminalManager,
operation: &str,
) -> mlua::Result<crate::terminal::TerminalViewKey> {
let frontend_id = lua
.app_data_ref::<InteractiveCommandOrigin>()
.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"
))
})?;
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
.get(&frontend_id)
.expect("active window implies registered frontend view")
.active,
window.buffer_id,
))
}
fn terminal_context_integer(context: &Table, field: &str) -> mlua::Result<u64> {
match context.raw_get::<Value>(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<crate::buffer::BufferId> {
match context.raw_get::<Value>("buffer")? {
Value::UserData(buffer) => buffer
.borrow::<BufferIdLua>()
.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,
) -> mlua::Result<Option<crate::terminal::TerminalViewKey>> {
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_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 {
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,
)))
}
#[allow(
clippy::too_many_lines,
reason = "single strict Lua module installation"
)]
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<BufferIdLua> {
let spec = parse_terminal_spec(&spec)?;
let core = lua
.app_data_ref::<SharedCore>()
.map(|core| core.clone())
.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
.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_for(frontend_id, 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(
frontend_id,
core.views
.get(&frontend_id)
.expect("checked frontend has active view")
.active,
buffer_id,
)
};
let claimed = {
let mut manager = manager.borrow_mut();
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))
})?,
)?;
}
{
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)
})?,
)?;
}
{
let manager = manager.clone();
terminal.set(
"view_state",
lua.create_function(move |lua, context: Table| -> mlua::Result<Option<Table>> {
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).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, &manager, "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, &manager, "_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, &manager, "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, &manager, "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)
}
fn parse_terminal_spec(table: &Table) -> mlua::Result<crate::terminal::TerminalSpec> {
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<Option<String>> {
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<Vec<String>> {
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<Vec<(String, String)>> {
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<u16> {
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<usize> {
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<Table> {
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)
// ---------------------------------------------------------------------------

View File

@ -3824,7 +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, &[]);
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 { .. }),

View File

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

View File

@ -97,6 +97,60 @@ 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<TerminalRow>,
/// Active visible rows, including logical-line and soft-wrap metadata.
pub visible_rows: Vec<TerminalRow>,
/// Published cursor position when visible.
pub cursor: Option<CellCoord>,
/// Published terminal title.
pub title: Option<String>,
/// Screen generation represented by this projection.
pub generation: u64,
}
/// Borrowed, publication-consistent row projection for in-process views.
#[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],
pub visible_rows: &'a [TerminalRow],
pub cursor: Option<CellCoord>,
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 {
size: self.size,
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,
@ -138,7 +192,7 @@ pub struct TerminalScreen {
tab_stops: BTreeSet<usize>,
title: Option<String>,
generation: u64,
published: ScreenSnapshot,
published: ScreenProjection,
sync_started: Option<Instant>,
next_line_id: u64,
scrollback_rows: usize,
@ -159,9 +213,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 +558,58 @@ 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()
}
}
/// 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 {
size: self.size,
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
}
/// 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 +1479,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 +1608,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 +1751,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 +1797,59 @@ 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]
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]

View File

@ -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<BufferId, TerminalSession>,
pub(super) sessions: HashMap<BufferId, TerminalSession>,
process_to_buffer: HashMap<ProcessId, BufferId>,
/// Removed buffers whose children are still being reaped. Their events
/// remain manager-owned so Lua/LSP/MCP consumers cannot steal a batch.
closing: HashSet<ProcessId>,
/// Per-frontend/window projections over the one session screen.
pub(super) views: HashMap<TerminalViewKey, TerminalViewState>,
/// At most one authenticated frontend/window controls each session PTY.
pub(super) controllers: HashMap<BufferId, TerminalController>,
}
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,99 @@ 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<u64> {
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.
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<TerminalViewKey>,
) {
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.
///
/// 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
}
/// 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<TerminalController> {
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<TerminalSnapshot> {
@ -488,6 +591,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 +633,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;

1032
src/terminal/view.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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<dyn portable_pty::Child + Send + Sync>,
writer: Box<dyn Write + Send>,
_reader_thread: thread::JoinHandle<()>,
_master: Box<dyn portable_pty::MasterPty + Send>,
output: Arc<Mutex<Vec<u8>>>,
master: Box<dyn portable_pty::MasterPty + Send>,
}
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<u8> {
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,
}
}

View File

@ -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={:?}",

View File

@ -57,7 +57,13 @@ fn paint(state: &EditorState, rows: u32, cols: u32) -> Vec<Cell> {
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,9 +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(), 2, "builtin providers are discoverable");
assert!(initial.iter().any(|provider| provider.name == "mode"));
assert!(initial.iter().any(|provider| provider.name == "lsp"));
assert_eq!(
initial
.iter()
.map(|provider| provider.name.as_str())
.collect::<Vec<_>>(),
["mode", "terminal", "lsp"],
"built-in providers are discoverable in registration order"
);
let before_epochs = {
let registry = state.statusline_registry.borrow();
(registry.layout_epoch(), registry.face_set_epoch())

View File

@ -92,7 +92,13 @@ fn paint_full_frame(state: &EditorState, rows: u32, cols: u32) -> Vec<Cell> {
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
}

View File

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

View File

@ -0,0 +1,708 @@
//! 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]
#[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");
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();
{
let lua = state.lua_host.lua();
let error = lua
.load(format!(
"return pmacs.terminal.open {{ command = {command_lua}, unknown = true }}"
))
.eval::<Value>()
.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::<BufferIdLua>()
.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)));
}
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),
);
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::<bool>("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"));
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
.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 = "<f12>", 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()
.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::<Value>()
.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]
#[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");
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 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
.snapshot_for_view(first, size)
.expect("first snapshot");
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);
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(replacement));
assert_eq!(
manager.controller_view_for_frontend(FrontendId(11)),
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)
);
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");
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::<AnyUserData>()
.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) {
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<u8> {
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]
#[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");
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"\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"\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");
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"
);
}