Merge pull request #135 from levineuwirth/vterm-gpu

feat(vterm): protocol v19 terminal frames and a native GPU terminal (Stage 3)
This commit is contained in:
Levi Neuwirth 2026-07-22 23:41:25 +00:00 committed by GitHub
commit cac4961c73
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 6430 additions and 326 deletions

1
Cargo.lock generated
View File

@ -2595,6 +2595,7 @@ dependencies = [
"postcard",
"serde",
"thiserror 2.0.18",
"unicode-width",
]
[[package]]

View File

@ -13,6 +13,10 @@ members = [".", "pmacs-protocol", "pmacs-gpu"]
serde = { version = "1", features = ["derive"] }
postcard = { version = "1", features = ["use-std"] }
thiserror = "2"
# Shared between `pmacs` (terminal screen) and `pmacs-protocol`
# (`TerminalFrame::validate`). Pinned here so the producer and the
# validator agree on every glyph's column width.
unicode-width = "0.2"
[package]
name = "pmacs"
@ -85,7 +89,7 @@ crdt = ["dep:loro", "pmacs-protocol/crdt"]
[dependencies]
crossterm = "0.28"
thiserror = { workspace = true }
unicode-width = "0.2"
unicode-width = { workspace = true }
unicode-segmentation = "1"
# Regex engine for in-buffer regex search (Q#RX1). `regex::bytes::Regex`
# matches over rope-snapshot bytes and yields byte offsets directly.

View File

@ -14,8 +14,8 @@ 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` @ `40111dc` (landed-state docs after locals-query #134;
protocol v18).
`githubsucks/main` @ `2625ec7` (tab-width parity #137 merged atop
locals-query #134 and modeline detection #132; protocol v18 on `main`).
- On the transfer source, `origin/main` named a release mirror at
`d3fa632` and lagged badly. On the current destination, `origin` names
the canonical URL. This difference is why all recovery begins by
@ -49,35 +49,120 @@ git worktree list
git status --short --branch
```
The first command must expose `40111dc` or a newer intentional main.
The first command must expose `2625ec7` or a newer intentional main.
If it does not, stop and repair the remote/fetch configuration.
## Tab-width rendering parity lane
## Vterm Stage 3 implementation lane
- Portable branch: `githubsucks/tab-width-parity`.
- Base: canonical `main` @ `40111dc`; protocol v18.
- Approved framing: `docs/tab-width-parity-framing.md` revision 2; framing
branch head `9f2f0d5`.
- Implementation head: `9f7bc77`.
- State: implementation complete; PR #137 open:
<https://github.com/levineuwirth/pmacs/pull/137>. One fixed 8-column constant now
drives core/TUI columns, GPU code projection, and minimap width. Source bytes
and protocol ranges remain unchanged.
- Verification: `cargo fmt --check`; strict workspace Clippy; 1,763 default,
1,939 CRDT, and 1,763 Lua 5.4 library tests; 2 tab-width acceptance tests;
M4 121 passed (3 ignored, 1 filtered); required GPU 119; workspace 2,911
passed across 83 suites (19 ignored, 1 filtered); `git diff --check`.
- Concurrent PR #135 owns overlapping `Cargo.lock`, `pmacs-protocol/src/lib.rs`,
and `pmacs-gpu/src/main.rs`. This branch deliberately remains based on
canonical `main`; rebase and rerun gates if #135 lands first.
- Recovery:
- Portable branch: `githubsucks/vterm-gpu`
- Framing carried as the first commit; implementation follows it.
- Base: cut from canonical `main` @ `1dd47fc`, NOT stacked on
`vterm-stage3-framing`, per the framing's §8. Canonical `main` @
`2625ec7` (tab-width parity #137, locals-query #134, modeline handoff
#133/#136) is MERGED IN — see the integration entry below.
- PR: #135, <https://github.com/levineuwirth/pmacs/pull/135>, open against
canonical `main`. Never merge without explicit authorization.
- State: criteria 28-37 implemented. Protocol v19 (`SUPPORTED=[6..=19]`):
`InstanceMessage::TerminalFrame` (discriminant 26, daemon-gated),
`FrontendEvent::TerminalResize` (11) and `TerminalPointer` (12)
(frontend-gated). `pmacs-protocol/src/terminal.rs` owns the shared
bounds and `TerminalFrame::validate`; `pmacs-gpu/src/terminal.rs` is the
pure cell-space paint planner; `pmacs-gpu --headless-probe` drives the
real attach client without winit for criterion 37.
- Verification (clean tree, this machine):
- `cargo fmt --check`;
- `cargo clippy --workspace --all-targets -- -D warnings`;
- `cargo test --lib`: 1,757 passed (3 ignored);
- `cargo test --lib --features crdt`: 1,933 passed (3 ignored);
- vterm Stage 1 acceptance: 9 default / 10 CRDT;
- vterm Stage 2 acceptance: 4 default / 4 CRDT;
- vterm Stage 3 acceptance: 4 default / 5 CRDT (the CRDT-only case is
the real-daemon + real-PTY + headless-GPU path);
- statusline acceptance: 7 default / 8 CRDT;
- `cargo test --test m4_acceptance -- --skip basedpyright`: 120 passed
(3 ignored, 1 filtered);
- `PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu`: 127 passed;
- `cargo test --workspace -- --skip basedpyright`: 2,919 passed across
83 suites (19 ignored), one invocation;
- `git diff --check`.
- Review round 1 addressed (framing §0.10): hover no longer claims durable
terminal control (real defect, bite-verified); terminal motion dedupes by
cell; declarations record only once sent; unchanged frames skip
revalidation; the terminal-mode presence-sweep skip is removed. The
review's predicted presence FREEZE did not reproduce — the buffer-follow
clears the declaration before `render_frame`, so a truthful sweep always
precedes terminal mode; that test is a labelled regression guard, not
fix evidence.
- Review round 2 addressed (framing §0.11): a daemon disconnect now leaves
terminal mode so the notice is visible (real defect, hand-verified
because fix and test share a file); the per-tick full-grid clone is
gone; inbound terminal events require a negotiated v19 session; a
grid-missing press no longer arms a drag; roadmap/handoff Arc 5 lines
corrected. Named deferral: terminal wheel gestures discard scroll
magnitude.
- Post-round-2 gates (pre-integration): 1,758 default + 1,934 CRDT library
tests; required GPU 129; workspace sweep 2,923 across 83 suites; Stage 3
acceptance 5 default / 7 CRDT; M4 120; fmt, clippy, diff check clean.
- **Post-integration gates (canonical `main` @ `2625ec7` merged in):**
`cargo fmt --check`; strict workspace Clippy; `pmacs-protocol` 17;
`cargo test --lib` 1,768; `--features crdt` 1,944 (3 ignored each);
vterm Stage 1 9/10, Stage 2 4/4, Stage 3 5/7, statusline 7/8, tab-width
2/2 (default/CRDT); M4 121 passed (3 ignored, 1 filtered); required GPU
139; workspace sweep 2,946 passed across 84 suites (19 ignored), one
invocation; `git diff --check` clean.
- Closed caveat: the once-seen required-GPU failure did not reproduce in
eight author runs plus five reviewer runs. Treated as environmental.
- Canonical-main integration after #137 landed: the agreed order was
#137 first (it was approved and FROZEN at `5b23e11`, so it could not
absorb a rebase without breaking its freeze), then this lane second.
Integrated by MERGING canonical main into the branch, matching repo
precedent (`Merge canonical main into vterm-tui`, `… into modeline
detection`) rather than a rebase, which would have force-pushed away
the review anchors on #135. Main had also moved past this lane's base
by #133/#134/#136, so the integration surface was wider than the
#135/#137 overlap: `src/semantic_render.rs` was a fourth overlapping
code file and auto-merged, as did `pmacs-protocol/src/lib.rs`. The one
code conflict was the `pmacs_protocol` import list in
`pmacs-gpu/src/main.rs` (`TAB_STOP_COLUMNS` against the terminal
types) — resolved as a union.
- Next: further user review rounds on the PR.
```sh
git worktree add --track \
-b tab-width-parity \
../pmacs-tab-width-parity \
githubsucks/tab-width-parity
```
Recovery worktree:
```sh
git worktree add --track \
-b vterm-gpu \
../pmacs-vterm-gpu \
githubsucks/vterm-gpu
```
## Cross-PR coordination: #135 and #137 (resolved)
**Resolved 2026-07-22: #137 merged first, #135 integrated second.** Kept
as the worked example, because the deciding argument is reusable.
#137 was APPROVED and FROZEN at `5b23e11`. "Frozen" and "rebase onto the
resulting main" are mutually exclusive, so the frozen PR had to land
first — the alternative would have broken its freeze and voided its
approval. Three arguments pointed the same way: the approved PR should
not wait on an unapproved one; the PR carrying the protocol byte pins
should be the one that integrates, because its own suite is what detects
a disturbed discriminant; and the larger, more invasive change should
pay the integration cost, since its author has the context to verify the
merged result.
The named overlap (`pmacs-gpu/src/main.rs`, `pmacs-protocol/src/lib.rs`,
`Cargo.lock`, both ledger docs) was accurate but incomplete — main had
also moved by #133/#134/#136, making `src/semantic_render.rs` a fourth
overlapping code file. **Lesson: derive the integration surface from
`git diff <base>..main`, not from the other PR's file list.**
The feared semantic collision did not occur. Terminal cell geometry
still uses the monospace advance and is not routed through
`TAB_STOP_COLUMNS`: terminal columns come from the child, and tab
expansion is a DOCUMENT projection concern. That is verified rather than
assumed — see the integration gates on the lane above.
## Parked lane: kill-ring browser + persistence
@ -115,6 +200,27 @@ git worktree add --track \
## Closed since the last snapshot
- **Branches deleted 2026-07-22 (authorized):** `vterm-stage3-framing`
(Revision 8 framing; its content is carried on `vterm-gpu`, verified as a
superset before deletion — the branch was NOT an ancestor of `vterm-gpu`
because the framing was copied rather than merged, so it needed a forced
local delete) and `tab-width-parity` (a clean ancestor of `main` via
#137). Both removed as worktree + local ref + `githubsucks` ref; the
`origin` tracking refs were pruned. The `-framing` branches for each are
deliberately kept.
- **Tab-width rendering parity — MERGED as #137** (`main` @ `2625ec7`,
2026-07-22). One fixed 8-column `TAB_STOP_COLUMNS` in `pmacs-protocol`
now drives core/TUI columns, GPU code projection, and minimap width;
source bytes and protocol ranges are unchanged. Its lane, worktree, and
`tab-width-parity` branch (local + `githubsucks`) are deleted; the
`tab-width-parity-framing` branch is kept. This closes the long-standing
"tab width is a rendering-parity
bug, NOT a config gap" deferral recorded in `docs/agent-handoff.md` §5.
- **Locals-query processing — MERGED as #134** (with handoff #136), and
**modeline detection handoff #133**. Both landed between this lane's
base and its canonical-main integration.
- **Config registry — MERGED as #127** (`main` @ `2e37c04`). Its lane
(`config-registry`, worktree `../pmacs-config-registry`) is done; the
branch is kept but carries nothing unmerged. Durable substrate facts

View File

@ -1,8 +1,12 @@
# Agent handoff — cross-machine continuity
**Last updated: 2026-07-22, with tab-width rendering parity implemented and
open as PR #137, after locals-query processing (#134) landed on `main`.
Vterm Stage 3 remains in review as #135.**
**Last updated: 2026-07-22, after tab-width rendering parity (#137) and
locals-query processing (#134) landed on `main`, and canonical `main` was
merged into the Vterm Stage 3 lane. Vterm Stage 3 (protocol v19, GPU
terminal) is implemented on `vterm-gpu` and in review as PR #135; see
`docs/active-work.md`. Vterm Stage 2 (#130), modeline detection (#132),
mode system wiring (#129), config registry (#127), Vterm Stage 1 (#126),
and completed Themes Arc 4 (#120/#124/#125) are on `main`.**
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`
@ -16,8 +20,9 @@ commands, read `docs/active-work.md` immediately after this file.
## 1. Where the project stands (2026-07-22)
- `main` @ `40111dc` (landed-state documentation after locals-query processing
#134), protocol **v18** (`SUPPORTED=[6..18]`; v16 = `ThemeFacts`, v17 =
- `main` @ `2625ec7` (tab-width parity #137 atop locals-query #134),
protocol **v18** on `main`, **v19** on `vterm-gpu`
(`SUPPORTED=[6..=19]`; v16 = `ThemeFacts`, v17 =
`FontFacts`, v18 = `StatuslineSegments`).
- **Config registry LANDED — #127** (`docs/config-registry-framing.md`
rev 3; merge `2e37c04`; two review rounds). `pmacs.config` is the
@ -263,14 +268,43 @@ 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 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 3 GPU/protocol is IMPLEMENTED on `vterm-gpu`** (framing
`docs/vterm-framing.md` Revision 9, criteria 28-37; awaiting review).
Protocol **v19**: `InstanceMessage::TerminalFrame` (discriminant 26,
daemon-gated) plus `FrontendEvent::TerminalResize` (11) and
`TerminalPointer` (12), both frontend-gated - the first bump gating in
BOTH directions. `SUPPORTED=[6..=19]`.
- `pmacs-protocol/src/terminal.rs` now owns the shared terminal bounds,
`TerminalProcessState`, `TerminalSelectionSpan`, and the single
structural policy `TerminalFrame::validate`; `src/terminal/*`
re-exports them so no duplicate type exists. `unicode-width` is a
workspace dependency so the screen and the validator measure glyph
columns identically. `MAX_TERMINAL_FRAME_GLYPH_BYTES = 8 MiB` bounds
the payload instead of widening the transport cap; the measured
maximum legal frame encodes to 13,437,863 bytes under the unchanged
16 MiB `MAX_FRAME_BYTES`. Over-bound snapshots are rejected, never
truncated or silently chunked.
- **The `Viewport` gate keys on the AUTHENTICATED SOURCE'S ACTIVE
BUFFER, not the buffer the message names.** `Viewport` also aligns the
window to the buffer it declares, so a stale document viewport in
flight when a command opens a terminal drags the frontend back off it
- the terminal then never paints, with no error anywhere. The weaker
"is the declared buffer a terminal" reading looks right and fails
exactly this way.
- Suppression compares the COMPLETE ordered payload, never
`screen_generation`: scroll, selection, viewport, and process state all
change without advancing it.
- GPU: `pmacs-gpu/src/terminal.rs` is a pure cell-space paint planner
(testable without a GPU); the renderer builds one shaped buffer per
text run so a wide/cluster advance can never choose the next column's
origin. `pmacs-gpu --headless-probe` drives the real attach client
without winit (`attach::connect_with_sink`), which is how criterion 37
gets one real daemon + real PTY + real wgpu path.
- **Stage 2 TUI LANDED ON `main` — #130** (merge `86fc1bc`;
`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.
@ -305,19 +339,19 @@ commands, read `docs/active-work.md` immediately after this file.
removes owned cell snapshots from terminal mouse routing. The framing now
records the transient v18 semantic-controller boundary and bracketed-paste
injection deferral.
- 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;
- Current-main integration (`3f0252f`) preserved per-frontend terminal
dispatch while applying the landed mode-scoped keymap, and exposed the
`mode`, `terminal`, and `lsp` statusline providers together. PR #130 merged
at `86fc1bc`.
- Final integrated 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.
- **Tab-width rendering parity IMPLEMENTED — PR #137 OPEN**
(`docs/tab-width-parity-framing.md` rev 2; branch `tab-width-parity`;
implementation `9f7bc77`; <https://github.com/levineuwirth/pmacs/pull/137>).
- **Tab-width rendering parity LANDED — #137** (merge `2625ec7`;
`docs/tab-width-parity-framing.md` rev 2).
Source tabs remain one byte while every buffer
renderer follows the shared fixed `pmacs_protocol::TAB_STOP_COLUMNS = 8`.
- `src/display_width.rs` owns allocation-free Unicode/tab-aware byte-to-column
@ -333,6 +367,11 @@ commands, read `docs/active-work.md` immediately after this file.
version changed. Local gates: 1,763 default + 1,939 CRDT + 1,763 Lua 5.4
library tests; 2 focused acceptance; M4 121; required GPU 119; workspace
2,911 across 83 suites; strict Clippy and diff check clean.
- This closes the standing "**tab width is a rendering-parity bug, NOT a
config gap**" deferral in §5: one shared constant now drives every
renderer. Terminal cells are deliberately OUTSIDE it — a terminal's
columns come from the child, so `pmacs-gpu`'s terminal geometry uses
the monospace advance and never `TAB_STOP_COLUMNS`.
- **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
@ -349,8 +388,9 @@ commands, read `docs/active-work.md` immediately after this file.
fix (#101).
- **Arc 4 (themes + extensibility) COMPLETE** — named UI faces (#120),
live GPU font preferences (#124), statusline providers (#125).
- **Arc 5 terminal stage ACTIVE** — compile mode (#113), Vterm terminal core
(#126), and Vterm TUI (#130) landed; protocol/GPU Stage 3 is next.
- **Arc 5 terminal stage ACTIVE** — compile mode (#113), Vterm terminal
core (#126), and Vterm TUI (#130) landed; protocol/GPU Stage 3 is
implemented and in review as PR #135.
- **Config registry COMPLETE (#127)** — not a numbered arc; it was the
cross-cutting substrate ranked first on
`docs/side-quest-backlog.md`'s north star, and it unblocks the
@ -472,10 +512,12 @@ buffer owns a path's recovery slot; only recover/discard release
unclaimed crash data; adopt clears the old owner's skip cache.
**Protocol** — encoding-breaking bumps are deliberate and versioned
(`SUPPORTED=[6..18]`). v15 = `CompletionPopup` +
(`SUPPORTED=[6..=19]`). v15 = `CompletionPopup` +
`StatusFacts.message`; v16 = `ThemeFacts`; v17 = `FontFacts`; v18 =
`StatuslineSegments`. New wire surface ⇒ bump + both-frontends support +
acceptance.
`StatuslineSegments`; v19 = the vterm terminal family. New wire surface ⇒
bump + both-frontends support + acceptance. An APPENDED variant must be
guarded by a byte pin on the PREVIOUS final variant — its own round-trip
cannot detect a discriminant shift.
**Fake LSP** (`src/bin/pmacs_fake_lsp.rs`) modes: `fullonly`,
`rangeonly`, `rangeonly16` (UTF-16 + fail-closed bounds validation),
@ -536,6 +578,31 @@ acceptance.
and go through `pmacs.command.invoke("buffer.save")`. Caught only
because the *other* case failed and the cause was chased instead of
the assertion adjusted.
- **A message that ALIGNS state cannot be gated on the state it names.**
`FrontendEvent::Viewport` both declares a byte range and switches the
frontend's window to the buffer it names. Gating the vterm v19 dual
declaration on "is the DECLARED buffer a terminal" therefore left a
stale in-flight document viewport free to drag a frontend straight back
off a terminal a command had just opened — the window oscillated, the
terminal declaration was refused every time, and no frame ever arrived.
Nothing errored. The gate has to key on the authenticated source's
ACTIVE buffer. Generalizes: when two messages declare competing views of
"what am I showing", the arbiter is the daemon's own state, never the
claim inside either message.
- **A pass that sets a mode flag must clear it on EVERY exit.** The
semantic producer's terminal pass returned early via `?` when no
declaration existed, leaving `terminal_active` set — and the daemon uses
that flag to suppress `CursorByte` and the presence sweep, so a frontend
that went back to a document silently lost both. Caught by an acceptance
assertion, not by any type.
- **Sub-crate acceptance needs a real seam, not a fixture.** `pmacs-gpu`
depends only on `pmacs-protocol`, so "real daemon + real PTY + real
wgpu in one path" could not be an in-crate test. Generalizing
`attach::connect`'s reader sink (`connect_with_sink`) and adding
`--headless-probe` gave the acceptance the REAL handshake, outbox,
writer, and `render_to_view` — which is the whole point; a
decoded-message fixture would have proved none of the three fit
together. The probe found two real defects the in-process tests did not.
- **Real-grid acceptance must budget for macOS startup and path width.**
A 100 ms first-Hello timeout failed under loaded macOS CI; use the normal
five-second handshake window, then short polling reads. An 80-column split
@ -587,8 +654,12 @@ currently accepted for `autosave.interval-ms`, where a per-buffer
value is meaningless.
**Tab width is NOT a config gap** — see §5.
Mode system (SHIPPED #129): minor modes, `buffer.after-mode-change`,
mode-scoped settings, `describe-mode`, and persistence of explicit
major-mode overrides/clears across sessions. Modeline detection shipped in #132.
mode-scoped settings, `describe-mode`, and persistence of explicit major-mode
overrides/clears across sessions.
Modeline detection (SHIPPED #132): bounded first/last-line Emacs `-*-`
and Vim `ft=`/`filetype=` parsing, explicit-over-inferred precedence,
alias normalization, and shared fresh-load language pinning for
syntax/highlight/LSP startup.
Highlight/detection (from the #114#118 side-quest + injections #122):
~~locals-query processing~~ **SHIPPED #134**; remaining injection follow-ups
now that the engine landed (#122) —
@ -598,6 +669,7 @@ comment schemes), child-tree incrementality + range-scoped layer rebuild
runtime/Lua-registered languages (v1 resolves only against
`BUILTIN_LANGUAGES`), and the next injection *consumers* gated on new
grammars — HTML/CSS/GraphQL/SQL (`<script>`/`<style>`, JS/TS template
literals, doc-comment code);
~~modeline detection as a 5th layer (`-*- mode: … -*-` /
`# vim: ft=…`)~~ **SHIPPED #132**;
byte-accurate multibyte cursor placement in `move_active_cursor_to`

View File

@ -80,7 +80,7 @@ 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 STAGES 12 LANDED
### Arc 5 — Terminal, staged — VTERM STAGE 3 IN REVIEW
- **Compile mode landed in #113**: line-oriented PTY/ANSI output,
error-regex navigation, and `M-x compile`.
@ -92,10 +92,13 @@ protocol-v18 `StatuslineSegments`.
- **Vterm Stage 2 TUI landed in #130**: 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 is next**: additive protocol v19
complete frames, authenticated daemon routing, and native GPU terminal
rendering. Its framing must resolve the 16 MiB transport cap's incompatibility
with the legal worst complete terminal frame; never silently chunk.
- **Vterm Stage 3 protocol/GPU is implemented and in review (PR #135)**:
additive protocol v19 complete frames/events, an aggregate glyph-byte bound
under the unchanged transport cap, dual viewport bootstrap, authenticated
semantic routing, and native fixed-cell GPU terminal rendering. The 16 MiB
transport cap is preserved: the measured legal-worst frame encodes to
13,437,863 bytes under an 8 MiB aggregate glyph bound, never chunked.
Landing it closes Arc 5's terminal stage.
### Arc 6 — Folding (keystone gutter rider)

View File

@ -1,22 +1,34 @@
# Vterm — framing (Arc 5 stage 2, three-PR delivery)
**Revision 7 — 2026-07-21. Status: Stage 1 landed on `main` as PR #126
at merge `643d1e1`; Stage 2 is implemented on branch `vterm-tui` and Stage 3
is not implemented.**
**Revision 9 — 2026-07-22. Status: Stage 1 landed on `main` as PR #126
at merge `643d1e1`; Stage 2 landed as PR #130 at merge `86fc1bc`; Stage 3
is IMPLEMENTED on `vterm-gpu` and awaits review. Revision 8's framing text
below is preserved verbatim as the approved contract; §0.9 records what the
implementation actually did, including the two places it had to go beyond
the letter of the framing.**
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.
Revision 8 re-scouts the final stage against the integrated protocol-v18 tree
and closes its remaining producer/frontend boundary decisions. Protocol v19
uses one validated complete `TerminalFrame`; a shared 8 MiB aggregate glyph
budget keeps the largest legal postcard message below the existing 16 MiB
transport cap instead of broadening every connection's allocation ceiling.
The first-frame bootstrap is explicit: after every `BufferSnapshot`, a v19 GPU
sends the document viewport and, when the drawable terminal cell size is
nonzero, terminal-cell geometry; the daemon accepts only the declaration
appropriate to the authenticated active buffer.
Semantic terminal rendering is per frontend/window. Passive views never resize
the PTY, and terminal mode retains existing statusline, menu, minibuffer, and
BEL/clipboard channels while suppressing document projection and presence.
Revision 7's geometric `at_bottom`, fixed `C-c` transport escape,
authenticated context-implicit Lua operations, 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; 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
@ -26,7 +38,8 @@ emits only the line-oriented subset compile-mode needs. Vterm does not replace
either subsystem. It extends their contracts and adds the missing terminal
screen state machine.
Arc 5 stage 2 ships as three separately reviewed PRs:
Arc 5 stage 2 (vterm) ships as three separately reviewed internal stages,
one PR each:
1. **terminal core** — full-screen VT events, `TerminalScreen`, internal
session ownership/contracts, and headless real-PTY acceptance;
@ -38,7 +51,7 @@ 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 7 — landed Stage 1 and reviewed Stage 2 contract
## 0. Revision 8 — landed internal Stages 12 and framed Stage 3
The first of the three vterm PRs landed on `main` at merge `643d1e1`.
Implementation commits `bbc1f33` and `962944b`, first-review fixes through
@ -189,14 +202,13 @@ The final framing review pinned three precision contracts:
`active_frontend`.
Stage 3 additionally owns `pmacs-gpu/src/attach.rs` for gated terminal
resize/pointer sending and coalescing. New daemon event variants must apply the
resize/pointer sending and coalescing. New daemon event variants 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.
limits live in or are re-exported from `pmacs-protocol`. Revision 8 resolves
the complete-frame size finding with a shared 8 MiB aggregate glyph-byte
bound: the maximum legal encoded frame is measured below the unchanged
16 MiB transport cap. The producer rejects rather than truncates or silently
chunks an over-bound internal snapshot.
### 0.5 Stage 1 review round 1
@ -245,6 +257,229 @@ Out-of-range DECSTBM bottom clamping, CSI-intermediate clone removal, and a
separately named configuration-time scrollback-row cap remain explicit
deferrals in §11.
### 0.7 Stage 2 landing and Stage 3 re-scout
Stage 2 landed on `main` as PR #130 at merge `86fc1bc` after two review
rounds. Its integrated tree is the Stage 3 base: protocol v18, four terminal
view-state acceptance cases in both default and CRDT builds, 109 required-GPU
tests, and the authenticated daemon/TUI seams described in §§5 and 9. Stage 3
does not reopen the landed escape, controller, selection, copy, resize, or Lua
contracts.
The current tree exposes five Stage 3 integration facts that are now locked:
- A semantic frontend learns a buffer switch from `BufferSnapshot`, but an
empty terminal identity snapshot does not identify itself as terminal.
Therefore the GPU sends both its ordinary byte viewport and a
version-gated terminal-cell size after each snapshot; the daemon ignores the
inapplicable declaration. No buffer-kind flag or pixel geometry is added.
- The existing TUI layout adapter subtracts modelines and handles splits from
a whole terminal size. Reusing it for the GPU would subtract chrome twice.
Stage 3 adds exact active-window semantic adapters whose `CellSize` already
describes the terminal content rectangle.
- `screen_generation` does not cover selection, scroll, or process-state
changes. Silence is therefore based on equality of the complete ordered
wire payload, not on any one generation counter.
- The legal Stage 1 cell bounds can exceed the 16 MiB transport cap when every
cell carries a maximal combining cluster. Rather than raising the allocation
ceiling for all pre-handshake and established messages, v19 adds an 8 MiB
aggregate glyph-byte limit and proves the largest legal encoded frame stays
below `MAX_FRAME_BYTES`.
- The GPU's document shaper cannot define terminal column origins. Terminal
mode uses fixed cell geometry: explicit row/column rectangles own
background, underline, selection, cursor, clipping, and hit testing; text
runs are positioned at cell origins and never determine later cell
positions.
### 0.8 Stage 3 framing review round 1
The first external Stage 3 review verified the Revision 8 base, named seams,
limits, Stage 1/2 compatibility, and continuity state and found no
architectural defect. This round closes its three precision findings:
- The measured maximum payload fixture now maximizes legal `Style` bytes on
every cell and distributes the exact glyph budget across legal clusters with
lengths chosen to maximize serialized length-prefix overhead.
- GPU acceptance names terminal copy through `InstanceSignal::Clipboard` and
the existing OS clipboard path; it does not imply that child OSC 52 is
honored.
- Arc 5 stage 2 is the vterm delivery; capitalized Stages 13 are its three
internal PR stages.
### 0.9 Stage 3 as built
Stage 3 is implemented on `vterm-gpu`, cut from canonical `main` @ `1dd47fc`
rather than stacked on the documentation branch, with the approved Revision 8
framing as its first commit. Criteria 2837 are implemented.
**Protocol.** `pmacs-protocol` gained `src/terminal.rs`, which now owns the
shared row/column/visible-cell/grapheme/metadata bounds (re-exported from
`crate::terminal::*` so every Stage 1/2 caller keeps its path and no duplicate
type exists), `TerminalProcessState`, `TerminalSelectionSpan`, `TerminalFrame`,
and `TerminalFrame::validate`. `unicode-width` was promoted to a workspace
dependency so the terminal screen and the wire validator measure glyph columns
with one table. Discriminants: `InstanceMessage::TerminalFrame` is 26,
`FrontendEvent::TerminalResize` 11, `TerminalPointer` 12 — each appended after
its enum's final v18 variant, with placement pins on `StatuslineSegments` and
`MenuPointer` guarding them. `SUPPORTED_PROTOCOL_VERSIONS` is `[6..=19]`.
The measured maximum legal frame — 512x512, one maximal selection span per row,
maximum title and process metadata, the maximal-encoding `Style` on every cell,
and the exact 8 MiB aggregate glyph budget distributed to maximize serialized
length-prefix overhead — encodes to **13,437,863 bytes**, against the unchanged
16,777,216-byte transport cap. A one-byte-over aggregate is rejected before
serialization.
**Two decisions the implementation had to make.**
1. *The `Viewport` gate keys on the ACTIVE buffer, not the declared one.*
§6.2 says "a document buffer accepts only `Viewport`; an active terminal
buffer accepts only `TerminalResize`", and the first implementation read
that as a test on the buffer the message names. That is not sufficient:
`Viewport` also ALIGNS the frontend's window to the buffer it names, so a
stale document viewport still in flight when a command opened a terminal
dragged the frontend straight back off it — the real-daemon acceptance
showed the window oscillating and no frame ever arriving. The daemon now
drops `Viewport` when the authenticated source's active window shows a
terminal (and, defensively, when the declared buffer is itself a terminal).
This is the framing's own wording taken literally; it is recorded here
because the weaker reading looks correct and silently produces a terminal
that never paints.
2. *The producer clears terminal mode on every exit path.* `in_terminal_mode`
is used daemon-side to suppress `CursorByte` and the presence sweep. An
early return from the terminal pass that left the flag set kept those
suppressed after the frontend went back to a document. Every path out of
the pass now clears it explicitly.
**Renderer.** `pmacs-gpu/src/terminal.rs` is a pure, cell-space paint planner:
it resolves a validated frame into background/underline/selection/cursor runs
and explicitly positioned text runs, taking the frontend's two default colors
as parameters so every paint rule is unit-testable without a GPU. `main.rs`
holds a two-state machine (`Document` / `Terminal`), builds one shaped buffer
per text run so a wide or cluster glyph's advance can never choose the next
column's origin, and swaps the document quad/squiggle/caret/minimap/gutter
batches for terminal ones while leaving the status band and popup layers alone.
**Criterion 37.** The GPU is a separate binary that depends only on
`pmacs-protocol`, so the single real path is driven as a process:
`pmacs-gpu --headless-probe <socket> <report>` attaches through the REAL
`attach` client (the reader sink was generalized so the winit path and the probe
share one handshake, outbox, and writer), presses a real key that opens a real
`/bin/sh` child, applies real `TerminalFrame`s, composites real pixels through
`render_to_view`, sends real input and a real geometry change, and writes named
observations the acceptance asserts on. `tests/vterm_stage3_acceptance.rs`
`a37_…` is that test; it is CRDT-gated because the daemon advertises
`crdt_replica` / `semantic_render` only on CRDT builds.
**Verification (from a clean tree).** `cargo fmt --check`; strict workspace
Clippy; 1,757 default + 1,933 CRDT library tests (3 ignored each); Stage 1
acceptance 9 default + 10 CRDT; Stage 2 acceptance 4 default + 4 CRDT; Stage 3
acceptance 4 default + 5 CRDT (the fifth is the CRDT-gated real-daemon path);
statusline acceptance 7 default + 8 CRDT; M4 120 passed (3 ignored, 1 filtered);
required GPU 127; one-invocation workspace sweep 2,919 passed across 83 suites
(19 ignored); `git diff --check` clean. One unexplained single failure of the
required-GPU suite occurred once mid-session and did not reproduce across eight
subsequent runs including the full sweep; its identity was not captured.
### 0.10 Stage 3 review round 1
PR #135's first review found no correctness blocker, confirmed the required-GPU
suite clean across four runs (twelve total with the author's), and raised two
design questions plus three minor notes. All five are addressed.
- **Presence while in terminal mode (finding 1) — fix kept, prediction not
reproduced.** The review predicted that skipping the presence sweep freezes
`last_broadcast` at the abandoned document position, leaving peers painting a
stale caret. It does not: the buffer-follow clears the terminal declaration
when it ships the snapshot, so `terminal_active` is false on the tick a window
first shows a terminal, and the declaration cannot arrive until a later tick
(the frontend learns the buffer id FROM that snapshot). One truthful sweep
always lands first. A real-daemon two-frontend test written to catch the
freeze passes against the pre-fix tree — the bite is VACUOUS, and it is
labelled a regression guard rather than fix evidence. The skip is removed
anyway: it was load-bearing on tick ordering and bought nothing, and its
removal makes "presence follows the frontend" structural.
- **Hover claimed durable control (finding 2) — real, fixed, bite-verified.**
`apply_terminal_gesture` claimed the controller before dispatching, including
for `Move`, which does nothing. A semantic frontend reports motion at pixel
rate, so sweeping the mouse across a PASSIVE split's terminal took durable
control and the next layout sync resized the shared PTY to that background
view's geometry — exactly the theft the controller rule exists to prevent.
Bare motion no longer claims; every deliberate gesture still does.
`scripts/bite HEAD src/editor.rs` on
`hover_does_not_steal_terminal_control_from_the_active_frontend` is a clean
behavioral bite (assertion failure, not a compile error).
- **Terminal motion is deduplicated by cell (finding 3).** Sub-cell motion
resolved to the same coordinate and still crossed the wire, where each event
is a daemon-side gesture. `State::terminal_motion_is_new` now gates it, and
press/release re-arm the memo so the first drag after a press still reports.
Its unit test cannot bite — the seam did not exist pre-fix — and says so.
- **Declarations record only once sent (finding 4).**
`terminal_declaration_if_changed` is now a pure query and
`note_terminal_declaration_sent` records, so a failed write is retried instead
of suppressed as already-declared. The existing `a35` test caught the contract
change and now pins both halves.
- **Unchanged frames are no longer re-validated (finding 5).** The complete
payload comparison runs before `validate`; only validated frames are ever
stored, so a frame equal to the baseline has already passed. The chrome tail
is factored into `terminal_chrome` so both exits emit it identically.
Post-review gates: `cargo fmt --check`; strict workspace Clippy; 1,757 default +
1,933 CRDT library tests; Stage 1 acceptance 9/10, Stage 2 4/4, Stage 3 5/7,
statusline 7/8 (default/CRDT); M4 120; required GPU 128; workspace sweep 2,921
across 83 suites (19 ignored); `git diff --check` clean.
### 0.11 Stage 3 review round 2
The second review verified all five round-1 fixes in code, re-ran the
required-GPU suite clean (a thirteenth consecutive pass, closing the flake
caveat), and found one new low-severity defect plus minor items.
- **A disconnect in terminal mode hid the notice (finding 1) — real, fixed,
hand-verified.** `AttachEvent::Disconnected` set the placeholder text but
never left terminal mode, where the document code layer is not prepared at
all and the terminal glyph layer keeps painting its last frame. The user was
left looking at a frozen, live-looking terminal that silently ignored input —
and with GPU auto-reconnect a named deferral, until relaunch.
`State::on_daemon_disconnected` now leaves terminal mode, forces a repaint
even when the notice text is byte-identical, and requests a redraw. Its test
lives in the same file as the fix, so `scripts/bite`'s file granularity
cannot bite it; the equivalent was done by hand — neutralizing only the
`exit_terminal_mode()` call makes the test fail, restoring it makes it pass.
- **Per-tick full-grid clone removed (finding 2).**
`sync_semantic_terminal_layout` compared geometry via `snapshot(..).size`,
cloning the whole visible cell grid every dispatcher tick to answer one
comparison. `TerminalManager::screen_size` reads it from the borrowed
projection instead.
- **Roadmap and handoff Arc 5 lines corrected (finding 3).** Both still said
Stage 3 was framed and awaiting approval, contradicting this PR's own ledger.
- **A press that misses the grid no longer arms a drag (nit).** It set
`pointer_drag_active` unconditionally, so a later in-grid motion sent a
`Drag` with no preceding `Down`. Daemon-side impact was nil
(`update_selection` bails without a drag anchor), but the state is now
honest. A release still always ends the drag, including one that wandered
outside the grid.
- **Inbound terminal events now require a negotiated v19 session (finding 5).**
The outbound `TerminalFrame` was gated twice while the inbound declarations
relied on the frontend's send gate alone. A pre-v19 peer cannot construct
these variants, so this only refuses a hand-rolled client — and the a32
forgery tests already prove such an event reaches nothing but the sender's
own authenticated active view — but the asymmetry was not deliberate, and
"gated in both directions" should be true of the code rather than only of the
frontends we ship.
Deferred from this round, named: **terminal wheel gestures discard scroll
magnitude.** One winit wheel event becomes one terminal gesture regardless of
the lines it accumulated, so a two-tick event scrolls the same distance as a
one-tick event, while the document path scrolls by `lines`. Closing it means
either sending N gestures (chattier) or widening the terminal pointer event
with a magnitude — a protocol change. Not worth either inside this stage.
Post-round-2 gates: `cargo fmt --check`; strict workspace Clippy; 1,758 default
+ 1,934 CRDT library tests; Stage 1 acceptance 9/10, Stage 2 4/4, Stage 3 5/7,
statusline 7/8 (default/CRDT); M4 120; required GPU 129; workspace sweep 2,923
across 83 suites (19 ignored); `git diff --check` clean.
## 1. Problem and ownership boundary
Pmacs can supervise a PTY and can parse enough ANSI to turn command output into
@ -881,25 +1116,29 @@ interval by adding the semantic terminal surface.
## 6. Stage 3 — protocol v19 and GPU integration
### 6.1 Wire additions
### 6.1 Protocol-owned wire contract
Protocol v19 appends, never inserts, these final variants:
Protocol v19 appends, never inserts, one instance message and two frontend
events after the final v18 variants:
```rust
InstanceMessage::TerminalFrame {
buffer_id: BufferId,
size: CellSize,
cells: Vec<Cell>,
cursor: Option<CellCoord>,
title: Option<String>,
screen_generation: u64,
selection: Vec<TerminalSelectionSpan>,
scroll_offset: u32,
at_bottom: bool,
pid: u32,
process: TerminalProcessState,
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct TerminalFrame {
pub buffer_id: BufferId,
pub size: CellSize,
pub cells: Vec<Cell>,
pub cursor: Option<CellCoord>,
pub title: Option<String>,
pub screen_generation: u64,
pub selection: Vec<TerminalSelectionSpan>,
pub scroll_offset: u32,
pub at_bottom: bool,
pub pid: u32,
pub process: TerminalProcessState,
}
InstanceMessage::TerminalFrame(TerminalFrame)
FrontendEvent::TerminalResize {
frontend_id: FrontendId,
buffer_id: BufferId,
@ -915,75 +1154,247 @@ FrontendEvent::TerminalPointer {
}
```
`TerminalProcessState`, `TerminalSelectionSpan`, and every limit needed to
validate these values move to or are re-exported from `pmacs-protocol`; core
paths may re-export them so Stage 1/2 callers do not gain duplicate types.
The shared limits are the landed row, column, visible-cell, grapheme, and
metadata bounds plus:
```rust
pub const MAX_TERMINAL_FRAME_GLYPH_BYTES: usize = 8 * 1024 * 1024;
```
The aggregate counts each `Char`'s UTF-8 length, every `Cluster` byte, and zero
for `Continuation`, with checked addition. `TerminalFrame::validate` is the
single structural policy used before daemon emission and after GPU decode.
`pmacs-protocol` takes the direct `unicode-width` dependency needed to validate
one/two-column glyph and continuation topology; another frontend must not
copy that policy.
The existing `MAX_FRAME_BYTES = 16 MiB` remains unchanged. A protocol test
constructs the maximum row/column frame with one maximal legal selection span
per row and maximum title/process metadata. Every cell carries a legal
one-column `Cluster` plus the maximal-encoding legal `Style`: RGB foreground,
background, and underline color, a non-`None` underline, and all boolean flags
set. The fixture distributes the exact aggregate budget across a legal cluster
in every cell and chooses cluster lengths to maximize total serialized
length-prefix overhead rather than packing bytes into a few maximal clusters.
This maximizes cluster and per-cell style overhead together. It serializes
the enclosing `InstanceMessage` with `postcard::to_allocvec` and asserts the
measured length is below `MAX_FRAME_BYTES`. A one-byte-over aggregate is
rejected before serialization.
This is a wire-specific bound, not a mutation of the TUI screen: if a child
constructs a larger internal snapshot, the semantic producer retains its last
valid baseline, emits nothing malformed or truncated, and logs the first
distinct bounded error until a valid frame clears the latch.
`TerminalFrame` is a complete visible-grid replacement. Empty is not a clear
sentinel: valid terminal sizes are non-zero and `cells.len()` must equal area.
Complete replacement is chosen over a second diff/cache protocol for the first
GPU stage. `screen_generation` advances on screen/process/title mutation;
scroll/selection have their own per-context epochs. The producer caches and
compares the complete context payload, so a view-only change still sends even
when `screen_generation` is unchanged, while an identical payload is silent.
sentinel: valid terminal dimensions are nonzero and `cells.len()` equals the
checked area exactly. `screen_generation` describes the published terminal
screen/title generation; selection, scroll, viewport, and process state may
change without it. The producer compares every ordered field against one
retained baseline per semantic frontend context. A view-only change therefore
sends; a byte-identical/equal payload is silent. Buffer snapshot, detach, and
context replacement clear that baseline so the next valid terminal frame is
authoritative.
All terminal frame fields are untrusted at the GPU boundary. Validation checks
shared row/column/area limits, exact area, cursor bounds, title length,
selection ordering/non-overlap/bounds, cluster UTF-8 and cluster-byte limits,
continuation structure, and attachment absence.
Invalid input is rejected atomically and the last valid terminal frame remains
painted.
Validation is atomic and covers:
The daemon routes terminal resize/pointer events by authenticated session
source. Claimed frontend and buffer must match the source's active terminal
window. A mismatch is dropped without resizing, selecting, or writing PTY
input.
- shared nonzero row/column/checked-area and aggregate-glyph limits;
- exact cell area; each caller separately requires the expected current
`buffer_id`;
- cursor bounds;
- title and process-state metadata length/control-character rules;
- valid nonempty cluster UTF-8 within the per-cluster limit;
- printable one/two-column leading glyphs, required wide continuations, and no
orphan continuation; continuation style is ignored in favor of its lead;
- no `Attachment` in any terminal cell;
- strictly increasing, one-per-row, nonempty selection spans inside the frame;
- `at_bottom == (scroll_offset == 0)`.
The protocol remains compatible with v18 where structurally possible:
Invalid daemon output is never written. Invalid GPU input retains the previous
valid frame, requests no redraw, and reports one latched diagnostic rather than
partially applying cells or metadata.
- v18 grid peers need no new message and continue to receive composed
`CellDelta` terminal windows;
Compatibility remains additive:
- v18 grid peers keep receiving the Stage 2 composed `CellDelta` terminal
windows;
- v18 semantic peers receive the immutable empty identity snapshot but no
terminal variant. They cannot display the terminal screen; terminal use from
those peers is unsupported, while normal document editing remains supported;
- v19 frontends gate the new outbound event variants on negotiated version;
- postcard byte pins cover the old final variants plus the newly appended
discriminants.
terminal message. Terminal use remains unsupported and invisible for those
peers; ordinary document editing remains supported;
- v19 frontends send `TerminalResize`/`TerminalPointer` only to a negotiated
v19 daemon;
- the v19 daemon filters `TerminalFrame` from every wire below v19;
- postcard pins preserve every v18 discriminant and pin all three appended
v19 discriminants.
### 6.2 Semantic producer
### 6.2 First-frame bootstrap and authenticated routing
When a semantic frontend's active buffer is a terminal, its producer emits
`TerminalFrame` plus the existing global theme/font/statusline facts that still
apply. It suppresses document-only style spans, decorations, inlays, block
adornments, folds, file summaries, line numbers, and document cursor layout for
that buffer. On switching back to a document, existing caches are invalidated
so the first document frame is a full authoritative resync.
`BufferSnapshot` remains the one semantic display-switch message. It carries
the terminal identity buffer's valid empty CRDT state and adds no terminal
flag. Immediately after applying any snapshot, a v19 GPU sends the existing
`Viewport` for the new replica/generation and, when its derived terminal
content rectangle has at least one row and column, `TerminalResize` with the
new `buffer_id` and size in cells. A zero-area window sends no terminal
declaration until a later geometry change yields a valid size.
The terminal frame is scoped to the authenticated frontend/window context,
because scrollback offset and selection are per view. A frame for one split or
frontend must never overwrite another context's baseline.
The daemon authenticates the connection source before reading any claimed
`frontend_id`. A document buffer accepts only `Viewport`; an active terminal
buffer accepts only `TerminalResize`. The inapplicable declaration is dropped
without mutation or logging noise. This dual declaration occurs only after a
snapshot or an actual geometry change, not every frame, and removes the
otherwise circular dependency where the GPU would need a terminal frame before
it knew to request one.
A GPU frontend reports its terminal viewport in **cells**, computed from its
own font metrics and pixel allocation. No pixel dimensions, glyph advances,
or DPI cross the daemon boundary. The daemon accepts a resize only from the
controlling active frontend defined in §5.4.
For an authenticated active terminal window, a valid `TerminalResize` always
records that exact `(frontend, window, buffer)` view size so passive frontends
can receive their own clipped/padded projection. It resizes the PTY only when
that exact view is the durable controller from §5.4; resize never claims
control. The semantic adapter consumes a content `CellSize` directly and does
not run the TUI placement helper or subtract a modeline again. Unchanged,
zero/out-of-range, passive, and failed resizes retain existing geometry under
the Stage 2 ordering contract.
### 6.3 GPU renderer
`TerminalPointer` must match the authenticated source, active terminal buffer,
and last accepted terminal viewport, and its coordinate must be in bounds.
Once accepted, it follows the same Stage 2 terminal pointer path: child SGR
mouse reporting when eligible, otherwise per-view scroll/selection/context
menu. Like other accepted pointer/input/focus events, it may claim control.
A forged source, stale buffer, missing declaration, or out-of-bounds coordinate
is dropped before view, controller, selection, menu, or PTY mutation.
The GPU keeps a dedicated terminal render mode keyed by active `buffer_id`.
It does not synthesize rope text from cells. Layout rules:
Key, paste, focus, detach, BEL, and clipboard keep their landed event/message
types. The daemon continues to overwrite client-claimed IDs with the
authenticated source before terminal dispatch. Terminal mode adds no raw-byte
input message and never sends child bytes through Lua or a shell.
- one terminal column equals the active monospace cell advance;
- `Glyph::Continuation` consumes a column and draws nothing;
- clusters shape as one cell origin with the declared one/two-column footprint;
- terminal foreground/background/reverse/style resolve from the cell, not
syntax or UI faces;
- selection spans resolve through `ui.selection` over child cells; cursor
placement comes from terminal snapshot state;
- rows never wrap in the frontend; clipping is by terminal cell bounds;
- status band remains outside the terminal grid;
- theme/font changes invalidate terminal shaping and geometry caches;
- a font-size or window-size change recomputes the cell viewport and sends one
`TerminalResize` after suppression of identical sizes.
### 6.3 Semantic producer and editor adapters
Terminal mouse hit-testing is frontend-local pixel -> terminal cell. The GPU
sends `TerminalPointer`, never a fake source byte offset.
`SemanticRenderState` stores the optional terminal viewport and last valid
terminal-frame baseline for its one authenticated frontend. When the active
buffer is terminal and a matching viewport exists, it requests an owned
snapshot for the exact active `TerminalViewKey`, validates/converts it, and
emits `TerminalFrame` only on complete-payload change.
The editor-side contract is narrow:
```rust
prepare_semantic_terminal_view(
frontend_id: FrontendId,
buffer_id: BufferId,
size: CellSize,
) -> Option<TerminalSnapshot>
sync_semantic_terminal_layout(
frontend_id: FrontendId,
buffer_id: BufferId,
size: CellSize,
) -> bool
dispatch_semantic_terminal_pointer(
frontend_id: FrontendId,
buffer_id: BufferId,
size: CellSize,
coord: CellCoord,
kind: MouseKind,
mods: Modifiers,
) -> bool
```
Each method derives the active `WindowId` from `frontend_id`, verifies that it
still displays `buffer_id`, and delegates to the existing manager/view/input
state machines. No method accepts a client-supplied window identity. Semantic
layout sync runs after event coalescing, before `tick_processes`, exactly beside
the landed grid sync; snapshot production occurs on the following render pass
from the already-published screen.
The terminal producer retains the buffer-independent/UI messages required by
the native frontend: `StatusFacts`, `ThemeFacts`, `FontFacts`,
`StatuslineSegments`, `MenuPrompt`, and `MinibufferPrompt`, plus daemon-owned
`DispatchIdle`, `Signal`, and lifecycle messages. The built-in terminal
statusline provider therefore remains the source of title/process/scroll text.
It suppresses document-only style spans, decorations, inlays, block
adornments, folds, file summaries, search/completion surfaces, line numbers,
document `CursorByte`, and presence for the terminal identity buffer.
On terminal activation the GPU clears/ignores every document-local visual
cache before painting the first frame. On switching back, snapshot reset clears
the producer's document baselines so the first matching document viewport
receives the existing full authoritative style/decoration/summary resync.
Terminal baselines are per frontend context; one split/frontend can never
suppress or overwrite another's scroll/selection projection.
### 6.4 GPU state and fixed-cell renderer
The GPU state machine is explicit: `Document` or
`Terminal { buffer_id, frame, derived paint caches }`. `BufferSnapshot`
immediately leaves terminal mode, clears the prior terminal frame and
terminal-only caches, and restores document defaults. A valid matching
`TerminalFrame` enters terminal mode. A stale-buffer frame is ignored; an
identical valid frame is retained without rebuild or redraw.
Terminal geometry is the drawable code rectangle above the existing status
band, with no document gutter or minimap. Rows and columns are
`floor(pixel_extent / active_cell_metric)`, clamped through the shared
nonzero protocol limits. Pixels, scale, DPI, and glyph advances never cross
the wire. Rows never wrap; excess frame rows/columns clip to the declared cell
rectangle and undersized content is padded with terminal defaults.
Painting is cell-derived rather than rope-derived:
- Every cell rectangle has a fixed origin from `(row, col)` and the active
monospace metrics. Backgrounds coalesce only adjacent equal resolved colors.
- `Default` foreground/background map to the GPU's existing plain-text/window
defaults; indexed colors use the existing xterm palette; truecolor is exact.
`reverse` swaps the two resolved colors. A continuation draws no glyph and
inherits its lead cell's paint semantics.
- Text shaping is split into explicitly positioned row runs. Contiguous
single-width ASCII may share one monospace buffer with rich attribute spans;
non-ASCII/cluster/wide leads start at an explicit cell origin and are clipped
to their declared one/two-cell footprint. A shaped advance never chooses the
next run's column.
- Bold and italic use font attributes. Single/double/dotted/dashed underlines
use fixed-cell quads; curly uses the existing squiggle pipeline. Default
underline color follows the post-reverse foreground.
- Terminal selection is a separate fixed-cell wash resolved through the
existing GPU `ui.selection` site; it never rewrites child cell styles.
Cursor visibility/position comes only from the frame and paints through the
existing caret primitive inside the terminal clip.
- The status band and its provider runs remain outside/above terminal paint.
Document decoration, presence, caret, gutter, and minimap batches are not
prepared or drawn in terminal mode.
### 6.5 GPU input, signals, and cache invalidation
Keyboard, paste, focus, and detach reuse existing attach messages and daemon
encoding. Inside the terminal clip, GPU mouse hit testing is
pixel-to-`CellCoord`; press/release/drag/move/wheel sends `TerminalPointer`
instead of a source-byte `Pointer`. The status band/outside clip is never a
terminal hit. Move/drag events coalesce only while kind, coordinate, and
modifiers are unchanged.
Window resize, scale change, accepted `FontFacts`, and buffer snapshot
recompute the cell viewport. One changed size sends one version-gated
`TerminalResize`; an equal size is silent. A font change clears terminal shape
and geometry caches until a matching-size authoritative frame arrives.
Invalidation is deliberately narrow:
- a changed terminal frame rebuilds cell text/background/underline/selection/
cursor data, but not statusline buffers;
- `ThemeFacts` rebuilds the selection/status color sites, not child cell
shaping;
- `FontFacts` rebuilds shape and geometry and emits a changed cell viewport;
- status/statusline/menu/minibuffer messages touch only their existing caches;
- a duplicate valid terminal frame and an unchanged geometry declaration do
no work.
`InstanceSignal::Bell` requests one frontend attention event for each new
active-terminal BEL; historical/passive suppression remains daemon-owned.
`InstanceSignal::Clipboard` keeps the existing OS clipboard path. OSC title is
sanitized frame/statusline metadata only and never becomes a raw window-title
or terminal-control operation.
## 7. Four-agent execution plan
@ -993,19 +1404,20 @@ coherent.
| Owner | Stable scope | Primary files |
| --- | --- | --- |
| Lead/integrator | contracts first; `TerminalManager`, Lua/builtin wiring, lifecycle/signal integration, shared acceptance, gates, docs, branches/PRs | `src/terminal/session.rs`, `src/lua_bindings/mod.rs`, `builtin/runtime/terminal.lua`, narrow Stage 2 authenticated-dispatch sections of `src/daemon.rs`, `tests/vterm_*_acceptance.rs`, docs |
| VT core agent | encoder corrections and screen query seams needed by view projection; no renderer ownership | `src/ansi.rs`, `src/terminal/screen.rs`, `src/terminal/input.rs` |
| TUI agent | view projection, frontend-explicit grid composition/cursor, per-frontend dispatch, local events, scroll/selection/copy/resize | `src/terminal/view.rs`, `src/instance_render.rs`, `src/frontend.rs`, owned sections of `src/editor.rs`, focused TUI tests |
| Protocol/GPU agent | Stage 2 contract review; Stage 3 v19 types/limits/gates, semantic producer, authenticated new-event routing, GPU state/render/hit-test | `pmacs-protocol`, `src/protocol.rs`, `src/semantic_render.rs`, Stage 3 sections of `src/daemon.rs`, `pmacs-gpu/src/{main,attach}.rs` |
| Lead/integrator | contracts first; Stage 1/2 manager/Lua lifecycle; Stage 3 semantic producer, authenticated daemon routing, shared acceptance, gates, docs, branches/PRs | `src/terminal/session.rs`, `src/lua_bindings/mod.rs`, `builtin/runtime/terminal.lua`, `src/semantic_render.rs`, owned Stage 3 sections of `src/daemon.rs`, `tests/vterm_*_acceptance.rs`, docs |
| VT core agent | encoder/screen seams; Stage 3 snapshot-to-wire conversion and shared terminal re-exports, no renderer ownership | `src/ansi.rs`, `src/terminal/{screen,input,session,view}.rs` in assigned non-overlapping sections |
| TUI agent | landed TUI projection/input; Stage 3 exact active-window semantic adapters and parity tests, no GPU/protocol files | owned sections of `src/editor.rs`, focused TUI/adapter tests |
| Protocol/GPU agent | protocol v19 types/limits/pins/gates and native GPU state/render/hit-test/attach sending | `pmacs-protocol`, `src/protocol.rs`, `pmacs-gpu/src/{main,attach}.rs` |
Coordination rules:
- Lead establishes types, invariants, and method signatures before another
lane edits callers.
- Strict file ownership. `src/editor.rs` passes from lead to TUI only after
construction wiring; lead and TUI coordinate exact non-overlapping
`src/daemon.rs`/signal hunks. Stage 3 daemon work begins only after Stage 2
lands.
- Lead establishes invariants and method signatures before another lane edits
callers; the protocol/GPU agent then implements the shared wire types.
- Strict Stage 3 file ownership follows the table. VT-core and TUI edits in
`src/terminal/{session,view}.rs` / `src/editor.rs` are agreed as exact
non-overlapping symbols before work starts. Only the lead edits
`src/semantic_render.rs`, Stage 3 daemon routing, or shared acceptance.
Stage 3 begins only from landed Stage 2.
- Workers do not update docs, ledgers, branches, or PRs and do not stash,
checkout, rebase, or merge.
- Workers add focused tests in owned modules. Lead alone owns shared
@ -1021,25 +1433,33 @@ Per-stage utilization:
- 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.
- Stage 3: lead locks the protocol constants/types and editor method signatures,
then owns `src/semantic_render.rs`, authenticated `src/daemon.rs` routing,
shared acceptance, and integration. The protocol/GPU agent owns
`pmacs-protocol` plus `pmacs-gpu/src/{main,attach}.rs`; the VT-core agent owns
snapshot-to-wire conversion/shared terminal re-exports; the TUI agent owns
the exact semantic adapters in `src/editor.rs` and parity tests. All four run
in parallel only after the lead's contract checkpoint; no TUI rendering
behavior changes.
## 8. Branch and PR plan
Stage 1 landed on `main` as PR #126 at merge `643d1e1`. Continue one clean PR
at a time:
Stage 1 landed as PR #126 at merge `643d1e1`. Stage 2 landed as PR #130 at
merge `86fc1bc`. Revision 8 is preserved for approval on
`vterm-stage3-framing`, based on the integrated current `main`.
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.
After explicit framing approval:
The framing branch is `vterm-framing` in worktree `pmacs-vterm-framing`.
Implementation branches are not stacked across an unmerged parent. This avoids
base-branch deletion/auto-close risk and makes each PR's gate evidence honest.
1. create worktree `pmacs-vterm-gpu` and branch `vterm-gpu` from the then-current
canonical `githubsucks/main`;
2. implement only Stage 3 / criteria 2837;
3. run the complete sequential gate and bite suite;
4. open the third and final vterm PR for user review; never merge it without
explicit authorization.
The historical framing branch `vterm-framing` remains the Revision 7 record.
The Stage 3 implementation is not stacked on a documentation branch or an
unmerged feature parent.
## 9. Acceptance
@ -1184,47 +1604,97 @@ coverage remains beside the owning implementation. The criteria map as follows:
### Stage 3 — GPU/protocol
28. Protocol v19 appends all new variants after v18 pins; v18 grid traffic
round-trips unchanged and new outbound variants are version-gated.
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.
30. Semantic terminal activation suppresses document-only messages; switching
back forces a complete document resync.
31. Two frontends/splits on one terminal keep independent scroll/selection
snapshots; only the active controlling context resizes or writes input.
32. Forged frontend/buffer IDs in terminal resize/pointer events cannot affect
another terminal or process.
33. Headless GPU rendering pins background rectangles, indexed/truecolor,
reverse, wide/combining cells, clipping, cursor visibility, status-band
separation, and no frontend wrapping.
34. Font/window resize emits cell dimensions, never pixels, and identical
resize requests are suppressed.
35. Theme/font/terminal generation changes invalidate exactly the affected
caches; an unchanged terminal frame produces no redraw message.
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.
28. Protocol v19 appends all three new variants after the v18 pins; v18 grid
traffic round-trips byte-identically, v18 semantic document traffic still
works, and both outbound directions are independently version-gated.
29. The measured maximum legal frame uses maximum dimensions, one maximum
selection span per row, maximum metadata, the exact aggregate glyph budget
present as one legal cluster per cell with lengths chosen to maximize
serialized length-prefix overhead, and the maximal-encoding legal style on
every cell. Its enclosing message encodes below the unchanged
16 MiB transport cap. Validation accepts exact shared boundaries and
atomically rejects one-byte-over aggregate, over-area, bad area,
out-of-bounds cursor, control/malformed/overlong clusters, orphan/missing
continuations, attachments, duplicate/out-of-order-row or otherwise
invalid selection spans, inconsistent bottom state, overlong title, and
overlong process text while retaining the prior valid frame.
30. First terminal activation emits one authoritative complete frame after the
dual viewport declaration, then stays silent for a completely equal
payload. View-only and process-only changes emit despite an unchanged
screen generation. Terminal activation suppresses document projection,
cursor, presence, gutter, and completion; switching back forces one
complete document resync.
31. Two semantic frontends over one terminal receive independent sizes,
scroll, selection, cursor visibility, and baselines while sharing one
process/screen. A passive declaration produces its clipped/padded frame but
does not resize; only the exact durable controller changes PTY geometry.
32. Forged frontend/buffer IDs, stale buffers, undeclared viewports, and
out-of-bounds terminal pointer/resize events cannot affect another view,
controller, terminal selection, menu, PTY size, or child input.
33. Headless GPU rendering pins default/indexed/truecolor foreground and
background, reverse, bold/italic, all supported underline forms, wide and
combining footprints, continuation inheritance, selection, cursor,
padding/clipping, status-band separation, and absence of frontend wrapping,
document gutter/minimap, and document overlays.
34. GPU key, paste, focus, press/release/drag/move/wheel input reaches the same
Stage 2 terminal encoders and ownership paths. Pixel hit testing yields only
in-bounds cells, never source bytes or wire pixels, and unchanged move/drag
cells coalesce.
35. Buffer/window/scale/font transitions emit exactly one changed cell
declaration and suppress identical sizes. Terminal, theme, font, and
statusline changes invalidate only their named caches; duplicate valid
frames request no redraw.
36. Each new active BEL produces one GPU attention action. Terminal copy
publishes through `InstanceSignal::Clipboard` and the existing OS
clipboard path exactly once. Sanitized OSC title/process/scroll metadata
reaches the terminal provider/statusline without becoming a raw host-title
or control effect.
37. A hermetic real daemon + required headless GPU smoke opens `/bin/sh`, runs
a full-screen alternate-screen probe, exercises key/paste/mouse/resize,
BEL/title metadata, scroll/select/copy through the clipboard signal, clean
exit, and buffer switch-back, then proves the preserved main screen and all
child/reader/session cleanup.
#### Stage 3 verification map
The cross-surface suite is `tests/vterm_stage3_acceptance.rs`; focused wire and
GPU assertions remain in `pmacs-protocol` and `pmacs-gpu` respectively.
- **2829:** postcard discriminant/round-trip pins, common
`TerminalFrame::validate` boundary table, measured maximum legal payload, and
v18/v19 daemon/frontend send filters.
- **3032:** semantic producer baseline/reset tests, dual-declaration
bootstrap, real dispatcher source-forgery tests, and two-frontend
controller/passive-view acceptance.
- **33:** pure fixed-cell paint-plan tests plus required headless offscreen
pixel probes for representative color/style/wide/selection/cursor/clipping
cases.
- **3436:** attach/event coalescing tests, authenticated daemon input tests,
and headless signal/statusline observations.
- **37:** one real-daemon/real-PTY/headless-wgpu acceptance path; it is not
replaced by a decoded-message fixture.
## 10. Gates and bite verification
Every PR runs the standing full gates from `AGENTS.md`, sequentially, plus its
stage acceptance suite. Stage 2 includes a real hermetic TUI PTY smoke; stage 3
includes `PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu` and the real-daemon GPU
probe.
stage acceptance suite. Stage 2 included a real hermetic TUI PTY smoke. Stage 3
adds `cargo test --test vterm_stage3_acceptance`, its CRDT variant,
`PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu`, the real-daemon/headless-GPU
probe, and the ordinary workspace sweep.
New behavioral acceptance must be bite-verified against the immediate
pre-stage tree with `scripts/bite` where the swapped files compile. Protocol
v19 tests additionally pin postcard bytes and verify the older-version daemon
filter; a test that merely fails to decode on old code is not a useful bite.
v19 tests additionally pin postcard bytes, prove the measured aggregate-bound
maximum stays below the unchanged transport cap, and verify both
older-version send filters; a test that merely fails to decode on old code is
not a useful bite.
## 11. Explicit deferrals
Not part of these three PRs:
- terminal image protocols (sixel, kitty graphics, iTerm images);
- OSC 52 host clipboard writes and OSC 8 hyperlink interaction;
- OSC 8 hyperlink interaction;
- faint, blink, conceal, and strikethrough additions to shared `Style`;
- kitty keyboard protocol, key release events, media keys, and IME preedit;
- cursor-shape/blink rendering and numeric-keypad distinction absent from the
@ -1264,7 +1734,7 @@ panic, unbounded allocation, or child leak.
## 12. Resolved decisions
The 2026-07-21 architecture, re-scout, and final framing review resolved every
The 2026-07-22 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.
@ -1272,7 +1742,8 @@ current question:
3. Exit: retain the buffer and append the process PID/outcome line from §4.1.
4. Compatibility: additive v19; v18 grid remains supported, v18 semantic has
no terminal surface.
5. GPU wire: complete visible frames with complete-payload suppression.
5. GPU wire: complete visible frames with complete-payload suppression under
one shared 8 MiB aggregate glyph-byte bound; the 16 MiB transport cap stays.
6. Style: preserve the shared encoding and defer unsupported attributes.
7. Identity: one process/screen per terminal `BufferId`.
8. View state: logical-line/cell anchors per
@ -1293,3 +1764,11 @@ current question:
origin and otherwise error without mutation.
14. Host effects: clipboard and BEL use explicit frontend signals; OSC title
remains sanitized metadata.
15. Bootstrap: after every semantic snapshot, v19 sends both byte viewport and
terminal cell size; the authenticated daemon accepts only the declaration
matching the active buffer kind.
16. Semantic resize: the declaration records passive view geometry, but only
the exact durable controller changes the shared PTY/screen size.
17. GPU layout: fixed cell rectangles own geometry and hit testing; shaped
glyph advances never determine subsequent columns, and OSC title remains
metadata rather than a host control effect.

View File

@ -23,10 +23,10 @@ use std::sync::{Arc, Condvar, Mutex};
use std::thread;
use pmacs_protocol::{
AttachRequest, BufferId, ByteRange, CrdtOp, FrontendCapabilities, FrontendEvent, FrontendId,
Hello, InstanceMessage, Key, KeyEvent, Modifiers, PROTOCOL_VERSION, PointerKind,
SUPPORTED_PROTOCOL_VERSIONS, TransportError, is_supported_protocol_version, read_message,
write_message,
AttachRequest, BufferId, ByteRange, CellCoord, CellSize, CrdtOp, FrontendCapabilities,
FrontendEvent, FrontendId, Hello, InstanceMessage, Key, KeyEvent, Modifiers, MouseKind,
PROTOCOL_VERSION, PointerKind, SUPPORTED_PROTOCOL_VERSIONS, TransportError,
is_supported_protocol_version, read_message, write_message,
};
use winit::event_loop::EventLoopProxy;
@ -141,6 +141,19 @@ fn coalesce_kind(event: &FrontendEvent) -> Option<u8> {
kind: PointerKind::Drag,
..
} => Some(1),
// Vterm Stage 3 — terminal pointer MOVE and DRAG are the
// high-frequency terminal gestures and coalesce like their
// document twin. Press, release, and wheel stay lossless:
// collapsing a wheel run would silently lose scroll distance,
// and collapsing a press/release would break selection.
FrontendEvent::TerminalPointer {
kind: MouseKind::Move,
..
} => Some(2),
FrontendEvent::TerminalPointer {
kind: MouseKind::Drag(_),
..
} => Some(3),
_ => None,
}
}
@ -216,6 +229,24 @@ impl Outbox {
pub fn connect(
socket_path: &Path,
proxy: EventLoopProxy<AppEvent>,
) -> Result<AttachClient, AttachClientError> {
connect_with_sink(socket_path, move |event| {
proxy.send_event(AppEvent::Attach(event)).is_ok()
})
}
/// [`connect`], with the decoded-message destination left to the caller.
///
/// The winit path forwards to the event loop; the headless probe used by
/// the Stage 3 acceptance forwards to a channel. Both drive the SAME
/// handshake, capability gate, reader, writer, and outbox — a probe that
/// reimplemented any of that would prove nothing about the real client.
///
/// `sink` returns `false` when its destination is gone, which stops the
/// reader thread.
pub fn connect_with_sink(
socket_path: &Path,
sink: impl Fn(AttachEvent) -> bool + Send + 'static,
) -> Result<AttachClient, AttachClientError> {
let stream = UnixStream::connect(socket_path).map_err(AttachClientError::Connect)?;
@ -300,17 +331,13 @@ pub fn connect(
loop {
match read_message::<InstanceMessage>(&mut read_stream) {
Ok(msg) => {
if proxy
.send_event(AppEvent::Attach(AttachEvent::Message(Box::new(msg))))
.is_err()
{
// Main loop torn down — quietly exit.
if !sink(AttachEvent::Message(Box::new(msg))) {
// Destination torn down — quietly exit.
return;
}
}
Err(e) => {
let _ = proxy
.send_event(AppEvent::Attach(AttachEvent::Disconnected(e.to_string())));
sink(AttachEvent::Disconnected(e.to_string()));
return;
}
}
@ -455,6 +482,44 @@ impl AttachClient {
})
}
/// Send a `FrontendEvent::TerminalResize` (Vterm Stage 3): the
/// terminal-cell geometry this frontend has on screen. Callers gate
/// on [`Self::server_protocol_version`] `>= 19`.
///
/// Cells, never pixels — the frontend divides its own drawable
/// rectangle by its own metrics, keeping the no-pixels contract the
/// document `Viewport` established.
pub fn send_terminal_resize(
&self,
buffer_id: BufferId,
size: CellSize,
) -> Result<(), TransportError> {
self.send_event(FrontendEvent::TerminalResize {
frontend_id: self.frontend_id,
buffer_id,
size,
})
}
/// Send a `FrontendEvent::TerminalPointer` (Vterm Stage 3): a
/// gesture hit-tested locally to a terminal cell. Callers gate on
/// [`Self::server_protocol_version`] `>= 19`.
pub fn send_terminal_pointer(
&self,
buffer_id: BufferId,
coord: CellCoord,
kind: MouseKind,
mods: Modifiers,
) -> Result<(), TransportError> {
self.send_event(FrontendEvent::TerminalPointer {
frontend_id: self.frontend_id,
buffer_id,
coord,
kind,
mods,
})
}
/// Send a `FrontendEvent::MenuPointer` (Q#CM1) — open-menu
/// navigation hit-tested locally against the popup we drew. `index`
/// is the row the pointer is over (`None` = off the menu); `invoke`
@ -518,7 +583,7 @@ impl AttachClient {
#[cfg(test)]
mod tests {
use super::*;
use pmacs_protocol::InstanceCapabilities;
use pmacs_protocol::{InstanceCapabilities, MouseButton};
fn caps(
multi_frontend: bool,
@ -652,6 +717,70 @@ mod tests {
));
}
fn fe_terminal_pointer(kind: MouseKind, row: u32, col: u32) -> FrontendEvent {
FrontendEvent::TerminalPointer {
frontend_id: FrontendId(1),
buffer_id: BufferId::from_raw(1),
coord: CellCoord::new(row, col),
kind,
mods: Modifiers::NONE,
}
}
/// Acceptance 34: terminal move/drag runs coalesce to the latest
/// cell, while press, release, and wheel stay lossless and ordered.
#[test]
fn terminal_motion_coalesces_but_presses_and_wheels_stay_lossless() {
let mut ob = Outbox::new();
ob.enqueue(fe_terminal_pointer(
MouseKind::Down(MouseButton::Left),
0,
0,
));
ob.enqueue(fe_terminal_pointer(
MouseKind::Drag(MouseButton::Left),
0,
1,
));
ob.enqueue(fe_terminal_pointer(
MouseKind::Drag(MouseButton::Left),
0,
2,
));
ob.enqueue(fe_terminal_pointer(
MouseKind::Drag(MouseButton::Left),
0,
3,
));
ob.enqueue(fe_terminal_pointer(MouseKind::Up(MouseButton::Left), 0, 3));
assert_eq!(ob.queue.len(), 3, "the drag run collapsed to one");
assert!(matches!(
&ob.queue[1],
FrontendEvent::TerminalPointer {
kind: MouseKind::Drag(MouseButton::Left),
coord: CellCoord { row: 0, col: 3 },
..
}
));
// Wheel ticks carry scroll DISTANCE; collapsing a run would
// silently lose scrollback rows.
let mut wheel = Outbox::new();
wheel.enqueue(fe_terminal_pointer(MouseKind::ScrollUp, 1, 1));
wheel.enqueue(fe_terminal_pointer(MouseKind::ScrollUp, 1, 1));
wheel.enqueue(fe_terminal_pointer(MouseKind::ScrollUp, 1, 1));
assert_eq!(wheel.queue.len(), 3, "wheel ticks are lossless");
// Hover motion coalesces, but not across a different kind.
let mut moves = Outbox::new();
moves.enqueue(fe_terminal_pointer(MouseKind::Move, 2, 1));
moves.enqueue(fe_terminal_pointer(MouseKind::Move, 2, 2));
assert_eq!(moves.queue.len(), 1);
moves.enqueue(fe_terminal_pointer(MouseKind::ScrollDown, 2, 2));
moves.enqueue(fe_terminal_pointer(MouseKind::Move, 2, 3));
assert_eq!(moves.queue.len(), 3);
}
#[test]
fn coalescing_only_collapses_a_same_kind_tail() {
let mut ob = Outbox::new();

File diff suppressed because it is too large Load Diff

753
pmacs-gpu/src/terminal.rs Normal file
View File

@ -0,0 +1,753 @@
//! Fixed-cell terminal paint planning (Vterm Stage 3).
//!
//! The document renderer derives geometry from shaped text: a glyph's
//! advance decides where the next glyph starts. A terminal cannot work
//! that way. Its column origins are defined by the CHILD, and the
//! frontend's font has no say in them — a wide glyph the font renders
//! 1.9 cells wide still occupies exactly two columns, and the cell after
//! it still starts at exactly `col * advance`.
//!
//! So this module resolves a [`TerminalFrame`] into a plan expressed in
//! CELLS, never pixels. Row/column rectangles own backgrounds,
//! underlines, selection, the cursor, and clipping; the renderer
//! multiplies by its own metrics at the end. That split is also what
//! makes the paint rules testable without a GPU: everything here is a
//! pure function of the frame plus two default colors.
use pmacs_protocol::{Cell, CellSize, Color, Glyph, Style, TerminalFrame, UnderlineStyle};
/// A resolved 24-bit color. The plan carries no `Default` sentinel:
/// resolution happens once, up front, because `reverse` swaps the
/// RESOLVED pair — a reversed default-on-default cell must come out as
/// dark-on-light, which is impossible if `Default` survives into the
/// swap.
pub type Rgb = [u8; 3];
/// The frontend defaults `Color::Default` resolves to.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct TerminalPalette {
/// The GPU's plain-text color.
pub default_fg: Rgb,
/// The GPU's window background.
pub default_bg: Rgb,
}
/// A half-open run of cells on one row: `[start_col, end_col)`.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct CellRun {
/// Row within the frame.
pub row: u32,
/// Inclusive first column.
pub start_col: u32,
/// Exclusive last column.
pub end_col: u32,
}
/// A background run with its resolved (post-`reverse`) color.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct BackgroundRun {
/// Cells covered.
pub run: CellRun,
/// Resolved fill.
pub color: Rgb,
}
/// An underline run with its resolved color and form.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct UnderlineRun {
/// Cells covered.
pub run: CellRun,
/// Resolved stroke color.
pub color: Rgb,
/// Which underline form to draw.
pub style: UnderlineStyle,
}
/// One shaped text run pinned to an explicit cell origin.
///
/// `cells` is the run's declared footprint. The renderer clips to it, so
/// a font whose glyph is wider than the cells the child allocated
/// overflows into a clip, never into the next column's origin.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TextRun {
/// Row within the frame.
pub row: u32,
/// Origin column.
pub col: u32,
/// Declared width in cells.
pub cells: u32,
/// Text to shape.
pub text: String,
/// Resolved (post-`reverse`) foreground.
pub color: Rgb,
/// Bold via font attributes.
pub bold: bool,
/// Italic via font attributes.
pub italic: bool,
}
/// Everything one terminal frame paints, in cell coordinates.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct TerminalPaintPlan {
/// The frame's declared grid.
pub size: CellSize,
/// Background fills, coalesced across adjacent equal colors.
pub backgrounds: Vec<BackgroundRun>,
/// Text runs in row-major order.
pub runs: Vec<TextRun>,
/// Underline strokes, coalesced across adjacent equal color+form.
pub underlines: Vec<UnderlineRun>,
/// Editor-owned selection wash, one run per selected row.
pub selection: Vec<CellRun>,
/// The child's cursor cell, when visible.
pub cursor: Option<CellRun>,
}
/// Resolve a cell's foreground and background, applying `reverse` after
/// both defaults have been substituted.
fn resolved_colors(style: &Style, palette: TerminalPalette) -> (Rgb, Rgb) {
let fg = resolve_color(style.fg, palette.default_fg);
let bg = resolve_color(style.bg, palette.default_bg);
if style.reverse { (bg, fg) } else { (fg, bg) }
}
/// Map one wire color through the frontend's defaults and palette.
fn resolve_color(color: Color, default: Rgb) -> Rgb {
match color {
Color::Default => default,
Color::Rgb(r, g, b) => [r, g, b],
Color::Indexed(index) => indexed_rgb(index),
}
}
/// Standard xterm-style 256-color palette: 16 base colors, the 6×6×6
/// cube (16..=231), then the 24-step grayscale ramp (232..=255).
///
/// Deliberately the same table the document path's `indexed_to_glyphon`
/// uses. Two palettes in one frontend would make an indexed diagnostic
/// and an indexed terminal cell disagree on what "red" is.
pub fn indexed_rgb(index: u8) -> Rgb {
const ANSI16: [Rgb; 16] = [
[0, 0, 0],
[205, 49, 49],
[13, 188, 121],
[229, 229, 16],
[36, 114, 200],
[188, 63, 188],
[17, 168, 205],
[229, 229, 229],
[102, 102, 102],
[241, 76, 76],
[35, 209, 139],
[245, 245, 67],
[59, 142, 234],
[214, 112, 214],
[41, 184, 219],
[255, 255, 255],
];
if index < 16 {
return ANSI16[index as usize];
}
if (16..=231).contains(&index) {
const STEPS: [u8; 6] = [0, 95, 135, 175, 215, 255];
let i = index - 16;
return [
STEPS[(i / 36) as usize],
STEPS[((i / 6) % 6) as usize],
STEPS[(i % 6) as usize],
];
}
let level = 8 + 10 * (index - 232);
[level, level, level]
}
/// The style a cell paints with.
///
/// A continuation has no paint identity of its own: it is the second
/// column of the preceding glyph, so it inherits that lead's style. A
/// continuation that carried its own style would let the two halves of
/// one wide character disagree on background or underline.
fn paint_style(cells: &[Cell], index: usize, row_start: usize) -> &Style {
if matches!(cells[index].glyph, Glyph::Continuation) && index > row_start {
&cells[index - 1].style
} else {
&cells[index].style
}
}
/// The text a cell contributes, or `None` for a continuation.
fn cell_text(cell: &Cell) -> Option<String> {
match &cell.glyph {
Glyph::Char(ch) => Some(ch.to_string()),
// Validation already proved the cluster is UTF-8; a defensive
// lossy decode keeps a future validator change from panicking
// the renderer.
Glyph::Cluster(bytes) => Some(String::from_utf8_lossy(bytes).into_owned()),
Glyph::Continuation => None,
}
}
/// Whether a cell can join an ASCII run.
///
/// Only single-column ASCII qualifies. Everything else — clusters, wide
/// leads, non-ASCII — gets its own explicitly positioned run, because
/// only for ASCII in a monospace face does the shaped advance reliably
/// equal the cell advance.
fn ascii_runnable(cell: &Cell) -> bool {
matches!(&cell.glyph, Glyph::Char(ch) if ch.is_ascii_graphic() || *ch == ' ')
}
/// Whether a cell is a wide lead (its continuation follows).
fn is_wide_lead(cells: &[Cell], index: usize) -> bool {
cells
.get(index + 1)
.is_some_and(|next| matches!(next.glyph, Glyph::Continuation))
}
impl TerminalPaintPlan {
/// Resolve a validated frame into cell-space paint data.
///
/// The frame must already have passed
/// [`TerminalFrame::validate`]; this assumes the cell count, the
/// wide-continuation topology, and the selection spans are sound.
#[must_use]
pub fn build(frame: &TerminalFrame, palette: TerminalPalette) -> Self {
let cols = frame.size.cols as usize;
let mut plan = Self {
size: frame.size,
..Self::default()
};
if cols == 0 {
return plan;
}
for row in 0..frame.size.rows {
let row_start = row as usize * cols;
let row_cells = &frame.cells[row_start..row_start + cols];
plan.plan_row(row, row_cells, palette);
}
plan.selection = frame
.selection
.iter()
.map(|span| CellRun {
row: span.row,
start_col: span.start_col,
end_col: span.end_col,
})
.collect();
plan.cursor = frame.cursor.map(|cursor| CellRun {
row: cursor.row,
start_col: cursor.col,
end_col: cursor.col + 1,
});
plan
}
/// Plan one row's backgrounds, underlines, and text runs.
#[allow(clippy::too_many_lines, reason = "one row's paint state machine")]
fn plan_row(&mut self, row: u32, cells: &[Cell], palette: TerminalPalette) {
// Backgrounds and underlines coalesce across the whole row;
// text runs break on any attribute change AND on any glyph that
// cannot be positioned by shaping.
let mut bg_open: Option<(u32, Rgb)> = None;
let mut ul_open: Option<(u32, Rgb, UnderlineStyle)> = None;
let mut text_open: Option<(u32, String, Rgb, bool, bool)> = None;
for col in 0..cells.len() {
let style = paint_style(cells, col, 0);
let (fg, bg) = resolved_colors(style, palette);
let col_u32 = col as u32;
match bg_open {
Some((start, color)) if color != bg => {
self.backgrounds.push(BackgroundRun {
run: CellRun {
row,
start_col: start,
end_col: col_u32,
},
color,
});
bg_open = Some((col_u32, bg));
}
Some(_) => {}
None => bg_open = Some((col_u32, bg)),
}
// A `Default` underline color follows the POST-reverse
// foreground: on a reversed cell the underline must track
// the color the glyph actually drew in, not the one the
// child nominally set.
let underline = (style.underline != UnderlineStyle::None).then(|| {
let color = match style.underline_color {
Color::Default => fg,
other => resolve_color(other, fg),
};
(color, style.underline)
});
match (ul_open, underline) {
(Some((start, color, form)), Some((next_color, next_form)))
if color != next_color || form != next_form =>
{
self.underlines.push(UnderlineRun {
run: CellRun {
row,
start_col: start,
end_col: col_u32,
},
color,
style: form,
});
ul_open = Some((col_u32, next_color, next_form));
}
(Some((start, color, form)), None) => {
self.underlines.push(UnderlineRun {
run: CellRun {
row,
start_col: start,
end_col: col_u32,
},
color,
style: form,
});
ul_open = None;
}
(None, Some((color, form))) => ul_open = Some((col_u32, color, form)),
(Some(_), Some(_)) | (None, None) => {}
}
let cell = &cells[col];
let joinable = ascii_runnable(cell) && !is_wide_lead(cells, col);
if joinable {
match text_open.as_mut() {
Some((_, text, color, bold, italic))
if *color == fg && *bold == style.bold && *italic == style.italic =>
{
text.push_str(&cell_text(cell).unwrap_or_default());
}
_ => {
self.flush_text_run(row, text_open.take());
text_open = Some((
col_u32,
cell_text(cell).unwrap_or_default(),
fg,
style.bold,
style.italic,
));
}
}
continue;
}
self.flush_text_run(row, text_open.take());
let Some(text) = cell_text(cell) else {
// A continuation draws nothing: its lead already
// covers both columns.
continue;
};
let cells_wide = if is_wide_lead(cells, col) { 2 } else { 1 };
self.runs.push(TextRun {
row,
col: col_u32,
cells: cells_wide,
text,
color: fg,
bold: style.bold,
italic: style.italic,
});
}
let end = cells.len() as u32;
if let Some((start, color)) = bg_open {
self.backgrounds.push(BackgroundRun {
run: CellRun {
row,
start_col: start,
end_col: end,
},
color,
});
}
if let Some((start, color, form)) = ul_open {
self.underlines.push(UnderlineRun {
run: CellRun {
row,
start_col: start,
end_col: end,
},
color,
style: form,
});
}
self.flush_text_run(row, text_open);
}
fn flush_text_run(&mut self, row: u32, open: Option<(u32, String, Rgb, bool, bool)>) {
let Some((col, text, color, bold, italic)) = open else {
return;
};
let cells = text.chars().count() as u32;
self.runs.push(TextRun {
row,
col,
cells,
text,
color,
bold,
italic,
});
}
}
/// The terminal cell viewport a drawable rectangle admits.
///
/// Rows and columns are `floor(extent / metric)`, clamped through the
/// shared protocol limits so an enormous window cannot declare a grid
/// the daemon would reject. A rectangle too small for one whole cell
/// yields `None`: a zero-area declaration is not sent at all, and the
/// next geometry change that produces a valid size sends one.
#[must_use]
pub fn cell_viewport(
width_px: f32,
height_px: f32,
advance_px: f32,
line_px: f32,
) -> Option<CellSize> {
if !(advance_px.is_finite() && line_px.is_finite()) || advance_px <= 0.0 || line_px <= 0.0 {
return None;
}
if !(width_px.is_finite() && height_px.is_finite()) || width_px <= 0.0 || height_px <= 0.0 {
return None;
}
let cols = (width_px / advance_px).floor();
let rows = (height_px / line_px).floor();
if cols < 1.0 || rows < 1.0 {
return None;
}
let cols = (cols as u32).min(u32::from(pmacs_protocol::MAX_TERMINAL_COLS));
let rows = (rows as u32).min(u32::from(pmacs_protocol::MAX_TERMINAL_ROWS));
// The area bound can still bite at the extremes (512x512 is exactly
// the cap, but a future limit change need not keep that true), so
// shed rows rather than emit a size the daemon must reject.
let max_rows = (pmacs_protocol::MAX_TERMINAL_VISIBLE_CELLS / cols as usize) as u32;
let rows = rows.min(max_rows);
if rows == 0 {
return None;
}
Some(CellSize::new(rows, cols))
}
/// Hit-test a pixel inside the terminal rectangle to a cell.
///
/// Returns `None` outside the declared grid, so the status band, the
/// padding past the last full column, and any point above the terminal
/// origin are never terminal hits.
#[must_use]
pub fn hit_test_cell(
x_px: f32,
y_px: f32,
origin: (f32, f32),
advance_px: f32,
line_px: f32,
size: CellSize,
) -> Option<pmacs_protocol::CellCoord> {
if advance_px <= 0.0 || line_px <= 0.0 {
return None;
}
let dx = x_px - origin.0;
let dy = y_px - origin.1;
if dx < 0.0 || dy < 0.0 {
return None;
}
let col = (dx / advance_px).floor();
let row = (dy / line_px).floor();
if col < 0.0 || row < 0.0 {
return None;
}
let col = col as u32;
let row = row as u32;
if row >= size.rows || col >= size.cols {
return None;
}
Some(pmacs_protocol::CellCoord::new(row, col))
}
#[cfg(test)]
mod tests {
use super::*;
use pmacs_protocol::{BufferId, CellCoord, TerminalProcessState, TerminalSelectionSpan};
const PALETTE: TerminalPalette = TerminalPalette {
default_fg: [230, 230, 235],
default_bg: [13, 13, 18],
};
fn styled(glyph: Glyph, style: Style) -> Cell {
Cell {
glyph,
style,
attachment: None,
}
}
fn plain(ch: char) -> Cell {
styled(Glyph::Char(ch), Style::default())
}
fn frame(rows: u32, cols: u32, cells: Vec<Cell>) -> TerminalFrame {
TerminalFrame {
buffer_id: BufferId::from_raw(1),
size: CellSize::new(rows, cols),
cells,
cursor: None,
title: None,
screen_generation: 1,
selection: Vec::new(),
scroll_offset: 0,
at_bottom: true,
pid: 1,
process: TerminalProcessState::Running,
}
}
#[test]
fn ascii_coalesces_by_attributes_and_keeps_explicit_origins() {
let red = Style {
fg: Color::Indexed(1),
..Style::default()
};
let cells = vec![
plain('a'),
plain('b'),
styled(Glyph::Char('c'), red),
plain('d'),
];
let plan = TerminalPaintPlan::build(&frame(1, 4, cells), PALETTE);
assert_eq!(plan.runs.len(), 3);
assert_eq!(plan.runs[0].text, "ab");
assert_eq!(plan.runs[0].col, 0);
assert_eq!(plan.runs[0].cells, 2);
assert_eq!(plan.runs[1].text, "c");
assert_eq!(plan.runs[1].col, 2);
assert_eq!(plan.runs[1].color, indexed_rgb(1));
// The run after the attribute change is positioned by its own
// column, not by where the previous run's glyphs happened to end.
assert_eq!(plan.runs[2].col, 3);
}
#[test]
fn wide_lead_owns_two_cells_and_its_continuation_draws_nothing() {
let cyan = Style {
bg: Color::Rgb(0, 40, 40),
..Style::default()
};
let cells = vec![
styled(Glyph::Char('\u{4e00}'), cyan),
styled(Glyph::Continuation, Style::default()),
plain('x'),
plain('y'),
];
let plan = TerminalPaintPlan::build(&frame(1, 4, cells), PALETTE);
let wide = &plan.runs[0];
assert_eq!(wide.text, "\u{4e00}");
assert_eq!(wide.col, 0);
assert_eq!(wide.cells, 2, "a wide lead declares a two-cell footprint");
assert_eq!(
plan.runs[1].col, 2,
"the run after a wide glyph starts at its own column"
);
assert!(
plan.runs.iter().all(|run| run.col != 1),
"a continuation contributes no text run"
);
// The continuation carries a DEFAULT style on the wire but must
// paint with its lead's background.
let covering = plan
.backgrounds
.iter()
.find(|bg| bg.run.start_col == 0)
.expect("lead background");
assert_eq!(covering.color, [0, 40, 40]);
assert_eq!(
covering.run.end_col, 2,
"the lead's background covers both of its columns"
);
}
#[test]
fn reverse_swaps_resolved_defaults_and_drives_the_underline_color() {
let style = Style {
reverse: true,
underline: UnderlineStyle::Single,
..Style::default()
};
let plan =
TerminalPaintPlan::build(&frame(1, 1, vec![styled(Glyph::Char('z'), style)]), PALETTE);
assert_eq!(plan.backgrounds[0].color, PALETTE.default_fg);
assert_eq!(plan.runs[0].color, PALETTE.default_bg);
assert_eq!(
plan.underlines[0].color, PALETTE.default_bg,
"a default underline color follows the post-reverse foreground"
);
}
#[test]
fn explicit_underline_color_and_form_coalesce_then_break() {
let curly = Style {
underline: UnderlineStyle::Curly,
underline_color: Color::Rgb(200, 0, 0),
..Style::default()
};
let mut dotted = curly;
dotted.underline = UnderlineStyle::Dotted;
let cells = vec![
styled(Glyph::Char('a'), curly),
styled(Glyph::Char('b'), curly),
styled(Glyph::Char('c'), dotted),
plain('d'),
];
let plan = TerminalPaintPlan::build(&frame(1, 4, cells), PALETTE);
assert_eq!(plan.underlines.len(), 2);
assert_eq!(plan.underlines[0].run.start_col, 0);
assert_eq!(plan.underlines[0].run.end_col, 2);
assert_eq!(plan.underlines[0].style, UnderlineStyle::Curly);
assert_eq!(plan.underlines[0].color, [200, 0, 0]);
assert_eq!(plan.underlines[1].run.start_col, 2);
assert_eq!(plan.underlines[1].run.end_col, 3);
assert_eq!(plan.underlines[1].style, UnderlineStyle::Dotted);
}
#[test]
fn bold_italic_and_truecolor_reach_the_run() {
let style = Style {
bold: true,
italic: true,
fg: Color::Rgb(1, 2, 3),
..Style::default()
};
let plan =
TerminalPaintPlan::build(&frame(1, 1, vec![styled(Glyph::Char('q'), style)]), PALETTE);
assert!(plan.runs[0].bold);
assert!(plan.runs[0].italic);
assert_eq!(plan.runs[0].color, [1, 2, 3]);
}
#[test]
fn selection_and_cursor_come_only_from_the_frame() {
let mut f = frame(2, 4, vec![plain('.'); 8]);
f.selection = vec![TerminalSelectionSpan {
row: 1,
start_col: 1,
end_col: 3,
}];
f.cursor = Some(CellCoord::new(0, 2));
let plan = TerminalPaintPlan::build(&f, PALETTE);
assert_eq!(
plan.selection,
vec![CellRun {
row: 1,
start_col: 1,
end_col: 3
}]
);
assert_eq!(
plan.cursor,
Some(CellRun {
row: 0,
start_col: 2,
end_col: 3
})
);
// A frame with no cursor paints none: visibility is the child's
// decision, never the frontend's.
f.cursor = None;
assert!(TerminalPaintPlan::build(&f, PALETTE).cursor.is_none());
}
#[test]
fn cluster_cells_get_their_own_positioned_run() {
let cells = vec![
plain('a'),
styled(
Glyph::Cluster("e\u{301}".as_bytes().to_vec().into_boxed_slice()),
Style::default(),
),
plain('b'),
];
let plan = TerminalPaintPlan::build(&frame(1, 3, cells), PALETTE);
assert_eq!(plan.runs.len(), 3);
assert_eq!(plan.runs[1].text, "e\u{301}");
assert_eq!(plan.runs[1].col, 1);
assert_eq!(plan.runs[1].cells, 1);
assert_eq!(plan.runs[2].col, 2);
}
#[test]
fn rows_never_share_runs_or_background_spans() {
let plan = TerminalPaintPlan::build(&frame(2, 2, vec![plain('x'); 4]), PALETTE);
assert_eq!(
plan.runs.len(),
2,
"one run per row, never wrapped together"
);
assert_eq!(plan.runs[0].row, 0);
assert_eq!(plan.runs[1].row, 1);
assert!(plan.backgrounds.iter().all(|bg| bg.run.end_col <= 2));
}
#[test]
fn cell_viewport_floors_clamps_and_refuses_a_degenerate_rectangle() {
assert_eq!(
cell_viewport(100.0, 50.0, 10.0, 20.0),
Some(CellSize::new(2, 10))
);
// Partial cells are dropped, never rounded up into a column the
// child would write past.
assert_eq!(
cell_viewport(109.0, 59.0, 10.0, 20.0),
Some(CellSize::new(2, 10))
);
assert_eq!(cell_viewport(9.0, 20.0, 10.0, 20.0), None);
assert_eq!(cell_viewport(100.0, 19.0, 10.0, 20.0), None);
assert_eq!(cell_viewport(100.0, 50.0, 0.0, 20.0), None);
assert_eq!(cell_viewport(f32::NAN, 50.0, 10.0, 20.0), None);
let huge = cell_viewport(1_000_000.0, 1_000_000.0, 1.0, 1.0).expect("clamped");
assert_eq!(huge.rows, u32::from(pmacs_protocol::MAX_TERMINAL_ROWS));
assert_eq!(huge.cols, u32::from(pmacs_protocol::MAX_TERMINAL_COLS));
}
#[test]
fn hit_test_yields_only_in_bounds_cells() {
let size = CellSize::new(3, 4);
assert_eq!(
hit_test_cell(16.0, 16.0, (16.0, 16.0), 10.0, 20.0, size),
Some(CellCoord::new(0, 0))
);
assert_eq!(
hit_test_cell(16.0 + 25.0, 16.0 + 41.0, (16.0, 16.0), 10.0, 20.0, size),
Some(CellCoord::new(2, 2))
);
// Above / left of the origin, and past the last declared cell —
// the status band and the trailing padding are not terminal hits.
assert_eq!(
hit_test_cell(15.0, 16.0, (16.0, 16.0), 10.0, 20.0, size),
None
);
assert_eq!(
hit_test_cell(16.0, 15.0, (16.0, 16.0), 10.0, 20.0, size),
None
);
assert_eq!(
hit_test_cell(16.0 + 40.0, 16.0, (16.0, 16.0), 10.0, 20.0, size),
None
);
assert_eq!(
hit_test_cell(16.0, 16.0 + 60.0, (16.0, 16.0), 10.0, 20.0, size),
None
);
}
}

View File

@ -46,3 +46,9 @@ crdt = []
serde = { workspace = true }
postcard = { workspace = true }
thiserror = { workspace = true }
# Vterm Stage 3: `TerminalFrame::validate` is the ONE structural policy
# for terminal cells, so the column-width and wide-continuation rules
# live here rather than being re-implemented per frontend. That needs a
# direct width table; pinned through the workspace so this crate and
# `pmacs`'s terminal screen measure glyphs identically.
unicode-width = { workspace = true }

View File

@ -40,6 +40,7 @@ pub mod cell;
pub mod crdt;
pub mod ids;
pub mod message;
pub mod terminal;
pub mod transport;
/// Logical display columns between fixed buffer-text tab stops.
@ -65,4 +66,9 @@ pub use message::{
StatuslineSegment, StyleSegment, StyleSpan, ThemeFace, is_builtin_pair_char,
is_modeline_face_name, is_supported_protocol_version, is_ui_face_name, negotiate_capabilities,
};
pub use terminal::{
MAX_TERMINAL_COLS, MAX_TERMINAL_FRAME_GLYPH_BYTES, MAX_TERMINAL_GRAPHEME_BYTES,
MAX_TERMINAL_METADATA_BYTES, MAX_TERMINAL_ROWS, MAX_TERMINAL_VISIBLE_CELLS, TerminalFrame,
TerminalFrameError, TerminalProcessState, TerminalSelectionSpan,
};
pub use transport::{MAX_FRAME_BYTES, TransportError, read_message, write_message};

View File

@ -393,6 +393,55 @@ pub enum FrontendEvent {
/// moves the highlight.
invoke: bool,
},
/// Vterm Stage 3 (protocol v19): the terminal-cell geometry a
/// semantic frontend has on screen for `buffer_id`.
///
/// This is the terminal twin of [`Self::Viewport`] and keeps the
/// same no-pixels contract: it carries a CELL size, never pixel
/// extent, DPI, or glyph advances. The frontend divides its own
/// drawable rectangle by its own metrics.
///
/// A v19 frontend sends both this and `Viewport` after every
/// `BufferSnapshot`; the daemon accepts only the declaration
/// matching the authenticated active buffer's kind and drops the
/// other. That dual declaration is what removes the otherwise
/// circular dependency where a frontend would need a terminal frame
/// before it knew to ask for one.
///
/// Recording the size is not claiming control: a passive view's
/// declaration produces its own clipped/padded projection, while
/// only the durable controller changes the shared PTY geometry.
/// Sent only to a `>= 19` daemon.
TerminalResize {
/// Which frontend declared the geometry (untrusted; the daemon
/// routes by the authenticated session, matching the
/// `CrdtOp` / `Viewport` / `Pointer` source-trust rule).
frontend_id: FrontendId,
/// Terminal identity buffer the frontend was displaying.
buffer_id: crate::BufferId,
/// Content rectangle size in terminal cells.
size: CellSize,
},
/// Vterm Stage 3 (protocol v19): a pointer gesture a semantic
/// frontend hit-tested to a terminal CELL.
///
/// Terminal windows have no source bytes to hit-test against, so
/// this replaces [`Self::Pointer`] inside the terminal clip. Once
/// accepted it follows the landed Stage 2 pointer path: child SGR
/// mouse reporting when eligible, otherwise per-view scroll,
/// selection, or context menu. Sent only to a `>= 19` daemon.
TerminalPointer {
/// Which frontend produced the gesture (untrusted, as above).
frontend_id: FrontendId,
/// Terminal identity buffer the frontend was displaying.
buffer_id: crate::BufferId,
/// Cell the pointer is over, within the last declared viewport.
coord: CellCoord,
/// Which gesture step this is.
kind: MouseKind,
/// Modifiers held during the gesture.
mods: Modifiers,
},
}
/// Gesture step for [`FrontendEvent::Pointer`]. Double-click
@ -437,7 +486,9 @@ impl FrontendEvent {
| Self::CrdtOp { frontend_id, .. }
| Self::Viewport { frontend_id, .. }
| Self::Pointer { frontend_id, .. }
| Self::MenuPointer { frontend_id, .. } => *frontend_id,
| Self::MenuPointer { frontend_id, .. }
| Self::TerminalResize { frontend_id, .. }
| Self::TerminalPointer { frontend_id, .. } => *frontend_id,
}
}
}
@ -1067,6 +1118,25 @@ pub enum InstanceMessage {
/// Right-side custom segments in display order.
right: Vec<StatuslineSegment>,
},
/// Vterm Stage 3 (protocol v19). The complete visible terminal grid
/// for the receiving frontend's active terminal window.
///
/// A terminal identity buffer's `BufferSnapshot` is an empty CRDT
/// anchor, so a semantic frontend has no text to lay out; this
/// carries the cells instead. It is a whole-grid replacement, never
/// a delta, and it is validated by
/// [`crate::terminal::TerminalFrame::validate`] both before the
/// daemon emits it and after the frontend decodes it.
///
/// Suppression compares the COMPLETE ordered payload, not
/// `screen_generation`: selection, scroll, viewport, and process
/// state all change without advancing that counter, and a
/// generation-keyed producer would go silent on exactly those
/// view-only updates. Daemon-gated `>= 19`.
///
/// Appended after [`Self::StatuslineSegments`], the final v18
/// variant, so no existing postcard discriminant moves.
TerminalFrame(crate::terminal::TerminalFrame),
}
/// One resolved UI face for [`InstanceMessage::ThemeFacts`]: a full
@ -1471,7 +1541,19 @@ pub enum ResourceBody {
/// carrying custom modeline provider output. Daemon-gated `< 18`; a
/// v17 peer keeps the built-in status band. Appended after `FontFacts`
/// so the final v17 discriminant remains stable.
pub const PROTOCOL_VERSION: u32 = 18;
///
/// Vterm Stage 3: bumped 18 → 19 for the terminal family —
/// [`InstanceMessage::TerminalFrame`] (daemon-gated `< 19`) plus
/// [`FrontendEvent::TerminalResize`] and
/// [`FrontendEvent::TerminalPointer`] (frontend-gated, sent only to a
/// `>= 19` instance). All three are appended after their enum's final
/// v18 variant, so the ladder resumes on the v6 encoding floor: a v18
/// grid peer keeps receiving Stage 2's composed `CellDelta` terminal
/// windows, and a v18 semantic peer keeps ordinary document editing
/// with no terminal surface at all. This is the first bump to gate in
/// BOTH directions at once, which is why criterion 28 pins the two
/// send filters independently.
pub const PROTOCOL_VERSION: u32 = 19;
/// T M10.5: the set of protocol versions a v1.0 binary accepts on
/// the wire. v0.1 binaries only accepted `[1]`; v1.0 binaries accept
@ -1540,7 +1622,13 @@ pub const PROTOCOL_VERSION: u32 = 18;
///
/// Q#SL7: extended to `[6, ..., 18]`.
/// [`InstanceMessage::StatuslineSegments`] is additive and daemon-gated.
pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] = &[6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18];
///
/// Vterm Stage 3: extended to `[6, ..., 19]`. The terminal family is
/// additive in both directions — `TerminalFrame` is daemon-gated,
/// `TerminalResize` / `TerminalPointer` are frontend-gated — so v18 and
/// v19 binaries interoperate with terminal traffic simply absent.
pub const SUPPORTED_PROTOCOL_VERSIONS: &[u32] =
&[6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19];
/// T M10.5: predicate for the handshake check. Returns `true` if
/// `peer_version` is in [`SUPPORTED_PROTOCOL_VERSIONS`].

File diff suppressed because it is too large Load Diff

View File

@ -799,6 +799,34 @@ fn peer_accepts_statusline_message(protocol_version: u32, message: &InstanceMess
protocol_version >= 18 || !matches!(message, InstanceMessage::StatuslineSegments { .. })
}
/// Whether a session negotiated the v19 wire, and may therefore drive
/// terminal state inbound.
///
/// The outbound `TerminalFrame` is gated twice — in the producer and in
/// the write loop — and the inbound direction is now symmetric. A
/// pre-v19 peer cannot construct these variants at all (its enum lacks
/// them), so this only ever refuses a hand-rolled client; the a32
/// forgery tests already prove such an event can reach nothing but the
/// sender's own authenticated active view. It is defense in depth, and
/// it makes "gated in both directions" true of the code rather than
/// only of the frontends we ship.
fn peer_declared_terminal_support(
session_registry: &SessionRegistry,
frontend_id: FrontendId,
) -> bool {
session_registry
.session_state(frontend_id)
.is_some_and(|state| state.negotiated_protocol_version >= 19)
}
/// The same belt-and-braces write-loop gate for the additive
/// protocol-v19 terminal frame. The semantic producer skips construction
/// for an older peer; this filter independently prevents an unknown
/// discriminant reaching one, so neither gate alone is load-bearing.
fn peer_accepts_terminal_message(protocol_version: u32, message: &InstanceMessage) -> bool {
protocol_version >= 19 || !matches!(message, InstanceMessage::TerminalFrame(_))
}
/// T M10.8 — dispatcher loop. The single thread that owns the editor.
///
/// All attached frontends' inputs arrive via the `dispatcher_rx`
@ -1078,9 +1106,42 @@ fn dispatcher_loop(
render_state.render_frame(editor, *fid, &terminal_snapshots, &other_presences)
};
// Vterm Stage 3 — a semantic frontend showing a terminal has
// no document cursor: the identity buffer is empty, so a
// `CursorByte` would describe byte 0 of a buffer with no
// text and scroll the frontend's document view against it.
let terminal_mode = semantic_states
.get(fid)
.is_some_and(crate::semantic_render::SemanticRenderState::in_terminal_mode);
// T M10.6 per-frontend presence sweep. The snapshot is
// computed from this frontend's view; the sweep then
// produces broadcasts to OTHER multi-frontend recipients.
//
// Terminal mode does NOT skip this (PR #135 review finding 1).
// `sweep` is diff-keyed on `last_broadcast`, so a skip can
// only ever FREEZE a frontend's presence — it can never
// retract it. Sweeping truthfully moves the presence into
// the terminal identity buffer, which is what takes this
// frontend's caret off every peer showing the document.
//
// Honest note on why the skip was not a live bug: the
// buffer-follow above clears the terminal declaration when
// it ships the snapshot, so `render_frame` reports
// `terminal_active == false` on the tick a window first
// shows a terminal, and the declaration cannot arrive until
// a later tick (the frontend learns the buffer id FROM that
// snapshot). Every entry into terminal mode is therefore
// already preceded by one truthful sweep. The skip was
// load-bearing on that ordering and bought nothing; removing
// it makes "presence follows the frontend" structural
// instead of a property of tick sequencing.
//
// The framing's "suppress presence for the terminal identity
// buffer" is a RENDER rule — no peer overlay is painted
// inside a terminal — which the GPU honors by not preparing
// the decoration batch in terminal mode. It was never a
// reason to stop telling peers where this frontend went.
let snapshot = build_presence_snapshot(editor, *fid);
let broadcasts = session_registry.sweep(&[(*fid, snapshot)]);
@ -1217,6 +1278,14 @@ fn dispatcher_loop(
if !peer_knows_font_facts && matches!(msg, InstanceMessage::FontFacts { .. }) {
continue;
}
// Vterm Stage 3 — TerminalFrame gated at v19. A v18
// semantic peer keeps the empty identity snapshot and
// no terminal surface; a v18 grid peer is unaffected
// because it composes terminal windows into its own
// CellDelta.
if !peer_accepts_terminal_message(negotiated_protocol_version, msg) {
continue;
}
if !peer_accepts_statusline_message(negotiated_protocol_version, msg) {
continue;
}
@ -1266,6 +1335,7 @@ fn dispatcher_loop(
// optimistic-apply path consumes byte_pos; the legacy
// paint path consumes the grid coord.
if !write_failed
&& !terminal_mode
&& session_registry
.session_state(*fid)
.is_some_and(|s| s.negotiated_capabilities.crdt_replica)
@ -1380,6 +1450,18 @@ fn dispatcher_loop(
if let Some(size) = term_sizes.get(frontend_id).copied() {
editor.sync_terminal_layout(*frontend_id, size);
}
// Vterm Stage 3 — the semantic twin, right beside the grid
// sync so both frontend kinds resize the screen before the
// next child-output drain. The frontend declared a CONTENT
// rectangle, so this consumes the size directly instead of
// running the TUI placement helper, which would subtract a
// modeline the GPU never drew.
if let Some((buffer_id, size)) = semantic_states
.get(frontend_id)
.and_then(crate::semantic_render::SemanticRenderState::terminal_viewport)
{
editor.sync_semantic_terminal_layout(*frontend_id, buffer_id, size);
}
}
// `tick_async` last: the M4.5 async bridge settles awaiters
@ -1621,7 +1703,30 @@ fn handle_dispatcher_event(
// session never sends this; if one does, there is
// no `SemanticRenderState` to update and it is a
// benign no-op.
if semantic_states.contains_key(&source) {
// Vterm Stage 3 — a v19 frontend declares BOTH a
// byte viewport and a terminal cell size after every
// snapshot, because an empty terminal identity
// snapshot does not announce itself as a terminal.
// The daemon keeps only the declaration appropriate
// to the authenticated source's ACTIVE buffer.
//
// Keying on the active buffer rather than the
// declared one is load-bearing: `Viewport` also
// ALIGNS the window to the buffer it names, so a
// stale document viewport still in flight when a
// command opens a terminal would drag the frontend
// straight back off it. The declared buffer is
// checked too — a terminal has no byte viewport to
// honor from any direction.
let terminal_context = {
let manager = editor.terminal_manager.borrow();
let core = editor.core.borrow();
let active = core
.active_window_for(source)
.is_some_and(|window| manager.is_terminal(window.buffer_id));
active || manager.is_terminal(buffer_id)
};
if semantic_states.contains_key(&source) && !terminal_context {
// Phase B (B1) — the Viewport declares *which
// buffer this frontend is displaying*. Align its
// editor window to that buffer so keyboard input
@ -1637,6 +1742,50 @@ fn handle_dispatcher_event(
}
}
}
FrontendEvent::TerminalResize {
buffer_id, size, ..
} => {
// Vterm Stage 3 — the terminal half of the dual
// declaration. Routed by the authenticated `source`;
// the payload's `frontend_id` is never read.
//
// Recording the geometry is what lets a PASSIVE view
// receive its own clipped/padded projection, so the
// record happens for any accepted declaration. Only
// the durable controller's declaration reaches the
// shared PTY — a declaration never claims control.
if semantic_states.contains_key(&source)
&& peer_declared_terminal_support(session_registry, source)
&& editor.semantic_terminal_declaration_is_active(source, buffer_id)
{
if let Some(sem) = semantic_states.get_mut(&source) {
sem.set_terminal_viewport(buffer_id, size);
}
editor.sync_semantic_terminal_layout(source, buffer_id, size);
}
}
FrontendEvent::TerminalPointer {
buffer_id,
coord,
kind,
mods,
..
} => {
// Vterm Stage 3 — a terminal-cell gesture. The
// adapter re-derives the window from the
// authenticated source and checks the coordinate
// against the geometry that source declared, so a
// forged id, a stale buffer, a missing declaration,
// or an out-of-bounds cell all drop before any view,
// controller, selection, menu, or PTY mutation.
if semantic_states.contains_key(&source)
&& peer_declared_terminal_support(session_registry, source)
{
editor.dispatch_semantic_terminal_pointer(
source, buffer_id, coord, kind, mods,
);
}
}
FrontendEvent::Pointer {
buffer_id,
byte,
@ -2653,6 +2802,18 @@ fn apply_event(
"pmacs daemon: FrontendEvent::MenuPointer from a grid session; dropping"
);
}
FrontendEvent::TerminalResize { .. } | FrontendEvent::TerminalPointer { .. } => {
// Vterm Stage 3 — the terminal declarations belong to
// semantic sessions and are routed by the authenticated
// source in `handle_dispatcher_event`. A grid session
// resizes its terminal through the Stage 2 layout path, so
// one arriving here is a protocol violation; drop it
// rather than letting a payload-trusted id reach a view.
eprintln!(
"pmacs daemon: terminal declaration from a grid session; dropping \
(grid terminals resize through the Stage 2 layout path)"
);
}
}
}
@ -2687,6 +2848,67 @@ mod tests {
));
}
#[test]
fn terminal_frame_write_gate_rejects_v18_independently() {
let frame = InstanceMessage::TerminalFrame(crate::terminal::TerminalFrame {
buffer_id: crate::buffer::BufferId::from_raw(2),
size: crate::cell::CellSize::new(1, 1),
cells: vec![crate::cell::Cell::default()],
cursor: None,
title: None,
screen_generation: 0,
selection: Vec::new(),
scroll_offset: 0,
at_bottom: true,
pid: 1,
process: crate::terminal::TerminalProcessState::Running,
});
assert!(!peer_accepts_terminal_message(18, &frame));
assert!(peer_accepts_terminal_message(19, &frame));
// The gate is variant-scoped: it must not silence anything else
// on an older wire.
assert!(peer_accepts_terminal_message(
18,
&InstanceMessage::StatuslineSegments {
buffer_id: crate::buffer::BufferId::from_raw(2),
left: Vec::new(),
right: Vec::new(),
}
));
}
#[test]
fn inbound_terminal_events_require_a_negotiated_v19_session() {
// Review round 2, finding 5: the outbound `TerminalFrame` was
// gated twice (producer + write loop) while the inbound
// declarations relied on the frontend's send gate alone. A
// pre-v19 peer cannot construct these variants, so this only
// refuses a hand-rolled client — but it makes "gated in both
// directions" true of the code, not just of the frontends we
// ship.
let mut registry = SessionRegistry::new();
let semantic = crate::protocol::NegotiatedCapabilities {
multi_frontend: true,
crdt_replica: true,
semantic_render: true,
};
let old_peer = FrontendId(2);
let new_peer = FrontendId(3);
registry.register_session(
old_peer,
crate::presence::SessionState::new(18, semantic, 0),
);
registry.register_session(
new_peer,
crate::presence::SessionState::new(19, semantic, 1),
);
assert!(!peer_declared_terminal_support(&registry, old_peer));
assert!(peer_declared_terminal_support(&registry, new_peer));
// An unknown session is refused rather than defaulted open.
assert!(!peer_declared_terminal_support(&registry, FrontendId(99)));
}
#[test]
fn build_identity_includes_version_and_uptime() {
let s = DaemonState::new(Some("research".into()));

View File

@ -1122,6 +1122,155 @@ impl EditorState {
}
}
/// Resolve the exact terminal view a semantic frontend is showing.
///
/// The window identity is DERIVED from the authenticated frontend,
/// never accepted from the wire: a semantic peer names only a
/// buffer, so a forged or stale `buffer_id` fails this check and
/// reaches no view, controller, or PTY. A window that has since
/// switched away also fails, which is what makes a pointer racing a
/// buffer switch a no-op instead of a gesture on the wrong buffer.
fn semantic_terminal_key(
&self,
frontend_id: FrontendId,
buffer_id: crate::buffer::BufferId,
) -> Option<TerminalViewKey> {
let core = self.core.borrow();
let view = core.views.get(&frontend_id)?;
let window = core.windows.get(&view.active)?;
if window.buffer_id != buffer_id {
return None;
}
let key = TerminalViewKey::new(frontend_id, window.id, buffer_id);
self.terminal_manager
.borrow()
.is_terminal(buffer_id)
.then_some(key)
}
/// Whether `buffer_id` is the terminal an authenticated semantic
/// frontend is currently displaying.
///
/// The daemon calls this before recording a terminal declaration so
/// a stale or forged buffer never becomes a frontend's projection
/// target — a declaration is only meaningful for the window the
/// sender actually has on screen.
#[must_use]
pub fn semantic_terminal_declaration_is_active(
&self,
frontend_id: FrontendId,
buffer_id: crate::buffer::BufferId,
) -> bool {
self.semantic_terminal_key(frontend_id, buffer_id).is_some()
}
/// Project one semantic frontend's active terminal view.
///
/// Called from the render pass, after `sync_semantic_terminal_layout`
/// has already applied any geometry change, so the snapshot comes
/// from an already-published screen rather than one mid-resize.
pub fn prepare_semantic_terminal_view(
&self,
frontend_id: FrontendId,
buffer_id: crate::buffer::BufferId,
size: CellSize,
) -> Option<TerminalSnapshot> {
let key = self.semantic_terminal_key(frontend_id, buffer_id)?;
self.terminal_manager
.borrow_mut()
.snapshot_for_view(key, size)
}
/// Record a semantic frontend's declared terminal geometry.
///
/// Recording is unconditional for a valid declaration — that is what
/// gives a passive split its own clipped/padded projection — but the
/// PTY resizes only when this exact view is the durable controller.
/// Declaring geometry never CLAIMS control: a background frontend
/// repainting at a different size must not steal the shared screen
/// out from under the frontend the user is typing into.
///
/// Returns whether the shared screen geometry actually changed.
pub fn sync_semantic_terminal_layout(
&mut self,
frontend_id: FrontendId,
buffer_id: crate::buffer::BufferId,
size: CellSize,
) -> bool {
let Some(key) = self.semantic_terminal_key(frontend_id, buffer_id) else {
return false;
};
if !self
.terminal_manager
.borrow_mut()
.record_view_size(key, size)
{
return false;
}
let controls = self
.terminal_manager
.borrow()
.controller(buffer_id)
.is_some_and(|controller| controller.matches(key));
if !controls {
return false;
}
// Read the size from the borrowed projection rather than a
// snapshot: this runs every dispatcher tick, and
// `snapshot(..).size` would clone the whole visible grid to
// answer one comparison.
let old_size = self.terminal_manager.borrow().screen_size(buffer_id);
if old_size == Some(size) {
return false;
}
let (Ok(rows), Ok(cols)) = (u16::try_from(size.rows), u16::try_from(size.cols)) else {
return false;
};
let result = self.terminal_manager.borrow_mut().resize(
buffer_id,
rows,
cols,
&mut self.process_supervisor.borrow_mut(),
);
if let Err(error) = result {
self.core.borrow_mut().status = error.to_string();
false
} else {
true
}
}
/// Apply a semantic frontend's terminal-cell pointer gesture.
///
/// The gesture must name the authenticated frontend's active
/// terminal buffer, match the viewport that frontend last declared,
/// and land inside it. Anything else is dropped before any view,
/// controller, selection, menu, or PTY mutation — a coordinate is
/// only meaningful relative to the geometry the sender declared, so
/// accepting one against a stale or undeclared viewport would let a
/// peer select cells it never saw.
pub fn dispatch_semantic_terminal_pointer(
&mut self,
frontend_id: FrontendId,
buffer_id: crate::buffer::BufferId,
coord: CellCoord,
kind: TerminalMouseKind,
mods: TerminalModifiers,
) -> bool {
let Some(key) = self.semantic_terminal_key(frontend_id, buffer_id) else {
return false;
};
let Some(size) = self.terminal_manager.borrow().declared_view_size(key) else {
return false;
};
if coord.row >= size.rows || coord.col >= size.cols {
return false;
}
self.core.borrow_mut().active_frontend = frontend_id;
self.apply_terminal_gesture(key, size, coord, kind, mods, (coord.row, coord.col));
true
}
/// Precompute owned terminal view snapshots before entering paint borrows.
pub fn prepare_terminal_views(
&mut self,
@ -1763,10 +1912,34 @@ impl EditorState {
event: MouseEvent,
global: (u32, u32),
) {
use crossterm::event::{MouseButton, MouseEventKind};
self.apply_terminal_gesture(
key,
viewport_size,
coord,
terminal_mouse_kind(event.kind),
terminal_modifiers(event.modifiers),
global,
);
}
let kind = terminal_mouse_kind(event.kind);
let modifiers = terminal_modifiers(event.modifiers);
/// The one terminal pointer path, shared by both frontend kinds.
///
/// The TUI reaches it through crossterm translation and the semantic
/// frontend through `FrontendEvent::TerminalPointer`; both arrive as
/// the protocol-native kind/modifier pair, so child mouse reporting,
/// scroll, selection, and the context menu stay single-sourced.
/// A second copy of this precedence in the GPU lane is exactly how
/// Shift-drag or scrolled-back selection would silently diverge
/// between frontends.
fn apply_terminal_gesture(
&mut self,
key: TerminalViewKey,
viewport_size: CellSize,
coord: CellCoord,
kind: TerminalMouseKind,
modifiers: TerminalModifiers,
global: (u32, u32),
) {
let shift = modifiers.contains(TerminalModifiers::SHIFT);
let (at_bottom, modes, screen_size) = {
let mut manager = self.terminal_manager.borrow_mut();
@ -1778,6 +1951,18 @@ impl EditorState {
(status.at_bottom, modes, screen_size)
};
// Hover is not an act of taking over a terminal (PR #135 review
// finding 2). Every other gesture is deliberate — a press, a
// release, a drag, a wheel tick, a right-click — but bare motion
// happens whenever a pointer crosses a window. A semantic
// frontend reports motion at pixel rate, so claiming on `Move`
// let merely sweeping the mouse across a PASSIVE split's
// terminal take durable control, and the next layout sync then
// resized the shared PTY to that background view's geometry.
// That is precisely the theft the controller rule exists to
// prevent.
let claims_control = !matches!(kind, TerminalMouseKind::Move);
if !shift
&& at_bottom
&& modes.mouse_sgr
@ -1785,30 +1970,34 @@ impl EditorState {
&& coord.col < screen_size.cols
&& let Some(bytes) = crate::terminal::input::encode_mouse(kind, coord, modifiers, modes)
{
self.claim_terminal_controller(key);
if claims_control {
self.claim_terminal_controller(key);
}
self.send_terminal_bytes(key.buffer_id, &bytes);
return;
}
self.claim_terminal_controller(key);
if claims_control {
self.claim_terminal_controller(key);
}
let mut manager = self.terminal_manager.borrow_mut();
match event.kind {
MouseEventKind::ScrollUp => {
match kind {
TerminalMouseKind::ScrollUp => {
let _ = manager.scroll_view(key, viewport_size, SCROLL_LINES);
}
MouseEventKind::ScrollDown => {
TerminalMouseKind::ScrollDown => {
let _ = manager.scroll_view(key, viewport_size, -SCROLL_LINES);
}
MouseEventKind::Down(MouseButton::Left) => {
TerminalMouseKind::Down(TerminalMouseButton::Left) => {
let _ = manager.begin_selection(key, viewport_size, coord);
}
MouseEventKind::Drag(MouseButton::Left) => {
TerminalMouseKind::Drag(TerminalMouseButton::Left) => {
let _ = manager.update_selection(key, viewport_size, coord);
}
MouseEventKind::Up(MouseButton::Left) => {
TerminalMouseKind::Up(TerminalMouseButton::Left) => {
let _ = manager.finish_selection(key, viewport_size, coord);
}
MouseEventKind::Down(MouseButton::Right) => {
TerminalMouseKind::Down(TerminalMouseButton::Right) => {
drop(manager);
self.core.borrow_mut().break_command_chain(key.frontend_id);
let rows = self.build_menu_rows();

View File

@ -429,6 +429,13 @@ impl Frontend {
// the grid TUI paints provider output directly from the
// registry and silently drops an unexpected wire copy.
| InstanceMessage::StatuslineSegments { .. }
// Vterm Stage 3 — TerminalFrame is the semantic frontend's
// terminal surface. A grid TUI composes terminal windows
// into its own CellDelta (Stage 2) and advertises no
// semantic render, so the daemon never sends it here; an
// unexpected copy drops silently like the rest of the
// family rather than being re-interpreted as cells.
| InstanceMessage::TerminalFrame(_)
| InstanceMessage::ResourceOffer { .. }
// T M11.6 — DispatchIdle is consumed by `attach.rs`'s
// optimistic-apply gate; if any reaches this render path

View File

@ -1683,7 +1683,7 @@ mod tests {
// --- M5.5a handshake & postcard round-trips ---
#[test]
fn protocol_version_is_eighteen_for_statusline_segments() {
fn protocol_version_is_nineteen_for_the_terminal_family() {
// Pin the value: T M10.5 bumped 1→2 (v1.0 wire: CrdtOp /
// PresenceUpdate). T M11.1 bumped 2→3 (v1.1 wire: the
// SemanticFrame family + FrontendEvent::Viewport). T M11.6
@ -1714,8 +1714,13 @@ mod tests {
// variant — see the ThemeFacts placement pin).
// Statusline segments Q#SL7 bumped 17→18 (`InstanceMessage::
// StatuslineSegments`, additive + daemon-gated, appended after
// FontFacts — see the v17 placement pin).
assert_eq!(PROTOCOL_VERSION, 18);
// FontFacts — see the v17 placement pin). Vterm Stage 3 bumped
// 18→19 (`InstanceMessage::TerminalFrame`, daemon-gated, plus
// `FrontendEvent::TerminalResize` / `TerminalPointer`,
// frontend-gated — the first bump that gates in BOTH
// directions; all three appended after their enum's final v18
// variant, see the placement pins).
assert_eq!(PROTOCOL_VERSION, 19);
}
#[test]
@ -1790,17 +1795,18 @@ mod tests {
// regex/invalid), v11 (the context menu), v12 (the GUI
// minibuffer), v13 (`LineNumbers`), v14 (`LineNumberMode`), v15
// (`CompletionPopup`), v16 (`ThemeFacts`), v17 (`FontFacts`),
// and v18 (`StatuslineSegments`) all interoperate.
for accepted in 6..=18 {
// v18 (`StatuslineSegments`), and v19 (the vterm terminal
// family) all interoperate.
for accepted in 6..=19 {
assert!(
is_supported_protocol_version(accepted),
"v{accepted} must be accepted"
);
}
for rejected in [0, 1, 2, 3, 4, 5, 19, u32::MAX] {
for rejected in [0, 1, 2, 3, 4, 5, 20, u32::MAX] {
assert!(
!is_supported_protocol_version(rejected),
"v{rejected} must be rejected by a v18 binary"
"v{rejected} must be rejected by a v19 binary"
);
}
}
@ -1891,6 +1897,124 @@ mod tests {
}
}
#[test]
fn statusline_segments_encoding_is_unchanged_by_the_v19_build() {
// Vterm Stage 3 placement pin: `TerminalFrame` must be APPENDED
// after `StatuslineSegments` — the final v18 variant, whose
// ordinal moves if anything is inserted before any v18 variant.
// These are the exact bytes a v18 binary produced for this
// value (discriminant 25 as a postcard varint, then the buffer
// id and two empty vectors); the new variant's own round-trip
// cannot detect a shift.
let msg = InstanceMessage::StatuslineSegments {
buffer_id: pmacs_protocol::BufferId::from_raw(4),
left: Vec::new(),
right: Vec::new(),
};
let bytes = postcard::to_allocvec(&msg).expect("encode");
assert_eq!(
bytes,
[25, 4, 0, 0],
"StatuslineSegments' v18 wire bytes changed — a variant was \
inserted before it; append new InstanceMessage variants \
at the end"
);
}
#[test]
fn menu_pointer_encoding_is_unchanged_by_the_v19_build() {
// The same placement pin for the frontend→instance enum:
// `TerminalResize` / `TerminalPointer` are appended after
// `MenuPointer`, the final v18 `FrontendEvent` variant. A
// frontend-gated variant inserted earlier would shift the
// discriminants of `Viewport` and `Pointer`, which older
// daemons decode on every session.
let ev = FrontendEvent::MenuPointer {
frontend_id: FrontendId(2),
index: Some(1),
invoke: true,
};
let bytes = postcard::to_allocvec(&ev).expect("encode");
assert_eq!(
bytes,
[10, 2, 1, 1, 1],
"MenuPointer's v18 wire bytes changed — a variant was \
inserted before it; append new FrontendEvent variants \
at the end"
);
}
#[test]
fn terminal_family_round_trips_and_pins_its_discriminants() {
let bid = pmacs_protocol::BufferId::from_raw(9);
let frame = pmacs_protocol::TerminalFrame {
buffer_id: bid,
size: CellSize::new(1, 2),
cells: vec![
Cell {
glyph: pmacs_protocol::Glyph::Char('h'),
style: Style::default(),
attachment: None,
},
Cell {
glyph: pmacs_protocol::Glyph::Char('i'),
style: Style::default(),
attachment: None,
},
],
cursor: Some(CellCoord::new(0, 1)),
title: None,
screen_generation: 2,
selection: Vec::new(),
scroll_offset: 0,
at_bottom: true,
pid: 77,
process: pmacs_protocol::TerminalProcessState::Running,
};
assert_eq!(frame.validate(), Ok(()));
let msg = InstanceMessage::TerminalFrame(frame);
let bytes = postcard::to_allocvec(&msg).expect("encode");
assert_eq!(
bytes.first(),
Some(&26),
"TerminalFrame must be the 27th InstanceMessage variant \
(appended after StatuslineSegments)"
);
let decoded: InstanceMessage = postcard::from_bytes(&bytes).expect("decode");
assert_eq!(decoded, msg);
for (ev, discriminant) in [
(
FrontendEvent::TerminalResize {
frontend_id: FrontendId(3),
buffer_id: bid,
size: CellSize::new(24, 80),
},
11u8,
),
(
FrontendEvent::TerminalPointer {
frontend_id: FrontendId(3),
buffer_id: bid,
coord: CellCoord::new(4, 5),
kind: MouseKind::Down(MouseButton::Left),
mods: Modifiers::SHIFT,
},
12,
),
] {
let bytes = postcard::to_allocvec(&ev).expect("encode");
assert_eq!(
bytes.first(),
Some(&discriminant),
"terminal FrontendEvent variants must be appended after MenuPointer"
);
let decoded: FrontendEvent = postcard::from_bytes(&bytes).expect("decode");
assert_eq!(decoded, ev);
}
}
#[test]
fn font_facts_encoding_is_unchanged_by_the_v18_build() {
let msg = InstanceMessage::FontFacts {

View File

@ -33,7 +33,7 @@
use std::collections::HashMap;
use crate::buffer::BufferId;
use crate::cell::Style;
use crate::cell::{CellSize, Style};
use crate::editor::EditorState;
use crate::protocol::{
AdornmentContent, AdornmentPlacement, ByteRange, Decoration, DecorationKind, DecorationSegment,
@ -44,6 +44,7 @@ use crate::statusline::{
StatuslineEvaluation, StatuslineEvaluationOutcome, StatuslineEvaluationTarget,
evaluate_statusline,
};
use crate::terminal::TerminalFrame;
/// The viewport a `semantic_render` frontend last declared.
#[derive(Clone, Debug, Eq, PartialEq)]
@ -131,6 +132,10 @@ fn minibuffer_window(candidates: &[String], selected: Option<usize>) -> (Vec<Str
/// Owns one `semantic_render` session's projection state: the last
/// viewport the frontend declared, and the diff baseline per buffer
/// for the `StyleSpans` and `Decorations` families.
#[allow(
clippy::struct_excessive_bools,
reason = "independent per-peer capability and latch flags"
)]
pub struct SemanticRenderState {
/// The session this projection serves. Selection is per-window
/// (per-frontend) state, so the decoration projection needs the
@ -276,6 +281,31 @@ pub struct SemanticRenderState {
/// when the store is non-stale and non-empty) — a steady-state
/// CPU burn for a value that changes only when the buffer does.
diag_line_cache: HashMap<BufferId, DiagLineCache>,
/// Whether the peer negotiated protocol >= 19 (Vterm Stage 3). A
/// v18 semantic peer receives the terminal identity buffer's empty
/// snapshot and nothing else: terminal use is unsupported and
/// invisible there, while ordinary document editing is unchanged.
peer_knows_terminal_frames: bool,
/// The terminal-cell geometry this frontend last declared, or
/// `None` before its first accepted `TerminalResize`. One value,
/// not a map: a frontend displays at most one terminal at a time
/// (its active window), and the buffer travels with the size so a
/// declaration outliving a switch cannot project the wrong session.
terminal_viewport: Option<(BufferId, CellSize)>,
/// The last terminal frame this peer received, compared in FULL.
///
/// Not keyed on `screen_generation`: selection, scroll, viewport,
/// and process state all change without advancing that counter, so
/// a generation-keyed baseline goes silent on exactly the view-only
/// updates the frontend needs. `None` means the peer has received
/// no frame, so the next valid one is authoritative.
last_terminal_frame: Option<TerminalFrame>,
/// Whether an invalid terminal snapshot was already reported since
/// the last valid frame. Bounds the log to one line per distinct
/// failure rather than one per tick while the condition persists.
terminal_error_latched: bool,
/// Whether the most recent render pass projected a terminal.
terminal_active: bool,
}
/// One [`SemanticRenderState::diag_line_cache`] entry: the line-start
@ -371,6 +401,7 @@ impl SemanticRenderState {
s.peer_knows_theme_facts = negotiated_protocol_version >= 16;
s.peer_knows_font_facts = negotiated_protocol_version >= 17;
s.peer_knows_statusline_segments = negotiated_protocol_version >= 18;
s.peer_knows_terminal_frames = negotiated_protocol_version >= 19;
s
}
@ -416,6 +447,11 @@ impl SemanticRenderState {
peer_knows_statusline_segments: true,
last_statusline: HashMap::new(),
diag_line_cache: HashMap::new(),
peer_knows_terminal_frames: true,
terminal_viewport: None,
last_terminal_frame: None,
terminal_error_latched: false,
terminal_active: false,
}
}
@ -431,6 +467,59 @@ impl SemanticRenderState {
});
}
/// Record the terminal-cell geometry this frontend declared
/// (`FrontendEvent::TerminalResize`, accepted by the daemon only
/// when it names the authenticated source's active terminal).
///
/// Replacing the declaration for a DIFFERENT buffer drops the frame
/// baseline: the next frame describes another session entirely, and
/// comparing it against the old one could suppress it.
pub fn set_terminal_viewport(&mut self, buffer_id: BufferId, size: CellSize) {
if self
.terminal_viewport
.is_some_and(|(previous, _)| previous != buffer_id)
{
self.clear_terminal_baseline();
}
self.terminal_viewport = Some((buffer_id, size));
}
/// The terminal geometry this frontend last declared.
///
/// The daemon reads this to apply the semantic layout sync beside
/// the landed grid sync, before the next child-output drain.
#[must_use]
pub fn terminal_viewport(&self) -> Option<(BufferId, CellSize)> {
self.terminal_viewport
}
/// Whether the last render pass projected a terminal.
///
/// The daemon consults this to suppress the document `CursorByte`
/// and the presence sweep: a terminal identity buffer is empty, so
/// both would describe a cursor at byte 0 of a buffer with no text.
/// It tracks the PASS, not the baseline — a first frame rejected by
/// validation still means this frontend is displaying a terminal,
/// and falling back to the document path there would paint an empty
/// buffer over a live session.
#[must_use]
pub fn in_terminal_mode(&self) -> bool {
self.terminal_active
}
/// Forget the terminal frame baseline so the next valid frame is
/// authoritative, and re-arm the invalid-frame log.
fn clear_terminal_baseline(&mut self) {
self.last_terminal_frame = None;
self.terminal_error_latched = false;
}
/// Drop terminal projection state on detach or context replacement.
pub fn on_terminal_context_released(&mut self) {
self.terminal_viewport = None;
self.clear_terminal_baseline();
}
/// Snapshot/baseline reset contract (PR #120 round 2 finding 1).
///
/// A `BufferSnapshot` resets the receiving frontend's
@ -464,6 +553,14 @@ impl SemanticRenderState {
/// buffer, and any buffer the frontend navigates to receives its
/// own snapshot first.
pub fn on_buffer_snapshot_sent(&mut self, buffer_id: BufferId) {
// Vterm Stage 3: a snapshot takes the GPU out of terminal mode
// unconditionally — it clears the prior frame and every
// terminal-only cache before painting. Both sides must forget
// together, and the frontend re-declares its geometry
// immediately after applying the snapshot, so dropping the
// declaration here costs one message, not a stuck terminal.
self.terminal_viewport = None;
self.clear_terminal_baseline();
self.last_sent.remove(&buffer_id);
self.last_style_gate.remove(&buffer_id);
self.last_decorations.remove(&buffer_id);
@ -496,6 +593,14 @@ impl SemanticRenderState {
/// to say (no hints, no prior non-empty send).
#[allow(clippy::too_many_lines)]
pub fn render_frame(&mut self, state: &EditorState) -> Vec<InstanceMessage> {
// Vterm Stage 3: a terminal window suppresses the whole document
// projection. It is checked FIRST because the terminal identity
// buffer is a valid (empty) document — running the document path
// over it would ship an authoritative empty styling/summary
// resync on top of the live cell grid.
if let Some(messages) = self.terminal_frame_pass(state) {
return messages;
}
let Some(vp) = self.viewport.clone() else {
// Emit nothing before the frontend declares a viewport.
return Vec::new();
@ -694,6 +799,125 @@ impl SemanticRenderState {
out
}
/// Project this frontend's active terminal, or `None` when it is
/// not displaying one (so the document path runs instead).
///
/// Returning `Some` means terminal mode: the caller emits exactly
/// these messages and no document family at all. What survives is
/// the buffer-independent chrome the native frontend still needs —
/// status band, theme, font, statusline, menu, and minibuffer — plus
/// the frame itself.
fn terminal_frame_pass(&mut self, state: &EditorState) -> Option<Vec<InstanceMessage>> {
// A v18 peer has no terminal surface at all: it keeps the
// document path over the empty identity buffer, exactly as it
// did before this protocol version existed.
//
// Every path out of terminal mode clears `terminal_active`
// explicitly. An early `?` that left it set would keep the
// daemon suppressing this frontend's `CursorByte` and presence
// long after it went back to editing a document.
let declaration = self
.peer_knows_terminal_frames
.then_some(self.terminal_viewport)
.flatten();
let Some((buffer_id, size)) = declaration else {
self.terminal_active = false;
return None;
};
let Some(snapshot) =
state.prepare_semantic_terminal_view(self.frontend_id, buffer_id, size)
else {
// The window switched away, the session died, or the
// declared size went out of range. Leave terminal mode and
// let the document path resume.
self.terminal_active = false;
self.clear_terminal_baseline();
return None;
};
self.terminal_active = true;
// Evaluate callbacks before `ThemeFacts` for the same reason the
// document path does: a callback may register a face, and the
// face inventory must precede the segment text that names it.
let statusline_evaluation = self.peer_knows_statusline_segments.then(|| {
evaluate_statusline(
state.lua_host.lua(),
&state.core,
&state.statusline_registry,
StatuslineEvaluationTarget::Semantic {
frontend_id: self.frontend_id,
declared_buffer: buffer_id,
},
)
});
let mut out = Vec::new();
let frame = snapshot.into_terminal_frame();
// Complete-payload comparison FIRST, and not on
// `screen_generation`: a scroll, a selection change, or a
// process exit must reach the frontend even though the screen
// itself is byte-identical.
//
// Comparing before validating is also what keeps the steady
// state cheap. Only validated frames are ever stored, so a frame
// equal to the baseline has already passed — re-running the
// per-cell width and topology checks every tick would recompute
// a verdict we hold.
if self.last_terminal_frame.as_ref() == Some(&frame) {
self.terminal_error_latched = false;
out.extend(self.terminal_chrome(state, buffer_id, statusline_evaluation));
return Some(out);
}
match frame.validate() {
Ok(()) => {
self.terminal_error_latched = false;
self.last_terminal_frame = Some(frame.clone());
out.push(InstanceMessage::TerminalFrame(frame));
}
Err(error) => {
// Never emit a malformed or truncated frame. The peer
// keeps the last valid one; one bounded log line marks
// the condition until a valid frame clears the latch.
if !self.terminal_error_latched {
self.terminal_error_latched = true;
eprintln!(
"pmacs: terminal frame for {:?} on {:?} failed validation, \
retaining the last valid frame: {error}",
buffer_id, self.frontend_id
);
}
}
}
out.extend(self.terminal_chrome(state, buffer_id, statusline_evaluation));
Some(out)
}
/// The buffer-independent chrome a terminal-mode frontend still
/// needs, in the order the document path emits it.
///
/// Shared by both terminal-pass exits so an unchanged frame and a
/// changed one ship exactly the same chrome — the suppression is
/// about the FRAME, never about the status band going quiet.
fn terminal_chrome(
&mut self,
state: &EditorState,
buffer_id: BufferId,
statusline_evaluation: Option<StatuslineEvaluation>,
) -> Vec<InstanceMessage> {
let mut out = Vec::new();
out.extend(self.status_facts_msg(state, buffer_id));
out.extend(self.menu_prompt_msg(state, buffer_id));
out.extend(self.minibuffer_prompt_msg(state, buffer_id));
out.extend(self.theme_facts_msg(state));
out.extend(self.font_facts_msg(state));
// Q#SL6/Q#SL8: face inventory must precede segment text.
if let Some(evaluation) = statusline_evaluation {
self.emit_statusline_segments(evaluation, &mut out);
}
out
}
/// Apply the lead evaluator's publication outcome to the v18 wire
/// baseline. Invalidated evaluations discard all callback text and
/// publish authoritative empty replacements for the captured old

View File

@ -12,24 +12,28 @@ pub mod session;
pub mod view;
pub use session::{
SharedTerminalManager, TerminalError, TerminalManager, TerminalProcessState,
TerminalSelectionSpan, TerminalSnapshot, TerminalSpec,
SharedTerminalManager, TerminalError, TerminalManager, 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;
/// Maximum terminal columns accepted at creation or resize.
pub const MAX_TERMINAL_COLS: u16 = 512;
/// Maximum visible terminal cells accepted at creation or resize.
pub const MAX_TERMINAL_VISIBLE_CELLS: usize = 262_144;
/// Maximum UTF-8 bytes retained in one terminal grapheme cluster.
pub const MAX_TERMINAL_GRAPHEME_BYTES: usize = 256;
// Vterm Stage 3: the screen bounds and the process/selection payload
// types moved to `pmacs-protocol` so the daemon's pre-emission check and
// a frontend's post-decode check run the SAME policy. Re-exported here
// so Stage 1/2 callers keep their `crate::terminal::…` paths and no
// duplicate type appears in the tree.
pub use pmacs_protocol::terminal::{
MAX_TERMINAL_COLS, MAX_TERMINAL_FRAME_GLYPH_BYTES, MAX_TERMINAL_GRAPHEME_BYTES,
MAX_TERMINAL_METADATA_BYTES, MAX_TERMINAL_ROWS, MAX_TERMINAL_VISIBLE_CELLS, TerminalFrame,
TerminalFrameError, TerminalProcessState, TerminalSelectionSpan,
};
/// Default retained main-screen scrollback rows.
///
/// Configuration-time, not a wire bound: history never crosses the
/// protocol, so this stays core-owned.
pub const DEFAULT_TERMINAL_SCROLLBACK_ROWS: usize = 10_000;
/// Maximum retained main-screen history cells.
/// Maximum retained main-screen history cells. Core-owned for the same
/// reason as [`DEFAULT_TERMINAL_SCROLLBACK_ROWS`].
pub const MAX_TERMINAL_HISTORY_CELLS: usize = 4_000_000;
/// Shared cap for terminal title and process-outcome metadata.
pub const MAX_TERMINAL_METADATA_BYTES: usize = 1_024;

View File

@ -20,7 +20,7 @@ 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,
MAX_TERMINAL_VISIBLE_CELLS, TerminalFrame, TerminalProcessState, TerminalSelectionSpan,
};
/// Shared single-owner terminal registry used by editor and future Lua bindings.
@ -133,30 +133,6 @@ impl TerminalSpec {
}
}
/// Process outcome published with an owned terminal snapshot.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum TerminalProcessState {
/// Child is running or termination has only been requested.
Running,
/// Child exited with a status code.
Exited(i32),
/// Child was terminated by a sanitized symbolic signal.
Signaled(String),
/// Supervision failed after the session was published.
Crashed(String),
}
/// One selected terminal-row span. Stage 1 snapshots leave selection empty.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TerminalSelectionSpan {
/// Visible row.
pub row: u32,
/// Inclusive starting column.
pub start_col: u32,
/// Exclusive ending column.
pub end_col: u32,
}
/// Owned, renderer-safe terminal state captured after a manager tick.
#[derive(Clone, Debug, PartialEq)]
pub struct TerminalSnapshot {
@ -184,6 +160,35 @@ pub struct TerminalSnapshot {
pub process: TerminalProcessState,
}
impl TerminalSnapshot {
/// Convert an owned snapshot into its protocol-v19 wire form.
///
/// The two shapes are deliberately distinct types even though their
/// fields line up: the snapshot is core-owned state the TUI also
/// consumes, while [`TerminalFrame`] is wire input a peer may forge.
/// The conversion is total, but the CALLER must still
/// [`TerminalFrame::validate`] before emitting — the aggregate glyph
/// bound is a wire limit the screen does not enforce, so a child
/// that builds a legal-but-huge internal snapshot must be caught
/// here rather than sent truncated.
#[must_use]
pub fn into_terminal_frame(self) -> TerminalFrame {
TerminalFrame {
buffer_id: self.buffer_id,
size: self.size,
cells: self.cells,
cursor: self.cursor,
title: self.title,
screen_generation: self.screen_generation,
selection: self.selection,
scroll_offset: self.scroll_offset,
at_bottom: self.at_bottom,
pid: self.pid,
process: self.process,
}
}
}
/// Terminal session/registry failures.
#[derive(Debug, Error)]
pub enum TerminalError {

View File

@ -7,8 +7,11 @@ use crate::buffer::BufferId;
use crate::cell::{Cell, CellCoord, CellSize, Glyph, Style};
use crate::protocol::FrontendId;
use crate::terminal::screen::{BorrowedScreenProjection, TerminalModes, TerminalRow};
use crate::terminal::session::{TerminalManager, TerminalSelectionSpan, TerminalSnapshot};
use crate::terminal::{MAX_TERMINAL_COLS, MAX_TERMINAL_ROWS, MAX_TERMINAL_VISIBLE_CELLS};
use crate::terminal::session::{TerminalManager, TerminalSnapshot};
use crate::terminal::{
MAX_TERMINAL_COLS, MAX_TERMINAL_ROWS, MAX_TERMINAL_VISIBLE_CELLS, TerminalProcessState,
TerminalSelectionSpan,
};
use crate::window::WindowId;
/// One frontend/window projection of a terminal session.
@ -245,11 +248,58 @@ impl TerminalManager {
/// Return the publication-consistent child grid size for one view.
#[must_use]
pub(crate) fn screen_size_for_view(&self, key: TerminalViewKey) -> Option<CellSize> {
self.screen_size(key.buffer_id)
}
/// The shared screen's current size, read from the borrowed
/// projection.
///
/// Deliberately not `snapshot(..).size`: that clones the whole
/// visible cell grid, and geometry comparison runs on every
/// dispatcher tick for every frontend with a declared terminal.
#[must_use]
pub fn screen_size(&self, buffer_id: BufferId) -> Option<CellSize> {
self.sessions
.get(&key.buffer_id)
.get(&buffer_id)
.map(|session| session.screen.projection_ref().size)
}
/// Record an exact view's declared viewport size without projecting.
///
/// Vterm Stage 3: a semantic frontend declares terminal geometry
/// through its own message rather than through a layout pass, and a
/// PASSIVE view must still record its size — that is what lets it
/// receive its own clipped/padded projection instead of the
/// controller's. Recording a size is deliberately not claiming
/// control; the caller decides whether to resize the PTY.
///
/// Returns `false` for an unknown session or an out-of-range size,
/// leaving prior geometry untouched.
pub fn record_view_size(&mut self, key: TerminalViewKey, viewport_size: CellSize) -> bool {
if !valid_viewport(viewport_size) {
return false;
}
let Some(session) = self.sessions.get(&key.buffer_id) else {
return false;
};
let projection = session.screen.projection_ref();
let bell_count = session.screen.bell_count();
let state = self.views.entry(key).or_insert_with(|| TerminalViewState {
alternate_active: Some(projection.alternate_active),
last_bell_count: bell_count,
..TerminalViewState::default()
});
normalize_state(state, projection);
state.viewport_size = Some(viewport_size);
true
}
/// The viewport size an exact view last declared or rendered at.
#[must_use]
pub fn declared_view_size(&self, key: TerminalViewKey) -> Option<CellSize> {
self.views.get(&key).and_then(|state| state.viewport_size)
}
/// Return fresh geometric status for one registered view.
#[must_use]
pub fn view_status(&mut self, key: TerminalViewKey) -> Option<TerminalViewStatus> {
@ -670,7 +720,7 @@ fn project_snapshot(
projection: BorrowedScreenProjection<'_>,
state: &TerminalViewState,
pid: u32,
process: crate::terminal::session::TerminalProcessState,
process: TerminalProcessState,
) -> TerminalSnapshot {
let rows = retained_rows(projection);
let geometry = view_geometry(&rows, state, viewport_size.rows);
@ -782,8 +832,8 @@ fn copy_selection_bytes(rows: &RetainedRows<'_>, selection: TerminalSelection) -
#[cfg(test)]
mod tests {
use super::*;
use crate::terminal::TerminalProcessState;
use crate::terminal::screen::ScreenProjection;
use crate::terminal::session::TerminalProcessState;
fn row(id: u64, offset: u32, text: &str, soft_wrapped: bool) -> TerminalRow {
TerminalRow {

View File

@ -63,6 +63,17 @@ impl TestDaemon {
Self::spawn_with_env_and_config(&[], Some(init_lua))
}
/// Spawn with BOTH env overrides and a user `init.lua`.
///
/// Vterm Stage 3 needs this pair together: `init.lua` opens the
/// terminal the frontend will attach to, and
/// `PMACS_INSTANCE_SEMANTIC_RENDER` is what makes the daemon
/// advertise the capability a semantic frontend requires.
#[allow(dead_code)] // consumed per-suite; not every test crate uses it
pub fn spawn_with_env_and_init(env_vars: &[(&str, &str)], init_lua: &str) -> Self {
Self::spawn_with_env_and_config(env_vars, Some(init_lua))
}
fn spawn_with_env_and_config(env_vars: &[(&str, &str)], init_lua: Option<&str>) -> Self {
let tempdir = TempDir::new().expect("tempdir");
// tempfile::TempDir creates 0755-mode directories; the daemon

View File

@ -781,11 +781,16 @@ fn a12_builtin_lsp_provider_tracks_real_attachment_and_unknown_label() {
// (the drop arm itself is pinned beside Frontend::apply_message).
#[test]
fn a13_17_26_protocol_semantic_init_late_join_and_version_cost() {
assert_eq!(PROTOCOL_VERSION, 18);
for version in 6..=18 {
// Vterm Stage 3 appended the terminal family as v19. This
// acceptance owns the STATUSLINE variant's placement and gate, so
// it tracks the current wire version rather than pinning 18: the
// v18 floor it actually cares about is asserted below and in
// `peer_accepts_statusline_message`.
assert_eq!(PROTOCOL_VERSION, 19);
for version in 6..=19 {
assert!(is_supported_protocol_version(version));
}
assert!(!is_supported_protocol_version(19));
assert!(!is_supported_protocol_version(20));
let sample = InstanceMessage::StatuslineSegments {
buffer_id: BufferId::from_raw(9),
left: vec![StatuslineSegment {

File diff suppressed because it is too large Load Diff