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:
commit
cac4961c73
|
|
@ -2595,6 +2595,7 @@ dependencies = [
|
|||
"postcard",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 15–27). `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`
|
||||
|
|
|
|||
|
|
@ -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 1–2 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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 1–2 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 1–3 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 28–37 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 28–37;
|
||||
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.
|
||||
|
||||
- **28–29:** postcard discriminant/round-trip pins, common
|
||||
`TerminalFrame::validate` boundary table, measured maximum legal payload, and
|
||||
v18/v19 daemon/frontend send filters.
|
||||
- **30–32:** 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.
|
||||
- **34–36:** 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.
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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
224
src/daemon.rs
224
src/daemon.rs
|
|
@ -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(®istry, old_peer));
|
||||
assert!(peer_declared_terminal_support(®istry, new_peer));
|
||||
// An unknown session is refused rather than defaulted open.
|
||||
assert!(!peer_declared_terminal_support(®istry, FrontendId(99)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_identity_includes_version_and_uptime() {
|
||||
let s = DaemonState::new(Some("research".into()));
|
||||
|
|
|
|||
213
src/editor.rs
213
src/editor.rs
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
138
src/protocol.rs
138
src/protocol.rs
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue