feat(vterm): add Stage 1 terminal core

Add compatibility-preserving full-screen ANSI operations, the bounded terminal
screen and input encoders, and a transactional TerminalManager owning one
read-only identity buffer, PTY process, and screen per session.

Drain terminal-owned process events before process.after-tick, retain exact
final output and PID/outcome annotations, reap killed buffers and shutdown
children safely, and enforce buffer-owned read-only checks across ordinary,
host, undo/redo, and CRDT mutation paths.

Cover split parser and grapheme boundaries, screen/reflow/history invariants,
device responses, lifecycle cleanup, and a real adversarial alternate-screen
PTY. Record the fully gated Stage 1 delivery and downstream TUI/GPU contracts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-07-21 14:20:28 -04:00
parent f26aa0b38b
commit bbc1f33a7c
14 changed files with 4611 additions and 216 deletions

View File

@ -1,8 +1,8 @@
# Agent handoff — cross-machine continuity
**Last updated: 2026-07-21, with Themes Arc 4 stage 3 implemented and
fully gated on the `statusline-segments` feature branch (awaiting
review; not merged).** This file is the bridge between development
**Last updated: 2026-07-21, with Vterm Stage 1 implemented and fully gated on
the `vterm-core` feature branch (awaiting review; not merged). Vterm Stages 2
and 3 are not implemented.** This file is the bridge between development
machines. If you are an agent reading on a fresh clone: this document
plus the `docs/*-framing.md` files ARE your memory. Read this fully
before taking on work, seed persistent memory from it, and **update this
@ -123,6 +123,43 @@ next machine reads it the way you just did.
workspace sweep 2,718 passed across 78 suites (19 ignored,
`basedpyright` filtered); `git diff --check` clean. This branch is
awaiting review and **must not be described as merged**.
- **Vterm Stage 1 terminal core IMPLEMENTED ON `vterm-core`, FULLY GATED,
AWAITING REVIEW, NOT MERGED** (`docs/vterm-framing.md` rev 3).
- `AnsiParserProfile::{LineOriented, FullScreen}` preserves compile/REPL
behavior while terminal PTYs emit the full cursor/mode/device operation
set. `src/terminal/{screen,input,session}.rs` owns the state machine,
encoders, and lifecycle registry.
- Public session seam: owned strict `TerminalSpec`; owned
`TerminalSnapshot`; `TerminalProcessState`; and
`SharedTerminalManager = Rc<RefCell<TerminalManager>>` with
`open/is_terminal/process_id/snapshot/tick/send/resize/terminate/prune/
shutdown`. Stage 1 snapshots are context-free; Stage 2 adds per-view
state without a second screen.
- `EditorState` tick order is supervisor → terminal-owned PID drain/prune →
`process.after-tick`. Terminal IDs are not exposed through
`pmacs.process`; ordinary Lua/LSP/MCP ownership is unchanged. Terminal
identity buffers are pathless, clean, empty, round-trip, and guarded
read-only at every rope/CRDT/history mutation boundary.
- Acceptance 114 is mapped in the framing. The real PTY bite splits
ESC/CSI writes, observes alternate-screen cursor addressing, blocks and
resumes through raw `send`, restores the main screen, and pins final
output before exact PID/outcome annotation. One-row annotation visibility,
TERM-ignoring shutdown, spawn rollback, buffer-kill prune, and immutable
empty CRDT bootstrap are pinned.
- Final from-start rerun: Clippy clean; 1,657 default + 1,833 CRDT library
tests (3 ignored each); 8 default + 9 CRDT vterm acceptance; M4 114 passed
(3 ignored, 1 filtered); required GPU 109; workspace 2,764 passed across
79 suites (19 ignored, 1 filtered); diff check clean. The first Clippy
attempt found only missing crate docs in the new acceptance; it was fixed
and the whole sequence restarted. `scripts/bite main src/lib.rs --test
vterm_stage1_acceptance` is green only as the helper's explicitly weaker
compile-time API bite.
- Stage 2 reviews require a durable focus/input resize owner, owning
`FrontendId` for the global `C-c` continuation, and local clipboard/BEL
signal drainage. Stage 3 additionally owns `pmacs-gpu/src/attach.rs`,
authenticated source routing, protocol-owned wire types/limits, and a
deliberate complete-frame limit decision: 16 MiB is insufficient; use a
measured legal-worst cap or aggregate bound, never silent chunking.
- Roadmap: `docs/roadmap-2026-07.md` (ranked arcs). Position:
- **Arc 1 (LSP utility surface) COMPLETE** — completion popup
(#92/#93), panels/references/outline/hover (#94#96), plus

View File

@ -91,15 +91,24 @@ composition, a pure built-in LSP segment, dynamic modeline faces, and
semantic/GPU transport through protocol v18. Merging stage 3 completes
Arc 4 on `main`.
### Arc 5 — Terminal, staged
### Arc 5 — Terminal, staged — VTERM STAGE 1 ON FEATURE BRANCH
- **Stage 1**: compile-mode / grep-mode / shell-command on the existing
PTY + ANSI + REPL-package substrate (line-oriented output buffer,
error-regex jump-to-file, `M-x compile`). Cheap, transformative.
- **Stage 2 (vterm)**: extend `ansi.rs` into a 2D grid model
(alt-screen, cursor addressing, scrollback — parser already
recognizes and discards these), grid-backed buffer view, GPU
rendering question (grid cells vs text buffer).
- **Compile-mode landed** in #113: line-oriented PTY/ANSI output,
error-regex navigation, and `M-x compile`.
- **Vterm Stage 1 terminal core** is implemented and fully gated on
`vterm-core`, awaiting review and **not merged**. It adds the compatibility
parser profiles, bounded VT screen/scrollback/reflow state machine, input
encoders, internal `TerminalManager`, read-only identity-buffer invariant,
process lifecycle/final annotations, and headless real-PTY acceptance. It
intentionally adds no interactive Lua command or frontend rendering.
- **Vterm Stage 2 TUI** starts only after Stage 1 merges: terminal-window
composition, input/resize, per-context scroll/selection/copy, and the Lua
surface.
- **Vterm Stage 3 protocol/GPU** starts only after Stage 2 merges: additive
protocol v19 complete frames, authenticated daemon routing, and native GPU
cell rendering. Its framing must resolve the current 16 MiB transport cap's
incompatibility with the legal worst complete terminal frame; never silently
chunk.
### Arc 6 — Folding (keystone gutter rider)

View File

@ -1,13 +1,16 @@
# Vterm — framing (Arc 5 stage 2, three-PR delivery)
**Revision 2 — 2026-07-21. Status: decisions folded; awaiting approval, no implementation.**
**Revision 3 — 2026-07-21. Status: Stage 1 terminal core implemented and fully
gated on `vterm-core`, awaiting review; not merged. Stages 2 and 3 are not
implemented.**
Revision 2 records the architecture discussion: `C-c` is the terminal editor
escape (`C-c C-c` sends interrupt); main-screen resize reflows while alternate
screen clips/pads; exited buffers remain with an Emacs-style process message;
protocol v19 is additive with complete frames; shared `Style` stays unchanged;
and one `BufferId` owns one shared process/screen whose most recently active
frontend controls size.
Revision 3 records the Stage 1 implementation and downstream consumer reviews.
The architecture remains unchanged: `C-c` is the terminal editor escape
(`C-c C-c` sends interrupt); main-screen resize reflows while alternate screen
clips/pads; exited buffers remain with an Emacs-style process message; protocol
v19 is additive with complete frames; shared `Style` stays unchanged; and one
`BufferId` owns one shared process/screen whose most recently active frontend
controls size.
This framing follows the compile-mode terminal substrate that landed in PR
#113. `src/process.rs` already owns PTY creation, process groups, bounded
@ -29,6 +32,123 @@ 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 3 — Stage 1 implementation record
The first of the three vterm PRs is implemented on `vterm-core`, fully gated,
and awaiting review. It is deliberately headless: there is no `pmacs.terminal`
Lua module, interactive terminal command, TUI paint branch, or GPU/protocol
surface yet.
### 0.1 Public seam and ownership
`src/terminal/session.rs` exports:
- owned `TerminalSpec { command, args, cwd, env, name, rows, cols,
scrollback_rows }`, with `new` and strict pre-side-effect `validate`;
- `TerminalProcessState::{Running, Exited(i32), Signaled(String),
Crashed(String)}`;
- owned `TerminalSnapshot { buffer_id, size, cells, cursor, title,
screen_generation, selection, scroll_offset, at_bottom, pid, process }`;
- `SharedTerminalManager = Rc<RefCell<TerminalManager>>`;
- `TerminalManager::{new, len, is_empty, open, is_terminal, process_id,
snapshot, tick, send, resize, terminate, prune, shutdown}`.
The Stage 1 `snapshot(BufferId)` is intentionally context-free:
`selection=[]`, `scroll_offset=0`, and `at_bottom=true`. Stage 2 adds
per-`(FrontendId, WindowId, BufferId)` state and an owned
`snapshot_for_view(...)`; it must not add a second screen.
`EditorState` owns the one shared manager. Tick order is supervisor `tick`
terminal-owned PID drain/watchdog/prune → `process.after-tick`; LSP, MCP, and
ordinary Lua process events retain their existing owners. `ProcessSpec` gained
an `AnsiParserProfile`: `ProcessSpec::new` and Lua `ansi=true` stay
`LineOriented`, while terminal sessions explicitly request `FullScreen`.
Synchronous unpublished terminal spawn failure emits no orphan process event.
Terminal identity buffers are pathless, clean, empty, and buffer-read-only.
The guard runs before direct edits, split Lua begin/skip-intercept edits,
undo/redo, and local/remote CRDT content import. Attaching an immutable empty
CRDT for semantic identity remains valid.
### 0.2 Stage 1 acceptance mapping
All fourteen Stage 1 criteria are implemented:
1. whole/split parser equivalence:
`screen::tests::parser_split_points_produce_identical_screen` plus the ANSI
split matrix;
2. malformed/truncated/over-cap recovery: the 44 `ansi::tests`, including
split UTF-8, ignored CSI/OSC/DCS, and forward-progress cases;
3. cursor/region/edit exactness:
`cursor_erase_insert_delete_scroll_and_margins_mutate_exact_regions`;
4. pending wrap/wide/combining invariants: wide overwrite, split ZWJ/RI/
modifier/variation, right-edge, and continuation tests;
5. alternate/synchronized publication:
`alternate_screen_preserves_main_and_has_no_history`,
`synchronized_output_gates_snapshot_and_finish_releases`, and watchdog;
6. DEC G0/G1 + SI/SO: `acs_and_device_replies_are_exact`;
7. SGR fidelity and ignored attributes: ANSI SGR/color/underline tests plus
screen operation coverage;
8. resize semantics: soft-wrap reflow, wide-boundary/cursor/hard-break tests,
alternate clipping, and atomic invalid resize;
9. dual history limits: `history_obeys_row_and_cell_caps`;
10. bounded DA/DSR/CPR and unsupported-output safety:
`acs_and_device_replies_are_exact` plus parser ignore cases;
11. strict owned specifications:
`strict_owned_spec_rejects_before_spawn_and_is_mutation_independent`;
12. real adversarial PTY/final drain:
`final_output_precedes_exact_nonzero_annotation_and_buffer_is_retained`
splits ESC/CSI writes, observes addressed alternate-screen output while
running, unblocks raw stdin through `send`, restores the main screen, and
proves final output precedes the exact PID/outcome annotation; zero,
non-zero, signal, wrapped, and one-row annotations are separately pinned;
13. lifecycle cleanup: transactional failure, live buffer-kill prune/reap, and
TERM-ignoring editor shutdown acceptance;
14. read-only/CRDT invariants: default + CRDT shared acceptance and focused
buffer unit tests cover every mutation route and empty bootstrap.
### 0.3 Final gates and bite
After one initial Clippy-only failure for the new acceptance crate's missing
module documentation, that source issue was fixed and the complete sequence was
restarted from gate 1:
- `cargo fmt --check`: clean;
- `cargo clippy --workspace --all-targets -- -D warnings`: clean;
- default library: 1,657 passed, 3 ignored;
- CRDT library: 1,833 passed, 3 ignored;
- Stage 1 acceptance: 8 default + 9 CRDT passed;
- M4 acceptance: 114 passed, 3 ignored, 1 `basedpyright` filtered;
- required GPU: 109 passed;
- workspace: 2,764 passed across 79 suites, 19 ignored, 1 filtered;
- `git diff --check`: clean.
`scripts/bite main src/lib.rs --test vterm_stage1_acceptance` returned
`bite: OK`: the swapped pre-stage crate root cannot compile the new terminal
API. This is explicitly the helper's weaker compile-time API bite, not a clean
behavioral assertion failure.
### 0.4 Downstream review findings (not implemented)
Stage 2 must derive PTY resize ownership from a durable accepted-input/focus
owner before render fan-out, never transient `EditorCore::active_frontend`.
Because `KeyDispatcher` pending state is global, a terminal `C-c` continuation
must carry its owning `FrontendId`. Terminal copy should use the existing core
kill-ring/clipboard setter, while the local run loop must drain/present
clipboard signals; active-terminal BEL likewise uses the out-of-band frontend
signal path.
Stage 3 additionally owns `pmacs-gpu/src/attach.rs` for gated terminal
resize/pointer sending and coalescing. Daemon handlers must authenticate source
frontend/buffer ownership before input, resize, or pointer routing. Wire-facing
terminal state, selection, and limits must live in or be re-exported from
`pmacs-protocol`. The current 16 MiB transport frame cap cannot hold the legal
worst complete terminal frame (up to roughly 64 MiB of cluster bytes before
encoding overhead): Stage 3 must either raise and test a measured cap at least
as large as the legal worst case (review estimate at least 80 MiB), or add a
shared aggregate payload bound. It must never silently chunk the locked
complete-frame protocol.
## 1. Problem and ownership boundary
Pmacs can supervise a PTY and can parse enough ANSI to turn command output into
@ -651,10 +771,10 @@ Per-stage utilization:
## 8. Branch and PR plan
After this framing is approved:
Stage 1 is now implemented and fully gated on `vterm-core`, awaiting review and
not merged. Continue the approved plan only after the preceding PR lands:
1. create sibling worktree `pmacs-vterm-core`, branch `vterm-core`, from current
canonical `main`; implement/gate/open PR; merge only when the user says;
1. review `vterm-core`; merge only when the user says;
2. after stage 1 merges, create `pmacs-vterm-tui`, branch `vterm-tui`, from the
new `main`; implement/gate/open a second PR;
3. after stage 2 merges, create `pmacs-vterm-gpu`, branch `vterm-gpu`, from the

View File

@ -43,6 +43,80 @@
use crate::cell::{Color, Style, UnderlineStyle};
/// Parser compatibility profile.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum AnsiParserProfile {
/// Preserve the compile/REPL byte-stream contract.
#[default]
LineOriented,
/// Emit terminal operations for a stateful full-screen consumer.
FullScreen,
}
#[allow(missing_docs)]
/// Erase direction for display and line operations.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EraseMode {
ToEnd,
ToStart,
All,
Saved,
}
#[allow(missing_docs)]
/// DEC alternate-screen selector.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AlternateScreenMode {
Mode47,
Mode1047,
Mode1049,
}
#[allow(missing_docs)]
/// Terminal modes understood by the screen/input core.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TerminalMode {
Insert,
Origin,
AutoWrap,
ApplicationCursor,
ApplicationKeypad,
CursorVisible,
BracketedPaste,
FocusReporting,
SynchronizedOutput,
MouseX10,
MouseButton,
MouseAny,
MouseSgr,
}
#[allow(missing_docs)]
/// G0/G1 designation target and supported character set.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CharacterSetSlot {
G0,
G1,
}
/// Character set designated into a DEC G0/G1 slot.
#[allow(missing_docs)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CharacterSet {
Ascii,
DecSpecialGraphics,
}
#[allow(missing_docs)]
/// Typed terminal query. Only these requests may generate PTY input.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DeviceRequest {
PrimaryAttributes,
SecondaryAttributes,
OperatingStatus,
CursorPosition,
}
// ---------------------------------------------------------------------------
// Public output
// ---------------------------------------------------------------------------
@ -53,6 +127,7 @@ use crate::cell::{Color, Style, UnderlineStyle};
/// at the moment the parser has enough context to commit to it
/// (e.g., `Text` is emitted at every transition out of Ground, not
/// per byte).
#[allow(missing_docs)]
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AnsiEvent {
/// Append literal text to the consumer's rope. Text never
@ -98,6 +173,55 @@ pub enum AnsiEvent {
/// `CSI ? 1049 l`: alternate-screen exited. `Text` and
/// `SetStyle` resume.
AlternateScreenExit,
/// Full-screen-only terminal operations.
Bell,
LineFeed,
HorizontalTab,
SetTabStop,
ClearTabStop,
ClearAllTabStops,
CursorUp(u32),
CursorDown(u32),
CursorForward(u32),
CursorBackward(u32),
CursorNextLine(u32),
CursorPreviousLine(u32),
CursorHorizontalAbsolute(u32),
CursorVerticalAbsolute(u32),
CursorPosition {
row: u32,
col: u32,
},
EraseDisplay(EraseMode),
EraseLineMode(EraseMode),
EraseCharacters(u32),
InsertCharacters(u32),
DeleteCharacters(u32),
InsertLines(u32),
DeleteLines(u32),
ScrollUp(u32),
ScrollDown(u32),
SetScrollingRegion {
top: u32,
bottom: Option<u32>,
},
SaveCursor,
RestoreCursor,
AlternateScreen {
mode: AlternateScreenMode,
enabled: bool,
},
SetMode {
mode: TerminalMode,
enabled: bool,
},
DesignateCharacterSet {
slot: CharacterSetSlot,
charset: CharacterSet,
},
ShiftOut,
ShiftIn,
DeviceRequest(DeviceRequest),
}
/// Tunable knobs for [`AnsiParser`].
@ -155,6 +279,7 @@ enum State {
Ground,
Escape,
EscapeIntermediate,
EscapeIgnore,
CsiEntry,
CsiParam,
CsiIntermediate,
@ -326,6 +451,7 @@ pub struct AnsiParser {
osc_body: Vec<u8>,
/// Intermediate bytes for plain ESC sequences (`ESC` + 0x20..=0x2F).
escape_intermediates: Vec<u8>,
profile: AnsiParserProfile,
config: AnsiParserConfig,
}
@ -339,12 +465,24 @@ impl AnsiParser {
/// Construct a parser with default configuration.
#[must_use]
pub fn new() -> Self {
Self::with_config(AnsiParserConfig::default())
Self::with_profile(AnsiParserProfile::LineOriented)
}
/// Construct a parser with custom configuration.
/// Construct a parser using the selected compatibility profile.
#[must_use]
pub fn with_profile(profile: AnsiParserProfile) -> Self {
Self::with_profile_and_config(profile, AnsiParserConfig::default())
}
/// Construct a line-oriented parser with custom configuration.
#[must_use]
pub fn with_config(config: AnsiParserConfig) -> Self {
Self::with_profile_and_config(AnsiParserProfile::LineOriented, config)
}
/// Construct a parser with both an explicit profile and configuration.
#[must_use]
pub fn with_profile_and_config(profile: AnsiParserProfile, config: AnsiParserConfig) -> Self {
Self {
state: State::Ground,
current_style: Style::default(),
@ -356,6 +494,7 @@ impl AnsiParser {
csi: CsiCollector::default(),
osc_body: Vec::new(),
escape_intermediates: Vec::new(),
profile,
config,
}
}
@ -396,11 +535,11 @@ impl AnsiParser {
// transition path (flush_text_run) does emit U+FFFD for
// pending bytes because a non-text byte genuinely
// interrupts the sequence; feed-boundary doesn't.
if !self.text_run.is_empty() && !self.alt_screen_active {
if !self.text_run.is_empty() {
let run = std::mem::take(&mut self.text_run);
events.push(AnsiEvent::Text(run));
} else {
self.text_run.clear();
if !self.suppress_visible() {
events.push(AnsiEvent::Text(run));
}
}
events
}
@ -431,23 +570,22 @@ impl AnsiParser {
pub fn finish(&mut self) -> Vec<AnsiEvent> {
let mut events = Vec::new();
self.flush_pending_utf8_as_replacement();
if !self.text_run.is_empty() && !self.alt_screen_active {
if !self.text_run.is_empty() {
let run = std::mem::take(&mut self.text_run);
events.push(AnsiEvent::Text(run));
} else {
self.text_run.clear();
if !self.suppress_visible() {
events.push(AnsiEvent::Text(run));
}
}
// Balancing state events, in unwind order. `reset` alone
// deliberately preserves alt-screen suppression (a
// mid-stream reset must not unhide alt-screen contents); a
// stream END does end it, observably.
if self.alt_screen_active {
self.alt_screen_active = false;
events.push(AnsiEvent::AlternateScreenExit);
}
if self.emitted_style != Style::default() {
events.push(AnsiEvent::SetStyle(Style::default()));
if self.profile == AnsiParserProfile::LineOriented {
if self.alt_screen_active {
self.alt_screen_active = false;
events.push(AnsiEvent::AlternateScreenExit);
}
if self.emitted_style != Style::default() {
events.push(AnsiEvent::SetStyle(Style::default()));
}
}
self.alt_screen_active = false;
self.current_style = Style::default();
self.emitted_style = Style::default();
self.reset();
@ -472,28 +610,53 @@ impl AnsiParser {
return;
}
// Per-state byte cap. The counter increments for every byte
// consumed in any non-Ground state, and is reset to zero at
// every transition into a fresh sequence (ESC-anywhere) or
// back to Ground (normal dispatch / force-recover). At the
// limit, the parser drops the in-flight sequence and
// returns to Ground; the *current* byte is dropped on the
// floor, but subsequent bytes are processed normally as
// ordinary text. Spec §sec:ansi-scope: "drops back to ground
// state at the next ESC or after a bounded number of bytes
// (1 KiB), whichever comes first."
if self.state != State::Ground {
// Bound retained control-string payload without ever exposing its
// overflow as printable text. Once capped, remain in a zero-storage
// ignore state until BEL/ST or a fresh ESC sequence provides a safe
// recovery boundary.
if self.state != State::Ground
&& !matches!(
self.state,
State::EscapeIgnore | State::CsiIgnore | State::OscIgnore | State::DcsIgnore
)
{
self.ignore_byte_count = self.ignore_byte_count.saturating_add(1);
if self.ignore_byte_count > self.config.unknown_sequence_byte_limit {
self.recover_to_ground();
match self.state {
State::OscString | State::OscEscPending => {
self.osc_body.clear();
self.state = State::OscIgnore;
self.ignore_byte_count = 0;
}
State::DcsEntry
| State::DcsParam
| State::DcsIntermediate
| State::DcsPassthrough
| State::SosPmApcString => {
self.state = State::DcsIgnore;
self.ignore_byte_count = 0;
}
State::Escape | State::EscapeIntermediate => {
self.escape_intermediates.clear();
self.state = State::EscapeIgnore;
self.ignore_byte_count = 0;
}
State::CsiEntry | State::CsiParam | State::CsiIntermediate => {
self.csi.reset();
self.state = State::CsiIgnore;
self.ignore_byte_count = 0;
}
_ => self.recover_to_ground(),
}
return;
}
}
match self.state {
State::Ground => self.feed_ground(b, events),
State::Escape => self.feed_escape(b),
State::EscapeIntermediate => self.feed_escape_intermediate(b),
State::Escape => self.feed_escape(b, events),
State::EscapeIntermediate => self.feed_escape_intermediate(b, events),
State::EscapeIgnore => self.feed_escape_ignore(b),
State::CsiEntry => self.feed_csi_entry(b, events),
State::CsiParam => self.feed_csi_param(b, events),
State::CsiIntermediate => self.feed_csi_intermediate(b, events),
@ -530,13 +693,13 @@ impl AnsiParser {
return;
}
let run = std::mem::take(&mut self.text_run);
if !self.alt_screen_active {
if !self.suppress_visible() {
events.push(AnsiEvent::Text(run));
}
}
fn emit_set_style(&mut self, events: &mut Vec<AnsiEvent>) {
if self.alt_screen_active {
if self.suppress_visible() {
return;
}
self.emitted_style = self.current_style;
@ -548,11 +711,15 @@ impl AnsiParser {
/// paste / `SetTitle`). The alt-screen markers themselves
/// bypass this.
fn push_visible(&self, ev: AnsiEvent, events: &mut Vec<AnsiEvent>) {
if !self.alt_screen_active {
if !self.suppress_visible() {
events.push(ev);
}
}
fn suppress_visible(&self) -> bool {
self.profile == AnsiParserProfile::LineOriented && self.alt_screen_active
}
/// Begin a fresh escape sequence (called from ESC-anywhere).
/// Resets the byte budget and all in-flight sequence state.
fn start_new_sequence(&mut self) {
@ -579,44 +746,53 @@ impl AnsiParser {
// -----------------------------------------------------------------------
fn feed_ground(&mut self, b: u8, events: &mut Vec<AnsiEvent>) {
match b {
// CR: flush text, emit CarriageReturn.
0x0D => {
self.flush_text_run(events);
if !self.alt_screen_active {
events.push(AnsiEvent::CarriageReturn);
if self.profile == AnsiParserProfile::LineOriented {
match b {
0x0D => {
self.flush_text_run(events);
self.push_visible(AnsiEvent::CarriageReturn, events);
}
0x08 => {
self.flush_text_run(events);
self.push_visible(AnsiEvent::Backspace, events);
}
0x07 | 0x09 | 0x0A | 0x0B | 0x0C | 0x20..=0x7E | 0x80..=0xFF => {
self.push_text_byte(b);
}
0x00..=0x1F | 0x7F => {}
}
return;
}
match b {
0x07 => {
self.flush_text_run(events);
events.push(AnsiEvent::Bell);
}
// BS: flush text, emit Backspace.
0x08 => {
self.flush_text_run(events);
if !self.alt_screen_active {
events.push(AnsiEvent::Backspace);
}
events.push(AnsiEvent::Backspace);
}
// BEL (0x07), VT (0x0B), FF (0x0C), HT (0x09), LF
// (0x0A): pass through to text alongside printable
// ASCII (0x20..=0x7E). The REPL view treats LF as a
// line break in the rope; HT as a literal tab. Other
// C0 controls (0x00..=0x06, 0x0E..=0x1F) and DEL
// (0x7F) are dropped silently.
//
// 0x80..=0xFF: UTF-8 lead or continuation byte. Goes
// through `push_text_byte`'s stateful decoder so
// multi-byte sequences across feeds are buffered until
// complete.
//
// All text bytes route through `push_text_byte` (not
// just non-ASCII): an ASCII byte arriving while a
// partial UTF-8 sequence is pending invalidates that
// sequence (the partial prefix's expected continuation
// didn't arrive), and `push_text_byte` is the only
// place that knows to flush the partial as `U+FFFD`.
// The fast path inside `push_text_byte` keeps the
// pure-ASCII case allocation-free.
0x07 | 0x09 | 0x0A | 0x0B | 0x0C | 0x20..=0x7E | 0x80..=0xFF => {
self.push_text_byte(b);
0x09 => {
self.flush_text_run(events);
events.push(AnsiEvent::HorizontalTab);
}
0x0A..=0x0C => {
self.flush_text_run(events);
events.push(AnsiEvent::LineFeed);
}
0x0D => {
self.flush_text_run(events);
events.push(AnsiEvent::CarriageReturn);
}
0x0E => {
self.flush_text_run(events);
events.push(AnsiEvent::ShiftOut);
}
0x0F => {
self.flush_text_run(events);
events.push(AnsiEvent::ShiftIn);
}
0x20..=0x7E | 0x80..=0xFF => self.push_text_byte(b),
0x00..=0x1F | 0x7F => {}
}
}
@ -726,7 +902,7 @@ impl AnsiParser {
// Escape
// -----------------------------------------------------------------------
fn feed_escape(&mut self, b: u8) {
fn feed_escape(&mut self, b: u8, events: &mut Vec<AnsiEvent>) {
match b {
0x20..=0x2F => {
self.escape_intermediates.push(b);
@ -740,42 +916,61 @@ impl AnsiParser {
self.osc_body.clear();
self.state = State::OscString;
}
// DCS / SOS / PM / APC introducers --- parse and discard.
b'P' => {
self.state = State::DcsEntry;
}
b'X' | b'^' | b'_' => {
self.state = State::SosPmApcString;
}
// ESC \ in Escape state is a stray ST; final byte for
// a bare ESC sequence (0x30..=0x7E) lands here too. We
// don't dispatch any single-byte ESC commands in v0.1
// (cursor save/restore `ESC 7`/`ESC 8` are deliberately
// unsupported per spec); both cases consume and return
// to Ground.
b'\\' | 0x30..=0x7E => {
b'P' => self.state = State::DcsEntry,
b'X' | b'^' | b'_' => self.state = State::SosPmApcString,
b'7' | b'8' | b'H' | b'=' | b'>' if self.profile == AnsiParserProfile::FullScreen => {
let event = match b {
b'7' => AnsiEvent::SaveCursor,
b'8' => AnsiEvent::RestoreCursor,
b'H' => AnsiEvent::SetTabStop,
b'=' => AnsiEvent::SetMode {
mode: TerminalMode::ApplicationKeypad,
enabled: true,
},
_ => AnsiEvent::SetMode {
mode: TerminalMode::ApplicationKeypad,
enabled: false,
},
};
events.push(event);
self.recover_to_ground();
}
// C0 controls inside Escape: drop, stay in Escape.
b'\\' | 0x30..=0x7E => self.recover_to_ground(),
_ => {}
}
}
fn feed_escape_intermediate(&mut self, b: u8) {
fn feed_escape_intermediate(&mut self, b: u8, events: &mut Vec<AnsiEvent>) {
match b {
0x20..=0x2F => {
self.escape_intermediates.push(b);
}
// Final byte: drop the sequence (no ESC + intermediate
// dispatches in v0.1 --- charsets are deliberately
// unsupported per spec) and return to Ground.
0x20..=0x2F => self.escape_intermediates.push(b),
0x30..=0x7E => {
if self.profile == AnsiParserProfile::FullScreen {
let slot = match self.escape_intermediates.as_slice() {
[b'('] => Some(CharacterSetSlot::G0),
[b')'] => Some(CharacterSetSlot::G1),
_ => None,
};
let charset = match b {
b'0' => Some(CharacterSet::DecSpecialGraphics),
b'B' => Some(CharacterSet::Ascii),
_ => None,
};
if let (Some(slot), Some(charset)) = (slot, charset) {
events.push(AnsiEvent::DesignateCharacterSet { slot, charset });
}
}
self.recover_to_ground();
}
_ => {}
}
}
fn feed_escape_ignore(&mut self, b: u8) {
if matches!(b, 0x30..=0x7E) {
self.recover_to_ground();
}
}
// -----------------------------------------------------------------------
// CSI
// -----------------------------------------------------------------------
@ -850,70 +1045,129 @@ impl AnsiParser {
/// Dispatch a fully-collected CSI sequence. `final_byte` is the
/// terminating byte (`0x40..=0x7E`). The collected parameters
/// are taken from `self.csi`.
#[allow(clippy::too_many_lines)]
fn dispatch_csi(&mut self, final_byte: u8, events: &mut Vec<AnsiEvent>) {
let private_marker = self.csi.private_marker;
let private = self.csi.private_marker;
let intermediates = self.csi.intermediates.clone();
let params = self.csi.finalize();
match (private_marker, final_byte) {
// SGR.
(None, b'm') => self.dispatch_sgr(&params, events),
// Erase in line: `CSI [n] K`. n=0 (default) →
// EraseToEol; n=2 → EraseLine; n=1 (start to cursor)
// and others: parsed and ignored.
(None, b'K') => {
let n = params.first().map_or(0, |p| p.main);
match n {
if private.is_none() && final_byte == b'm' {
self.dispatch_sgr(&params, events);
self.csi.reset();
return;
}
if self.profile == AnsiParserProfile::LineOriented {
match (private, final_byte) {
(None, b'K') => match param(&params, 0, 0) {
0 => self.push_visible(AnsiEvent::EraseToEol, events),
2 => self.push_visible(AnsiEvent::EraseLine, events),
_ => {}
}
}
// Bracketed paste markers: `CSI 200 ~` / `CSI 201 ~`.
(None, b'~') => {
let n = params.first().map_or(0, |p| p.main);
match n {
},
(None, b'~') => match param(&params, 0, 0) {
200 => self.push_visible(AnsiEvent::BracketedPasteBegin, events),
201 => self.push_visible(AnsiEvent::BracketedPasteEnd, events),
_ => {}
}
}
// DEC private mode set / reset: `CSI ? <num> h` / `l`.
// Of these, only ?1049 (alternate screen) produces an
// event; mouse modes (?1000, ?1006), bracketed-paste
// mode (?2004), and the long tail are parsed and
// discarded per spec §sec:ansi-scope.
(Some(b'?'), b'h' | b'l') => {
let set = final_byte == b'h';
for p in &params {
if p.main == 1049 {
if set && !self.alt_screen_active {
self.alt_screen_active = true;
events.push(AnsiEvent::AlternateScreenEnter);
} else if !set && self.alt_screen_active {
self.alt_screen_active = false;
events.push(AnsiEvent::AlternateScreenExit);
// SGR changes inside the alternate
// screen advanced `current_style` while
// their events were suppressed; the
// consumer still holds the pre-enter
// style. Resynchronize the effective
// style on exit (round-4 finding 2).
if self.current_style != self.emitted_style {
self.emit_set_style(events);
},
(Some(b'?'), b'h' | b'l') => {
let set = final_byte == b'h';
for p in &params {
if p.main == 1049 {
if set && !self.alt_screen_active {
self.alt_screen_active = true;
events.push(AnsiEvent::AlternateScreenEnter);
} else if !set && self.alt_screen_active {
self.alt_screen_active = false;
events.push(AnsiEvent::AlternateScreenExit);
if self.current_style != self.emitted_style {
self.emit_set_style(events);
}
}
}
}
}
_ => {}
}
// Cursor motions (A/B/C/D/E/F/G/H/J/f) and other CSI
// commands: parsed and discarded for M6.3. The M6.4
// view layer handles intra-line motion (CR / BS) at
// its own level; cross-region motion via CSI is in the
// "parsed but ignored when it would cross region
// boundaries" bucket from §sec:ansi-scope.
_ => {}
self.csi.reset();
return;
}
let count = || param(&params, 0, 1).max(1);
let event = match (private, intermediates.as_slice(), final_byte) {
(None, [], b'A') => Some(AnsiEvent::CursorUp(count())),
(None, [], b'B') => Some(AnsiEvent::CursorDown(count())),
(None, [], b'C' | b'a') => Some(AnsiEvent::CursorForward(count())),
(None, [], b'D') => Some(AnsiEvent::CursorBackward(count())),
(None, [], b'E') => Some(AnsiEvent::CursorNextLine(count())),
(None, [], b'F') => Some(AnsiEvent::CursorPreviousLine(count())),
(None, [], b'G' | b'`') => Some(AnsiEvent::CursorHorizontalAbsolute(
param(&params, 0, 1).max(1),
)),
(None, [], b'd') => Some(AnsiEvent::CursorVerticalAbsolute(
param(&params, 0, 1).max(1),
)),
(None, [], b'H' | b'f') => Some(AnsiEvent::CursorPosition {
row: param(&params, 0, 1).max(1),
col: param(&params, 1, 1).max(1),
}),
(None, [], b'J') => erase_mode(param(&params, 0, 0)).map(AnsiEvent::EraseDisplay),
(None, [], b'K') => erase_mode(param(&params, 0, 0)).map(AnsiEvent::EraseLineMode),
(None, [], b'X') => Some(AnsiEvent::EraseCharacters(count())),
(None, [], b'@') => Some(AnsiEvent::InsertCharacters(count())),
(None, [], b'P') => Some(AnsiEvent::DeleteCharacters(count())),
(None, [], b'L') => Some(AnsiEvent::InsertLines(count())),
(None, [], b'M') => Some(AnsiEvent::DeleteLines(count())),
(None, [], b'S') => Some(AnsiEvent::ScrollUp(count())),
(None, [], b'T') => Some(AnsiEvent::ScrollDown(count())),
(None, [], b'r') => Some(AnsiEvent::SetScrollingRegion {
top: param(&params, 0, 1).max(1),
bottom: params.get(1).map(|p| p.main).filter(|&n| n != 0),
}),
(None, [], b's') => Some(AnsiEvent::SaveCursor),
(None, [], b'u') => Some(AnsiEvent::RestoreCursor),
(None, [], b'g') => match param(&params, 0, 0) {
0 => Some(AnsiEvent::ClearTabStop),
3 => Some(AnsiEvent::ClearAllTabStops),
_ => None,
},
(None, [], b'~') => match param(&params, 0, 0) {
200 => Some(AnsiEvent::BracketedPasteBegin),
201 => Some(AnsiEvent::BracketedPasteEnd),
_ => None,
},
(None, [], b'h' | b'l') => {
let enabled = final_byte == b'h';
for p in &params {
if p.main == 4 {
events.push(AnsiEvent::SetMode {
mode: TerminalMode::Insert,
enabled,
});
}
}
None
}
(Some(b'?'), [], b'h' | b'l') => {
let enabled = final_byte == b'h';
for p in &params {
if let Some(ev) = private_mode_event(p.main, enabled) {
events.push(ev);
}
}
None
}
(None, [], b'c') => Some(AnsiEvent::DeviceRequest(DeviceRequest::PrimaryAttributes)),
(Some(b'>'), [], b'c') => {
Some(AnsiEvent::DeviceRequest(DeviceRequest::SecondaryAttributes))
}
(None, [], b'n') => match param(&params, 0, 0) {
5 => Some(AnsiEvent::DeviceRequest(DeviceRequest::OperatingStatus)),
6 => Some(AnsiEvent::DeviceRequest(DeviceRequest::CursorPosition)),
_ => None,
},
_ => None,
};
if let Some(event) = event {
events.push(event);
}
self.csi.reset();
}
@ -959,9 +1213,13 @@ impl AnsiParser {
_ => UnderlineStyle::Single,
};
}
// 5/6 (blink, rapid blink): mapped to bold per spec
// §sec:ansi-scope ("blink-as-bold").
5 | 6 => self.current_style.bold = true,
// Line-oriented compile/REPL consumers historically render
// blink as bold. Full-screen preserves the shared Style
// contract: blink is unsupported and leaves it unchanged.
5 | 6 if self.profile == AnsiParserProfile::LineOriented => {
self.current_style.bold = true;
}
5 | 6 => {}
7 => self.current_style.reverse = true,
// 8 (concealed/invisible): no-op. Out of scope.
8 => {}
@ -975,9 +1233,8 @@ impl AnsiParser {
22 => self.current_style.bold = false,
23 => self.current_style.italic = false,
24 => self.current_style.underline = UnderlineStyle::None,
// 25 (blink off): no-op. Symmetry with 5/6 → bold:
// we do not unset bold here, since that would also
// unset bold acquired via SGR 1.
// 25 (blink off): unsupported. It must not unset bold
// acquired through SGR 1.
25 => {}
27 => self.current_style.reverse = false,
28 => {}
@ -1039,11 +1296,12 @@ impl AnsiParser {
}
// ESC: begin ST-terminator check (ESC \).
0x1B => self.state = State::OscEscPending,
// 0x20..=0x7F: body bytes. The per-state byte cap
// (enforced at the top of `feed_byte`) bounds how many
// bytes we'll accept before force-recovering.
0x20..=0x7F => self.osc_body.push(b),
// Other C0/C1 controls: drop silently, stay in OSC.
// OSC payload is UTF-8 bytes, not ASCII. Retain printable ASCII,
// DEL (compatibility), and all high bytes; lossy UTF-8 decoding at
// dispatch replaces malformed sequences. The per-state cap bounds
// retained storage.
0x20..=0xFF => self.osc_body.push(b),
// Other C0 controls: drop silently, stay in OSC.
_ => {}
}
}
@ -1083,7 +1341,7 @@ impl AnsiParser {
let num: Option<u32> = std::str::from_utf8(num_part)
.ok()
.and_then(|s| s.parse().ok());
if matches!(num, Some(133)) && !self.alt_screen_active {
if matches!(num, Some(133)) && !self.suppress_visible() {
match text_part.first().copied() {
Some(b'A') => events.push(AnsiEvent::PromptStart),
Some(b'B') => events.push(AnsiEvent::PromptEnd),
@ -1099,13 +1357,66 @@ impl AnsiParser {
// above produce events. Other OSC numbers are parsed and
// discarded per spec §sec:ansi-scope, with the critical
// guarantee that state alignment is preserved.
if matches!(num, Some(0 | 2)) && !self.alt_screen_active {
if matches!(num, Some(0 | 2)) && !self.suppress_visible() {
let title = String::from_utf8_lossy(text_part).into_owned();
events.push(AnsiEvent::SetTitle(title));
}
}
}
fn param(params: &CsiParams, index: usize, default: u32) -> u32 {
params
.get(index)
.map_or(default, |p| if p.main == 0 { default } else { p.main })
}
fn erase_mode(value: u32) -> Option<EraseMode> {
match value {
0 => Some(EraseMode::ToEnd),
1 => Some(EraseMode::ToStart),
2 => Some(EraseMode::All),
3 => Some(EraseMode::Saved),
_ => None,
}
}
fn private_mode_event(value: u32, enabled: bool) -> Option<AnsiEvent> {
let mode = match value {
47 => {
return Some(AnsiEvent::AlternateScreen {
mode: AlternateScreenMode::Mode47,
enabled,
});
}
1047 => {
return Some(AnsiEvent::AlternateScreen {
mode: AlternateScreenMode::Mode1047,
enabled,
});
}
1049 => {
return Some(AnsiEvent::AlternateScreen {
mode: AlternateScreenMode::Mode1049,
enabled,
});
}
1 => TerminalMode::ApplicationCursor,
6 => TerminalMode::Origin,
7 => TerminalMode::AutoWrap,
25 => TerminalMode::CursorVisible,
66 => TerminalMode::ApplicationKeypad,
1000 => TerminalMode::MouseX10,
1002 => TerminalMode::MouseButton,
1003 => TerminalMode::MouseAny,
1004 => TerminalMode::FocusReporting,
1006 => TerminalMode::MouseSgr,
2004 => TerminalMode::BracketedPaste,
2026 => TerminalMode::SynchronizedOutput,
_ => return None,
};
Some(AnsiEvent::SetMode { mode, enabled })
}
/// Parse a CSI 38/48 extended-color suffix into a `Color` plus
/// the number of *additional* params consumed (legacy form only;
/// the modern subparam form keeps everything inside `p.sub` so
@ -1995,4 +2306,174 @@ mod tests {
even though the internal style is already default"
);
}
#[test]
fn full_screen_emits_typed_operation_set_across_every_split() {
let bytes = b"\x07\t\n\x1bH\x1b[2A\x1b[3B\x1b[4C\x1b[5D\x1b[2E\x1b[2F\
\x1b[7G\x1b[8d\x1b[2;3H\x1b[J\x1b[1K\x1b[2X\x1b[3@\x1b[4P\
\x1b[2L\x1b[2M\x1b[3S\x1b[2T\x1b[2;20r\x1b[s\x1b[u\x1b[3g\
\x1b[?1;6;7;25;1000;1002;1003;1004;1006;2004;2026h\
\x1b[?47h\x1b[?1047h\x1b[?1049h\x1b[c\x1b[>c\x1b[5n\x1b[6n";
let mut whole = AnsiParser::with_profile(AnsiParserProfile::FullScreen);
let expected = whole.feed(bytes);
assert!(expected.contains(&AnsiEvent::Bell));
assert!(expected.contains(&AnsiEvent::CursorPosition { row: 2, col: 3 }));
assert!(expected.contains(&AnsiEvent::SetScrollingRegion {
top: 2,
bottom: Some(20)
}));
assert!(expected.contains(&AnsiEvent::SetMode {
mode: TerminalMode::SynchronizedOutput,
enabled: true
}));
assert!(expected.contains(&AnsiEvent::AlternateScreen {
mode: AlternateScreenMode::Mode1049,
enabled: true
}));
assert!(expected.contains(&AnsiEvent::DeviceRequest(DeviceRequest::CursorPosition)));
for split in 0..=bytes.len() {
let mut parser = AnsiParser::with_profile(AnsiParserProfile::FullScreen);
let mut actual = parser.feed(&bytes[..split]);
actual.extend(parser.feed(&bytes[split..]));
assert_eq!(actual, expected, "split {split}");
}
}
#[test]
fn full_screen_finish_flushes_without_synthetic_balancing() {
let mut parser = AnsiParser::with_profile(AnsiParserProfile::FullScreen);
let events = parser.feed(b"\x1b[?1049h\x1b[31mred");
assert!(events.contains(&AnsiEvent::AlternateScreen {
mode: AlternateScreenMode::Mode1049,
enabled: true,
}));
assert!(
events
.iter()
.any(|event| matches!(event, AnsiEvent::SetStyle(_)))
);
assert!(parser.finish().is_empty());
assert!(!parser.alt_screen_active);
assert!(parser.finish().is_empty());
assert_eq!(parser.feed(b"x"), vec![AnsiEvent::Text("x".into())]);
}
#[test]
fn full_screen_charset_designation_and_shift_are_typed() {
let mut parser = AnsiParser::with_profile(AnsiParserProfile::FullScreen);
assert_eq!(
parser.feed(b"\x1b(0\x1b)B\x0e\x0f"),
vec![
AnsiEvent::DesignateCharacterSet {
slot: CharacterSetSlot::G0,
charset: CharacterSet::DecSpecialGraphics,
},
AnsiEvent::DesignateCharacterSet {
slot: CharacterSetSlot::G1,
charset: CharacterSet::Ascii,
},
AnsiEvent::ShiftOut,
AnsiEvent::ShiftIn,
]
);
}
#[test]
fn full_screen_capped_control_strings_recover_invisibly() {
let config = AnsiParserConfig {
unknown_sequence_byte_limit: 8,
};
let mut parser = AnsiParser::with_profile_and_config(AnsiParserProfile::FullScreen, config);
let events = parser.feed(b"\x1b]52;AAAAAAAABsecret\x1b\\ok\x1bPAAAAAAAAAAAA\x1b\\done");
let visible: String = events
.iter()
.filter_map(|event| match event {
AnsiEvent::Text(text) => Some(text.as_str()),
_ => None,
})
.collect();
assert!(!visible.contains("secret"));
assert!(!visible.contains("AAAA"));
assert!(visible.ends_with("done"));
assert!(!visible.contains('\x1b'));
}
#[test]
fn capped_csi_and_escape_intermediate_never_leak_payload() {
let config = AnsiParserConfig {
unknown_sequence_byte_limit: 8,
};
for input in [
b"\x1b[12345678901234567890mOK".as_slice(),
b"\x1b[?999999999999999999hOK".as_slice(),
b"\x1b 0OK".as_slice(),
] {
let mut parser =
AnsiParser::with_profile_and_config(AnsiParserProfile::FullScreen, config);
let visible: String = parser
.feed(input)
.into_iter()
.filter_map(|event| {
if let AnsiEvent::Text(text) = event {
Some(text)
} else {
None
}
})
.collect();
assert_eq!(visible, "OK", "input {input:?}");
}
}
#[test]
fn full_screen_unsupported_sgr_attributes_leave_style_unchanged() {
let mut parser = AnsiParser::with_profile(AnsiParserProfile::FullScreen);
parser.feed(b"\x1b[1;3;31m");
let before = parser.current_style;
parser.feed(b"\x1b[2;5;6;8;9;25;28;29m");
assert_eq!(parser.current_style, before);
assert!(before.bold);
let mut line = AnsiParser::new();
line.feed(b"\x1b[5m");
assert!(
line.current_style.bold,
"line-oriented blink-as-bold compatibility"
);
}
#[test]
fn unicode_osc_titles_survive_every_feed_split() {
let bytes = "\u{1b}]2;héllo 世界\u{7}".as_bytes();
let mut whole = AnsiParser::with_profile(AnsiParserProfile::FullScreen);
let expected = whole.feed(bytes);
assert_eq!(expected, vec![AnsiEvent::SetTitle("héllo 世界".into())]);
for split in 0..=bytes.len() {
let mut parser = AnsiParser::with_profile(AnsiParserProfile::FullScreen);
let mut actual = parser.feed(&bytes[..split]);
actual.extend(parser.feed(&bytes[split..]));
assert_eq!(actual, expected, "split {split}");
}
}
#[test]
fn malformed_utf8_osc_title_is_replaced_and_bounded() {
let config = AnsiParserConfig {
unknown_sequence_byte_limit: 32,
};
let mut parser = AnsiParser::with_profile_and_config(AnsiParserProfile::FullScreen, config);
let events = parser.feed(b"\x1b]0;bad\xfftitle\x07");
let title = events
.into_iter()
.find_map(|event| {
if let AnsiEvent::SetTitle(title) = event {
Some(title)
} else {
None
}
})
.expect("title event");
assert_eq!(title, "bad\u{fffd}title");
assert!(title.len() <= config.unknown_sequence_byte_limit);
}
}

View File

@ -160,6 +160,9 @@ pub struct Buffer {
rope: Rope,
name: String,
is_modified: bool,
/// When set, every content mutation is rejected before touching the
/// rope, CRDT, history, revision, modified bit, marks, or views.
read_only: bool,
/// Monotonic counter bumped by every successful forward edit, undo,
/// and redo. Used by the editor to detect "did this command modify
/// the buffer?" without reaching into the rope. LSP `did_change`
@ -243,6 +246,7 @@ impl Buffer {
rope,
name: name.into(),
is_modified: false,
read_only: false,
revision: 0,
views: Vec::new(),
next_view_id: 0,
@ -468,6 +472,32 @@ impl Buffer {
self.is_modified = false;
}
/// Whether content mutation is disabled for this buffer.
#[must_use]
pub fn is_read_only(&self) -> bool {
self.read_only
}
/// Enable or disable the buffer-owned content-mutation guard.
///
/// This is deliberately independent of edit intercepts: terminal identity
/// buffers use it to reject host-side edits, undo/redo, and remote CRDT
/// imports as well as ordinary interactive edits.
pub fn set_read_only(&mut self, read_only: bool) {
self.read_only = read_only;
}
fn ensure_writable(&self) -> Result<(), BufferError> {
if self.read_only {
Err(BufferError::ReadOnly {
id: self.id,
name: self.name.clone(),
})
} else {
Ok(())
}
}
/// Total length of the buffer in bytes.
#[must_use]
pub fn len(&self) -> Position {
@ -614,6 +644,7 @@ impl Buffer {
/// `apply_edit_skip_intercepts` surfaces a typed error rather
/// than silently corrupting state.
pub fn begin_edit(&mut self) -> Result<(), BufferError> {
self.ensure_writable()?;
if self.editing_in_progress {
return Err(BufferError::ConcurrentEdit {
id: self.id,
@ -661,6 +692,7 @@ impl Buffer {
///
/// Threading: main thread only.
pub fn apply_edit(&mut self, op: EditOp<'_>) -> Result<Edit, BufferError> {
self.ensure_writable()?;
if self.editing_in_progress {
return Err(BufferError::ConcurrentEdit {
id: self.id,
@ -732,6 +764,7 @@ impl Buffer {
/// ops in a CRDT-redundant edge case).
#[cfg(feature = "crdt")]
pub fn apply_remote_crdt_op(&mut self, op_bytes: &[u8]) -> Result<Option<Edit>, BufferError> {
self.ensure_writable()?;
if self.editing_in_progress {
return Err(BufferError::ConcurrentEdit {
id: self.id,
@ -942,6 +975,7 @@ impl Buffer {
reason = "by-value mirrors apply_edit's signature; the Lua bindings build a fresh EditOp per call"
)]
pub fn apply_edit_skip_intercepts(&mut self, op: EditOp<'_>) -> Result<Edit, BufferError> {
self.ensure_writable()?;
let mut views = std::mem::take(&mut self.views);
let result = self.run_rope_edit_and_broadcast(&mut views, &op);
self.views = views;
@ -1187,6 +1221,7 @@ impl Buffer {
///
/// Threading: main thread only.
pub fn undo(&mut self) -> Result<Edit, BufferError> {
self.ensure_writable()?;
// T M10.4: in CRDT mode, route through loro's UndoManager via
// the materialize-and-replace path (Day 1 morning audit
// decision — path (a)). Inverse ops are produced as proper
@ -1294,6 +1329,7 @@ impl Buffer {
///
/// Threading: main thread only.
pub fn redo(&mut self) -> Result<Edit, BufferError> {
self.ensure_writable()?;
// T M10.4: in CRDT mode, route through loro's UndoManager.
#[cfg(feature = "crdt")]
if self.crdt.is_some() {
@ -1675,6 +1711,15 @@ pub enum BufferError {
/// The underlying rope rejected the operation.
#[error("rope error: {0}")]
Rope(#[from] RopeError),
/// A content mutation was attempted on a buffer whose owner marked it
/// read-only. The check runs before all rope, CRDT, and history changes.
#[error("buffer `{name}` (id {id:?}) is read-only")]
ReadOnly {
/// The protected buffer.
id: BufferId,
/// Buffer name for user-facing diagnostics.
name: String,
},
/// `undo` was called with an empty undo stack.
#[error("nothing to undo")]
NothingToUndo,
@ -1824,6 +1869,99 @@ mod tests {
out
}
dual_mode_test!(
read_only_rejects_direct_skip_history_mutations,
|make, make_bytes| {
let mut buf = make_bytes("*read-only*", b"abc");
buf.apply_edit(EditOp::Insert {
pos: 3,
bytes: b"d",
})
.expect("seed undo history");
buf.undo().expect("seed redo history");
buf.set_read_only(true);
let before = (
collect(&buf),
buf.revision(),
buf.is_modified(),
buf.undo.len(),
buf.redo.len(),
);
assert!(matches!(
buf.begin_edit(),
Err(BufferError::ReadOnly { .. })
));
assert!(!buf.editing_in_progress());
let attempts = [
buf.apply_edit(EditOp::Insert {
pos: 0,
bytes: b"x",
}),
buf.apply_edit_skip_intercepts(EditOp::Replace {
range: Range::new(0, 1),
bytes: b"y",
}),
buf.undo(),
buf.redo(),
];
assert!(
attempts
.iter()
.all(|result| matches!(result, Err(BufferError::ReadOnly { .. })))
);
assert_eq!(
before,
(
collect(&buf),
buf.revision(),
buf.is_modified(),
buf.undo.len(),
buf.redo.len(),
)
);
// Keep the generated dual-mode factory used in both configurations.
drop(make("*unused*"));
}
);
#[cfg(feature = "crdt")]
#[test]
fn read_only_rejects_remote_crdt_before_import_and_allows_empty_bootstrap() {
let mut protected = Buffer::new(BufferId::next(), "*terminal*");
protected.set_read_only(true);
protected
.upgrade_to_crdt(1)
.expect("immutable empty CRDT bootstrap remains valid");
let before_snapshot = protected
.crdt_state()
.expect("CRDT attached")
.export_snapshot()
.expect("snapshot");
let donor = crate::crdt::CrdtState::new(2).expect("donor");
let version = donor.version();
donor.insert(0, "forged").expect("donor edit");
let op = donor.export_updates_since(&version).expect("remote op");
assert!(matches!(
protected.apply_remote_crdt_op(&op),
Err(BufferError::ReadOnly { .. })
));
assert!(protected.is_empty());
assert_eq!(protected.revision(), 0);
assert!(!protected.is_modified());
assert_eq!(
protected
.crdt_state()
.expect("CRDT attached")
.export_snapshot()
.expect("snapshot"),
before_snapshot
);
}
// A view that records every callback for assertions.
#[derive(Default)]
struct RecorderView {

View File

@ -64,6 +64,9 @@ pub struct EditorState {
/// Drop-time `shutdown` enforces SIGTERM-then-SIGKILL so editor
/// exit cannot leave zombies.
pub process_supervisor: crate::lua_bindings::SharedProcessSupervisor,
/// Terminal session registry. Shared with future terminal Lua bindings;
/// snapshots are owned so no screen borrow crosses editor/Lua/render work.
pub terminal_manager: crate::terminal::session::SharedTerminalManager,
/// LSP manager (T M4.5). Holds one [`crate::lsp::LspClient`] per
/// language server; rides on top of [`Self::process_supervisor`]
/// for spawn / I/O / restart. Constructed empty; user code
@ -133,6 +136,11 @@ impl Drop for EditorState {
/// stuck mid-handoff stays alive (bounded by its job), which is
/// still a ~15x improvement over leaking every pool whole.
fn drop(&mut self) {
{
let mut supervisor = self.process_supervisor.borrow_mut();
self.terminal_manager.borrow_mut().shutdown(&mut supervisor);
supervisor.shutdown();
}
self.async_runtime.shutdown_workers();
}
}
@ -239,6 +247,7 @@ impl EditorState {
// shutdown enforces no-zombie cleanup at editor exit.
let process_supervisor = crate::lua_bindings::make_process_supervisor(lua_host.lua())
.expect("install pmacs.process");
let terminal_manager = Rc::new(RefCell::new(crate::terminal::TerminalManager::new()));
// T M4.5 LSP manager. Wires onto the same supervisor so its
// spawn/restart/I/O machinery is shared with `pmacs.process.*`.
// The manager itself is reachable from Lua as `pmacs.lsp.*`.
@ -481,6 +490,7 @@ impl EditorState {
async_runtime,
syntax_registry,
process_supervisor,
terminal_manager,
lsp_manager,
font_pref,
mcp_manager,
@ -493,17 +503,35 @@ impl EditorState {
}
}
/// One pass of the process supervisor: drain pending I/O / exit
/// events and apply restart policies. Mirrors
/// [`Self::tick_async`]; the run loop calls both per iteration.
/// Transactionally open an internal Stage-1 terminal session.
///
/// Fires the `process.after-tick` hook (T M6.5) after the supervisor
/// tick releases its borrow. Lua subscribers typically own a
/// `{[process_id] = handle}` registry and drain events via
/// `pmacs.process.events_take(id)`; the REPL package
/// (`builtin/packages/repl/init.lua`) is the first such consumer.
/// No interactive Lua command is registered until a frontend can render
/// terminal snapshots. This Rust seam is used by headless acceptance and
/// future bindings.
pub fn open_terminal(
&mut self,
spec: crate::terminal::TerminalSpec,
) -> Result<crate::buffer::BufferId, crate::terminal::TerminalError> {
let mut manager = self.terminal_manager.borrow_mut();
let mut core = self.core.borrow_mut();
let mut supervisor = self.process_supervisor.borrow_mut();
manager.open(spec, &mut core, &mut supervisor)
}
/// One pass of the process supervisor and terminal-owned event drain.
///
/// Ordering is supervisor tick → terminal drain/prune →
/// `process.after-tick`. `TerminalManager` calls `take_events` only for its
/// own `ProcessId`s; existing Lua/LSP/MCP ownership remains unchanged.
pub fn tick_processes(&mut self) {
self.process_supervisor.borrow_mut().tick();
{
let mut supervisor = self.process_supervisor.borrow_mut();
supervisor.tick();
let mut manager = self.terminal_manager.borrow_mut();
manager.tick(&mut supervisor);
let core = self.core.borrow();
manager.prune(&core, &mut supervisor);
}
self.lua_host
.run_hook("process.after-tick", mlua::MultiValue::new());
}

View File

@ -131,6 +131,7 @@ pub mod state;
pub mod statusline;
pub mod symbol;
pub mod syntax;
pub mod terminal;
pub mod text_view;
pub mod transport;
pub mod view;

View File

@ -4912,6 +4912,10 @@ fn installed_package_to_lua(lua: &Lua, pkg: &InstalledPackage) -> mlua::Result<T
/// `bracketed_paste_begin` / `bracketed_paste_end` /
/// `alt_screen_enter` / `alt_screen_exit`: `{ kind=<name> }` only
/// - `set_title`: `{ kind="set_title", title=<string> }`
#[allow(
clippy::too_many_lines,
reason = "exhaustive wire-to-Lua conversion keeps every ANSI variant and field visible in one audited match"
)]
fn event_to_lua_table(lua: &Lua, ev: &crate::ansi::AnsiEvent) -> mlua::Result<Table> {
use crate::ansi::AnsiEvent;
let t = lua.create_table()?;
@ -4964,6 +4968,148 @@ fn event_to_lua_table(lua: &Lua, ev: &crate::ansi::AnsiEvent) -> mlua::Result<Ta
AnsiEvent::AlternateScreenExit => {
t.set("kind", "alt_screen_exit")?;
}
AnsiEvent::Bell => t.set("kind", "bell")?,
AnsiEvent::LineFeed => t.set("kind", "line_feed")?,
AnsiEvent::HorizontalTab => t.set("kind", "horizontal_tab")?,
AnsiEvent::SetTabStop => t.set("kind", "set_tab_stop")?,
AnsiEvent::ClearTabStop => t.set("kind", "clear_tab_stop")?,
AnsiEvent::ClearAllTabStops => t.set("kind", "clear_all_tab_stops")?,
AnsiEvent::CursorUp(count)
| AnsiEvent::CursorDown(count)
| AnsiEvent::CursorForward(count)
| AnsiEvent::CursorBackward(count)
| AnsiEvent::CursorNextLine(count)
| AnsiEvent::CursorPreviousLine(count)
| AnsiEvent::EraseCharacters(count)
| AnsiEvent::InsertCharacters(count)
| AnsiEvent::DeleteCharacters(count)
| AnsiEvent::InsertLines(count)
| AnsiEvent::DeleteLines(count)
| AnsiEvent::ScrollUp(count)
| AnsiEvent::ScrollDown(count) => {
let kind = match ev {
AnsiEvent::CursorUp(_) => "cursor_up",
AnsiEvent::CursorDown(_) => "cursor_down",
AnsiEvent::CursorForward(_) => "cursor_forward",
AnsiEvent::CursorBackward(_) => "cursor_backward",
AnsiEvent::CursorNextLine(_) => "cursor_next_line",
AnsiEvent::CursorPreviousLine(_) => "cursor_previous_line",
AnsiEvent::EraseCharacters(_) => "erase_characters",
AnsiEvent::InsertCharacters(_) => "insert_characters",
AnsiEvent::DeleteCharacters(_) => "delete_characters",
AnsiEvent::InsertLines(_) => "insert_lines",
AnsiEvent::DeleteLines(_) => "delete_lines",
AnsiEvent::ScrollUp(_) => "scroll_up",
AnsiEvent::ScrollDown(_) => "scroll_down",
_ => unreachable!("outer match restricts the event"),
};
t.set("kind", kind)?;
t.set("count", *count)?;
}
AnsiEvent::CursorHorizontalAbsolute(col) => {
t.set("kind", "cursor_horizontal_absolute")?;
t.set("col", *col)?;
}
AnsiEvent::CursorVerticalAbsolute(row) => {
t.set("kind", "cursor_vertical_absolute")?;
t.set("row", *row)?;
}
AnsiEvent::CursorPosition { row, col } => {
t.set("kind", "cursor_position")?;
t.set("row", *row)?;
t.set("col", *col)?;
}
AnsiEvent::EraseDisplay(mode) | AnsiEvent::EraseLineMode(mode) => {
t.set(
"kind",
if matches!(ev, AnsiEvent::EraseDisplay(_)) {
"erase_display"
} else {
"erase_line_mode"
},
)?;
t.set(
"mode",
match mode {
crate::ansi::EraseMode::ToEnd => "to_end",
crate::ansi::EraseMode::ToStart => "to_start",
crate::ansi::EraseMode::All => "all",
crate::ansi::EraseMode::Saved => "saved",
},
)?;
}
AnsiEvent::SetScrollingRegion { top, bottom } => {
t.set("kind", "set_scrolling_region")?;
t.set("top", *top)?;
t.set("bottom", *bottom)?;
}
AnsiEvent::SaveCursor => t.set("kind", "save_cursor")?,
AnsiEvent::RestoreCursor => t.set("kind", "restore_cursor")?,
AnsiEvent::AlternateScreen { mode, enabled } => {
t.set("kind", "alternate_screen")?;
t.set(
"mode",
match mode {
crate::ansi::AlternateScreenMode::Mode47 => 47,
crate::ansi::AlternateScreenMode::Mode1047 => 1047,
crate::ansi::AlternateScreenMode::Mode1049 => 1049,
},
)?;
t.set("enabled", *enabled)?;
}
AnsiEvent::SetMode { mode, enabled } => {
t.set("kind", "set_mode")?;
t.set(
"mode",
match mode {
crate::ansi::TerminalMode::Insert => "insert",
crate::ansi::TerminalMode::Origin => "origin",
crate::ansi::TerminalMode::AutoWrap => "auto_wrap",
crate::ansi::TerminalMode::ApplicationCursor => "application_cursor",
crate::ansi::TerminalMode::ApplicationKeypad => "application_keypad",
crate::ansi::TerminalMode::CursorVisible => "cursor_visible",
crate::ansi::TerminalMode::BracketedPaste => "bracketed_paste",
crate::ansi::TerminalMode::FocusReporting => "focus_reporting",
crate::ansi::TerminalMode::SynchronizedOutput => "synchronized_output",
crate::ansi::TerminalMode::MouseX10 => "mouse_x10",
crate::ansi::TerminalMode::MouseButton => "mouse_button",
crate::ansi::TerminalMode::MouseAny => "mouse_any",
crate::ansi::TerminalMode::MouseSgr => "mouse_sgr",
},
)?;
t.set("enabled", *enabled)?;
}
AnsiEvent::DesignateCharacterSet { slot, charset } => {
t.set("kind", "designate_character_set")?;
t.set(
"slot",
match slot {
crate::ansi::CharacterSetSlot::G0 => "g0",
crate::ansi::CharacterSetSlot::G1 => "g1",
},
)?;
t.set(
"charset",
match charset {
crate::ansi::CharacterSet::Ascii => "ascii",
crate::ansi::CharacterSet::DecSpecialGraphics => "dec_special_graphics",
},
)?;
}
AnsiEvent::ShiftOut => t.set("kind", "shift_out")?,
AnsiEvent::ShiftIn => t.set("kind", "shift_in")?,
AnsiEvent::DeviceRequest(request) => {
t.set("kind", "device_request")?;
t.set(
"request",
match request {
crate::ansi::DeviceRequest::PrimaryAttributes => "primary_attributes",
crate::ansi::DeviceRequest::SecondaryAttributes => "secondary_attributes",
crate::ansi::DeviceRequest::OperatingStatus => "operating_status",
crate::ansi::DeviceRequest::CursorPosition => "cursor_position",
},
)?;
}
}
Ok(t)
}
@ -7582,6 +7728,7 @@ fn lua_to_spec(table: &Table) -> mlua::Result<ProcessSpec> {
mode,
restart,
ansi_events,
ansi_profile: crate::ansi::AnsiParserProfile::LineOriented,
stdin,
group,
})
@ -7787,7 +7934,14 @@ pub fn install_process(lua: &Lua, supervisor: &SharedProcessSupervisor) -> mlua:
"list",
lua.create_function(move |lua, ()| {
let sup = s.borrow();
let ids: Vec<ProcessId> = sup.ids().collect();
let ids: Vec<ProcessId> = sup
.ids()
.filter(|id| {
sup.spec(*id).is_none_or(|spec| {
spec.ansi_profile == crate::ansi::AnsiParserProfile::LineOriented
})
})
.collect();
let out = lua.create_table_with_capacity(ids.len(), 0)?;
for (i, id) in ids.iter().enumerate() {
let row = lua.create_table_with_capacity(0, 3)?;

View File

@ -60,7 +60,7 @@ use crossbeam::channel::{self, Receiver, Sender};
use nix::sys::signal::Signal;
use nix::unistd::Pid;
use crate::ansi::{AnsiEvent, AnsiParser};
use crate::ansi::{AnsiEvent, AnsiParser, AnsiParserProfile};
// ---------------------------------------------------------------------------
// Identity and configuration
@ -216,6 +216,9 @@ pub struct ProcessSpec {
/// instead of raw stdout bytes. Opt-in so LSP and other byte-stream
/// consumers keep their existing stdout/stderr contract.
pub ansi_events: bool,
/// Compatibility profile for structured ANSI parsing. Ignored unless
/// `ansi_events` is true; ordinary process/Lua callers remain line-oriented.
pub ansi_profile: AnsiParserProfile,
/// Stdin disposition (pipe-mode only; rejected under PTY).
pub stdin: StdinMode,
/// Compile-mode group lifecycle (Q#CM3; pipe-mode only, rejected
@ -245,6 +248,7 @@ impl ProcessSpec {
mode: ProcessMode::Pipes,
restart: RestartPolicy::Never,
ansi_events: false,
ansi_profile: AnsiParserProfile::LineOriented,
stdin: StdinMode::Piped,
group: false,
}
@ -823,6 +827,23 @@ impl ProcessSupervisor {
/// crashes *after* spawn shows up as a [`Termination::Crashed`]
/// in the event stream, not as a return error.
pub fn spawn(&mut self, spec: ProcessSpec) -> Result<ProcessId, String> {
self.spawn_inner(spec, true)
}
/// Spawn an unpublished terminal-owned process.
///
/// Unlike the public Lua/process path, synchronous failure does not emit an
/// event for an ID no caller can own. `TerminalManager` rolls back its
/// temporary identity buffer and returns the error directly.
pub(crate) fn spawn_terminal(&mut self, spec: ProcessSpec) -> Result<ProcessId, String> {
self.spawn_inner(spec, false)
}
fn spawn_inner(
&mut self,
spec: ProcessSpec,
publish_synchronous_failure: bool,
) -> Result<ProcessId, String> {
if self.shut_down {
return Err("supervisor is shut down".to_owned());
}
@ -834,15 +855,20 @@ impl ProcessSupervisor {
attempt_count: 0,
next_restart_at: None,
};
self.start_generation(id, &mut managed)?;
self.start_generation(id, &mut managed, publish_synchronous_failure)?;
self.processes.insert(id, managed);
Ok(id)
}
/// Start a fresh generation for `managed`. Mutates `managed`
/// in place; on failure the state is left as
/// `Terminated(Crashed{...})` and an event is emitted.
fn start_generation(&self, id: ProcessId, managed: &mut ManagedProcess) -> Result<(), String> {
/// Start a fresh generation for `managed`. Mutates `managed` in place; on
/// failure its state is `Terminated(Crashed{...})`, and the event is emitted
/// only when `publish_failure` is true.
fn start_generation(
&self,
id: ProcessId,
managed: &mut ManagedProcess,
publish_failure: bool,
) -> Result<(), String> {
managed.attempt_count += 1;
managed.next_restart_at = None;
match build_runtime(&managed.spec, id) {
@ -865,11 +891,13 @@ impl ProcessSupervisor {
ended: now,
});
managed.runtime = None;
let _ = self.events_tx.send(ProcessEvent {
id,
kind: ProcessEventKind::Crashed { error: e.clone() },
at: now,
});
if publish_failure {
let _ = self.events_tx.send(ProcessEvent {
id,
kind: ProcessEventKind::Crashed { error: e.clone() },
at: now,
});
}
Err(e)
}
}
@ -1207,7 +1235,7 @@ impl ProcessSupervisor {
kind: ProcessEventKind::Restarting { attempt },
at: now,
});
let _ = self.start_generation(id, &mut managed);
let _ = self.start_generation(id, &mut managed, true);
self.processes.insert(id, managed);
} else {
// Schedule a restart attempt for `restart_backoff` from
@ -1547,7 +1575,12 @@ fn build_pty_runtime(
)];
let output_rx = if spec.ansi_events {
let (ansi_tx, ansi_rx) = channel::bounded::<AnsiBatch>(ANSI_EVENT_CHANNEL_CAP);
readers.push(spawn_ansi_parser(byte_rx, ansi_tx, Arc::clone(&cancel)));
readers.push(spawn_ansi_parser(
byte_rx,
ansi_tx,
Arc::clone(&cancel),
spec.ansi_profile,
));
RuntimeOutputRx::Ansi(ansi_rx)
} else {
RuntimeOutputRx::Bytes(byte_rx)
@ -1954,9 +1987,10 @@ fn spawn_ansi_parser(
byte_rx: Receiver<ByteChunk>,
ansi_tx: Sender<AnsiBatch>,
cancel: Arc<AtomicBool>,
profile: AnsiParserProfile,
) -> JoinHandle<()> {
std::thread::spawn(move || {
let mut parser = AnsiParser::new();
let mut parser = AnsiParser::with_profile(profile);
loop {
if cancel.load(Ordering::Relaxed) {
return;
@ -1964,31 +1998,44 @@ fn spawn_ansi_parser(
let (kind, bytes) = match byte_rx.recv_timeout(READER_SEND_POLL_INTERVAL) {
Ok(chunk) => chunk,
Err(crossbeam::channel::RecvTimeoutError::Timeout) => continue,
Err(crossbeam::channel::RecvTimeoutError::Disconnected) => return,
Err(crossbeam::channel::RecvTimeoutError::Disconnected) => {
let events = parser.finish();
if !events.is_empty() {
let _ = send_ansi_batch(&ansi_tx, &cancel, events);
}
return;
}
};
if !matches!(kind, ReaderKind::Stdout) {
continue;
}
let mut events = parser.feed(&bytes);
if events.is_empty() {
continue;
}
loop {
match ansi_tx.send_timeout(events, READER_SEND_POLL_INTERVAL) {
Ok(()) => break,
Err(crossbeam::channel::SendTimeoutError::Timeout(rejected)) => {
if cancel.load(Ordering::Relaxed) {
return;
}
events = rejected;
}
Err(crossbeam::channel::SendTimeoutError::Disconnected(_)) => return,
}
let events = parser.feed(&bytes);
if !events.is_empty() && !send_ansi_batch(&ansi_tx, &cancel, events) {
return;
}
}
})
}
fn send_ansi_batch(
ansi_tx: &Sender<AnsiBatch>,
cancel: &AtomicBool,
mut events: AnsiBatch,
) -> bool {
loop {
match ansi_tx.send_timeout(events, READER_SEND_POLL_INTERVAL) {
Ok(()) => return true,
Err(crossbeam::channel::SendTimeoutError::Timeout(rejected)) => {
if cancel.load(Ordering::Relaxed) {
return false;
}
events = rejected;
}
Err(crossbeam::channel::SendTimeoutError::Disconnected(_)) => return false,
}
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@ -2026,6 +2073,19 @@ mod tests {
})
}
#[test]
fn terminal_transactional_spawn_failure_has_no_event_or_process_residue() {
let mut supervisor = ProcessSupervisor::new();
let spec = ProcessSpec::new(
"unpublished-terminal",
"/definitely/not/a/real/pmacs-terminal-program",
);
assert!(supervisor.spawn_terminal(spec).is_err());
supervisor.tick();
assert_eq!(supervisor.ids().count(), 0);
assert!(supervisor.take_all_events().is_empty());
}
#[test]
fn spawn_pipes_lifecycle_started_then_exited() {
let mut sup = ProcessSupervisor::new();
@ -2618,7 +2678,12 @@ mod tests {
let (byte_tx, byte_rx) = channel::bounded::<ByteChunk>(1);
let (ansi_tx, _ansi_rx) = channel::bounded::<AnsiBatch>(1);
let cancel = Arc::new(AtomicBool::new(false));
let handle = spawn_ansi_parser(byte_rx, ansi_tx, Arc::clone(&cancel));
let handle = spawn_ansi_parser(
byte_rx,
ansi_tx,
Arc::clone(&cancel),
AnsiParserProfile::LineOriented,
);
drop(byte_tx);
let deadline = Instant::now() + Duration::from_millis(500);

366
src/terminal/input.rs Normal file
View File

@ -0,0 +1,366 @@
use crate::cell::CellCoord;
use crate::protocol::{Key, Modifiers, MouseButton, MouseKind};
use super::screen::{MouseTrackingMode, TerminalModes};
/// Encode one normalized key press for the child terminal.
///
/// Lock/media/unknown keys return `None`. Application-keypad mode is
/// intentionally not applied to `Key::Char` digits because the normalized
/// protocol cannot distinguish number-row and keypad input.
#[must_use]
pub fn encode_key(key: Key, mods: Modifiers, modes: TerminalModes) -> Option<Vec<u8>> {
if mods.contains(Modifiers::META) || mods.contains(Modifiers::HYPER) {
return None;
}
let alt = mods.contains(Modifiers::ALT);
let ctrl = mods.contains(Modifiers::CTRL);
let mut out = match key {
Key::Char(ch) => {
let mut bytes = Vec::with_capacity(4);
if ctrl {
bytes.push(control_byte(ch)?);
} else {
let mut encoded = [0; 4];
bytes.extend_from_slice(ch.encode_utf8(&mut encoded).as_bytes());
}
bytes
}
Key::Enter => vec![b'\r'],
Key::Tab => vec![b'\t'],
Key::Backspace => vec![0x7f],
Key::Escape => vec![0x1b],
Key::BackTab if mods == Modifiers::NONE || mods == Modifiers::SHIFT => b"\x1b[Z".to_vec(),
Key::BackTab => modified_csi(b'Z', mods, None),
Key::Up => navigation(b'A', mods, modes.application_cursor),
Key::Down => navigation(b'B', mods, modes.application_cursor),
Key::Right => navigation(b'C', mods, modes.application_cursor),
Key::Left => navigation(b'D', mods, modes.application_cursor),
Key::Home => navigation(b'H', mods, modes.application_cursor),
Key::End => navigation(b'F', mods, modes.application_cursor),
Key::Insert => tilde_key(2, mods),
Key::Delete => tilde_key(3, mods),
Key::PageUp => tilde_key(5, mods),
Key::PageDown => tilde_key(6, mods),
Key::F(n @ 1..=4) => function_1_to_4(n, mods),
Key::F(n @ 5..=12) => {
let code = [15, 17, 18, 19, 20, 21, 23, 24][usize::from(n - 5)];
tilde_key(code, mods)
}
Key::Null if ctrl => vec![0],
Key::F(_)
| Key::CapsLock
| Key::ScrollLock
| Key::NumLock
| Key::PrintScreen
| Key::Pause
| Key::Menu
| Key::KeypadBegin
| Key::Null
| Key::Unknown(_) => return None,
};
// Character/control/basic keys use the traditional ESC prefix for Alt.
// Named CSI keys encode Alt in their xterm modifier parameter already.
if alt
&& matches!(
key,
Key::Char(_) | Key::Enter | Key::Tab | Key::Backspace | Key::Escape
)
{
out.insert(0, 0x1b);
}
Some(out)
}
/// Encode pasted bytes, optionally framing them with bracketed-paste markers.
#[must_use]
pub fn encode_paste(bytes: &[u8], bracketed_paste: bool) -> Vec<u8> {
if !bracketed_paste {
return bytes.to_vec();
}
let mut out = Vec::with_capacity(bytes.len() + 12);
out.extend_from_slice(b"\x1b[200~");
out.extend_from_slice(bytes);
out.extend_from_slice(b"\x1b[201~");
out
}
/// Encode a focus transition when focus reporting is enabled.
#[must_use]
pub fn encode_focus(focused: bool, focus_reporting: bool) -> Option<Vec<u8>> {
focus_reporting.then(|| {
if focused {
b"\x1b[I".to_vec()
} else {
b"\x1b[O".to_vec()
}
})
}
/// Encode an xterm SGR mouse report using zero-based terminal coordinates.
#[must_use]
pub fn encode_mouse(
kind: MouseKind,
coord: CellCoord,
mods: Modifiers,
modes: TerminalModes,
) -> Option<Vec<u8>> {
if mods.contains(Modifiers::META) || mods.contains(Modifiers::HYPER) {
return None;
}
if !modes.mouse_sgr || modes.mouse_tracking == MouseTrackingMode::Off {
return None;
}
let allowed = match modes.mouse_tracking {
MouseTrackingMode::Off => false,
MouseTrackingMode::X10 => matches!(kind, MouseKind::Down(_)),
MouseTrackingMode::Button => !matches!(kind, MouseKind::Move),
MouseTrackingMode::Any => true,
};
if !allowed {
return None;
}
let (mut code, release) = match kind {
MouseKind::Down(button) => (button_code(button), false),
MouseKind::Up(_) => (3, true),
MouseKind::Drag(button) => (button_code(button) + 32, false),
MouseKind::Move => (35, false),
MouseKind::ScrollUp => (64, false),
MouseKind::ScrollDown => (65, false),
MouseKind::ScrollLeft => (66, false),
MouseKind::ScrollRight => (67, false),
};
if mods.contains(Modifiers::SHIFT) {
code += 4;
}
if mods.contains(Modifiers::ALT) {
code += 8;
}
if mods.contains(Modifiers::CTRL) {
code += 16;
}
let final_byte = if release { 'm' } else { 'M' };
Some(
format!(
"\x1b[<{code};{};{}{final_byte}",
coord.col.saturating_add(1),
coord.row.saturating_add(1)
)
.into_bytes(),
)
}
fn control_byte(ch: char) -> Option<u8> {
match ch {
'@' | ' ' | '`' => Some(0),
'a'..='z' => Some(ch as u8 - b'a' + 1),
'A'..='Z' => Some(ch as u8 - b'A' + 1),
'[' | '{' => Some(0x1b),
'\\' | '|' => Some(0x1c),
']' | '}' => Some(0x1d),
'^' | '~' => Some(0x1e),
'_' => Some(0x1f),
'?' => Some(0x7f),
_ => None,
}
}
fn navigation(final_byte: u8, mods: Modifiers, application: bool) -> Vec<u8> {
let parameter = modifier_parameter(mods);
if parameter == 1 {
vec![0x1b, if application { b'O' } else { b'[' }, final_byte]
} else {
modified_csi(final_byte, mods, None)
}
}
fn function_1_to_4(n: u8, mods: Modifiers) -> Vec<u8> {
let final_byte = b'P' + n - 1;
if modifier_parameter(mods) == 1 {
vec![0x1b, b'O', final_byte]
} else {
modified_csi(final_byte, mods, None)
}
}
fn tilde_key(code: u8, mods: Modifiers) -> Vec<u8> {
let modifier = modifier_parameter(mods);
if modifier == 1 {
format!("\x1b[{code}~").into_bytes()
} else {
format!("\x1b[{code};{modifier}~").into_bytes()
}
}
fn modified_csi(final_byte: u8, mods: Modifiers, first: Option<u8>) -> Vec<u8> {
let modifier = modifier_parameter(mods);
if modifier == 1 && first.is_none() {
return vec![0x1b, b'[', final_byte];
}
let first = first.unwrap_or(1);
format!("\x1b[{first};{modifier}{}", final_byte as char).into_bytes()
}
fn modifier_parameter(mods: Modifiers) -> u8 {
1 + u8::from(mods.contains(Modifiers::SHIFT))
+ 2 * u8::from(mods.contains(Modifiers::ALT))
+ 4 * u8::from(mods.contains(Modifiers::CTRL))
}
fn button_code(button: MouseButton) -> u8 {
match button {
MouseButton::Left => 0,
MouseButton::Middle => 1,
MouseButton::Right => 2,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn modes() -> TerminalModes {
TerminalModes::default()
}
#[test]
fn utf8_ctrl_and_alt_boundaries() {
assert_eq!(
encode_key(Key::Char('é'), Modifiers::NONE, modes()),
Some("é".as_bytes().to_vec())
);
assert_eq!(
encode_key(Key::Char('c'), Modifiers::CTRL, modes()),
Some(vec![3])
);
assert_eq!(
encode_key(Key::Char('?'), Modifiers::CTRL | Modifiers::ALT, modes()),
Some(vec![0x1b, 0x7f])
);
assert_eq!(encode_key(Key::Char('é'), Modifiers::CTRL, modes()), None);
}
#[test]
fn application_cursor_and_xterm_modifiers() {
let mut app = modes();
app.application_cursor = true;
assert_eq!(
encode_key(Key::Up, Modifiers::NONE, app),
Some(b"\x1bOA".to_vec())
);
assert_eq!(
encode_key(Key::Up, Modifiers::CTRL | Modifiers::SHIFT, app),
Some(b"\x1b[1;6A".to_vec())
);
assert_eq!(
encode_key(Key::Delete, Modifiers::ALT, modes()),
Some(b"\x1b[3;3~".to_vec())
);
assert_eq!(
encode_key(Key::F(1), Modifiers::NONE, modes()),
Some(b"\x1bOP".to_vec())
);
assert_eq!(
encode_key(Key::F(12), Modifiers::CTRL, modes()),
Some(b"\x1b[24;5~".to_vec())
);
assert_eq!(
encode_key(Key::BackTab, Modifiers::SHIFT, modes()),
Some(b"\x1b[Z".to_vec())
);
}
#[test]
fn ambiguous_digits_ignore_application_keypad() {
let mut app = modes();
app.application_keypad = true;
assert_eq!(
encode_key(Key::Char('7'), Modifiers::NONE, app),
Some(b"7".to_vec())
);
}
#[test]
fn paste_and_focus_are_exact() {
assert_eq!(encode_paste(b"a\0b", false), b"a\0b".to_vec());
assert_eq!(
encode_paste(b"a\0b", true),
b"\x1b[200~a\0b\x1b[201~".to_vec()
);
assert_eq!(encode_focus(true, true), Some(b"\x1b[I".to_vec()));
assert_eq!(encode_focus(false, true), Some(b"\x1b[O".to_vec()));
assert_eq!(encode_focus(true, false), None);
}
#[test]
fn sgr_mouse_modes_modifiers_and_coordinates() {
let mut m = modes();
m.mouse_sgr = true;
m.mouse_tracking = MouseTrackingMode::Any;
assert_eq!(
encode_mouse(
MouseKind::Down(MouseButton::Left),
CellCoord::new(0, 0),
Modifiers::NONE,
m
),
Some(b"\x1b[<0;1;1M".to_vec())
);
assert_eq!(
encode_mouse(
MouseKind::Drag(MouseButton::Right),
CellCoord::new(511, 511),
Modifiers::CTRL | Modifiers::ALT,
m
),
Some(b"\x1b[<58;512;512M".to_vec())
);
assert_eq!(
encode_mouse(
MouseKind::Up(MouseButton::Left),
CellCoord::new(4, 9),
Modifiers::NONE,
m
),
Some(b"\x1b[<3;10;5m".to_vec())
);
assert_eq!(
encode_mouse(
MouseKind::ScrollDown,
CellCoord::new(1, 2),
Modifiers::SHIFT,
m
),
Some(b"\x1b[<69;3;2M".to_vec())
);
}
#[test]
fn unsupported_keys_are_invisible() {
assert_eq!(encode_key(Key::Unknown(7), Modifiers::NONE, modes()), None);
assert_eq!(encode_key(Key::F(13), Modifiers::NONE, modes()), None);
assert_eq!(encode_key(Key::Char('c'), Modifiers::META, modes()), None);
assert_eq!(encode_key(Key::Up, Modifiers::HYPER, modes()), None);
let mut mouse_modes = modes();
mouse_modes.mouse_sgr = true;
mouse_modes.mouse_tracking = MouseTrackingMode::Any;
assert_eq!(
encode_mouse(
MouseKind::Down(MouseButton::Left),
CellCoord::new(0, 0),
Modifiers::META,
mouse_modes,
),
None,
);
assert_eq!(
encode_mouse(
MouseKind::Move,
CellCoord::new(4, 9),
Modifiers::HYPER,
mouse_modes,
),
None,
);
}
}

30
src/terminal/mod.rs Normal file
View File

@ -0,0 +1,30 @@
//! Stateful terminal core and process-session ownership.
//!
//! Terminal buffers are identity/lifecycle anchors. Visible contents live in
//! [`screen::TerminalScreen`] and are exposed as owned session snapshots.
/// Terminal input byte encoders.
pub mod input;
/// Stateful terminal screen model.
pub mod screen;
pub mod session;
pub use session::{
SharedTerminalManager, TerminalError, TerminalManager, TerminalProcessState,
TerminalSelectionSpan, TerminalSnapshot, TerminalSpec,
};
/// 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;
/// Default retained main-screen scrollback rows.
pub const DEFAULT_TERMINAL_SCROLLBACK_ROWS: usize = 10_000;
/// Maximum retained main-screen history cells.
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;

1945
src/terminal/screen.rs Normal file

File diff suppressed because it is too large Load Diff

599
src/terminal/session.rs Normal file
View File

@ -0,0 +1,599 @@
//! Terminal process/session registry.
use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::time::Instant;
use thiserror::Error;
use crate::ansi::AnsiParserProfile;
use crate::buffer::{Buffer, BufferId};
use crate::cell::{Cell, CellCoord, CellSize};
use crate::editor_core::EditorCore;
use crate::process::{
ProcessEventKind, ProcessId, ProcessMode, ProcessSpec, ProcessState, ProcessSupervisor,
RestartPolicy, StdinMode, TerminalMode,
};
use crate::terminal::screen::TerminalScreen;
use crate::terminal::{
MAX_TERMINAL_COLS, MAX_TERMINAL_HISTORY_CELLS, MAX_TERMINAL_METADATA_BYTES, MAX_TERMINAL_ROWS,
MAX_TERMINAL_VISIBLE_CELLS,
};
/// Shared single-owner terminal registry used by editor and future Lua bindings.
pub type SharedTerminalManager = Rc<RefCell<TerminalManager>>;
/// Complete owned description of a terminal child and its initial screen.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TerminalSpec {
/// Executable path or name resolved through `PATH`.
pub command: String,
/// Child arguments, excluding argv[0].
pub args: Vec<String>,
/// Working directory, or the editor process directory when absent.
pub cwd: Option<PathBuf>,
/// Environment overrides inherited by the child.
pub env: Vec<(String, String)>,
/// Identity-buffer name. Defaults to `*terminal:<command>*`.
pub name: Option<String>,
/// Initial terminal rows.
pub rows: u16,
/// Initial terminal columns.
pub cols: u16,
/// Retained main-screen scrollback row cap.
pub scrollback_rows: usize,
}
impl TerminalSpec {
/// Construct a conventional 24x80 terminal specification.
#[must_use]
pub fn new(command: impl Into<String>) -> Self {
Self {
command: command.into(),
args: Vec::new(),
cwd: None,
env: Vec::new(),
name: None,
rows: 24,
cols: 80,
scrollback_rows: crate::terminal::DEFAULT_TERMINAL_SCROLLBACK_ROWS,
}
}
/// Validate every raw field before any buffer or process is created.
pub fn validate(&self) -> Result<(), TerminalError> {
if self.command.is_empty() {
return Err(TerminalError::InvalidSpec(
"command must not be empty".into(),
));
}
reject_nul("command", self.command.as_bytes())?;
for arg in &self.args {
reject_nul("argument", arg.as_bytes())?;
}
if let Some(cwd) = &self.cwd {
if cwd.as_os_str().is_empty() {
return Err(TerminalError::InvalidSpec(
"cwd must not be an empty path".into(),
));
}
reject_nul("cwd", cwd.as_os_str().as_encoded_bytes())?;
}
let mut env_names = HashSet::with_capacity(self.env.len());
for (name, value) in &self.env {
if name.is_empty() || name.contains('=') {
return Err(TerminalError::InvalidSpec(format!(
"environment name {name:?} must be non-empty and contain no '='"
)));
}
reject_nul("environment name", name.as_bytes())?;
reject_nul("environment value", value.as_bytes())?;
if !env_names.insert(name) {
return Err(TerminalError::InvalidSpec(format!(
"duplicate environment name {name:?}"
)));
}
}
if let Some(name) = &self.name {
if name.is_empty() {
return Err(TerminalError::InvalidSpec(
"buffer name must not be empty".into(),
));
}
reject_nul("buffer name", name.as_bytes())?;
if name.contains(['\r', '\n']) {
return Err(TerminalError::InvalidSpec(
"buffer name must fit on one line".into(),
));
}
}
validate_size(self.rows, self.cols)?;
if self.scrollback_rows > MAX_TERMINAL_HISTORY_CELLS {
return Err(TerminalError::InvalidSpec(format!(
"scrollback row cap {} exceeds terminal history cell budget {}",
self.scrollback_rows, MAX_TERMINAL_HISTORY_CELLS
)));
}
Ok(())
}
fn buffer_name(&self) -> String {
self.name.clone().unwrap_or_else(|| {
let command = Path::new(&self.command)
.file_name()
.and_then(|name| name.to_str())
.filter(|name| !name.is_empty())
.unwrap_or(self.command.as_str());
format!("*terminal:{command}*")
})
}
}
/// 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 {
/// Identity buffer backing this terminal.
pub buffer_id: BufferId,
/// Visible grid dimensions.
pub size: CellSize,
/// Row-major visible cells.
pub cells: Vec<Cell>,
/// Visible child cursor, if enabled.
pub cursor: Option<CellCoord>,
/// Sanitized child title.
pub title: Option<String>,
/// Published screen generation.
pub screen_generation: u64,
/// Context selection. Empty in context-free Stage 1 snapshots.
pub selection: Vec<TerminalSelectionSpan>,
/// Context scrollback offset. Zero in Stage 1 snapshots.
pub scroll_offset: u32,
/// Whether this context follows the bottom. Always true in Stage 1.
pub at_bottom: bool,
/// Exact operating-system process id for this session generation.
pub pid: u32,
/// Latest observed process state.
pub process: TerminalProcessState,
}
/// Terminal session/registry failures.
#[derive(Debug, Error)]
pub enum TerminalError {
/// Specification validation failed before creation began.
#[error("invalid terminal specification: {0}")]
InvalidSpec(String),
/// The synchronous PTY spawn failed; no session is published.
#[error("terminal spawn failed: {0}")]
Spawn(String),
/// Buffer registry work failed during transactional creation.
#[error("terminal buffer operation failed: {0}")]
Buffer(String),
/// Screen construction or resize failed.
#[error("terminal screen operation failed: {0}")]
Screen(String),
/// No session owns the requested identity buffer.
#[error("buffer {0:?} is not a terminal")]
NotTerminal(BufferId),
/// Process I/O, resize, signal, or cleanup failed.
#[error("terminal process operation failed: {0}")]
Process(String),
}
struct TerminalSession {
process_id: ProcessId,
pid: u32,
screen: TerminalScreen,
process: TerminalProcessState,
annotated: bool,
}
/// Owns the one-buffer/one-process/one-screen terminal registry.
#[derive(Default)]
pub struct TerminalManager {
sessions: HashMap<BufferId, TerminalSession>,
process_to_buffer: HashMap<ProcessId, BufferId>,
/// Removed buffers whose children are still being reaped. Their events
/// remain manager-owned so Lua/LSP/MCP consumers cannot steal a batch.
closing: HashSet<ProcessId>,
}
impl TerminalManager {
/// Construct an empty manager.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Number of published terminal sessions.
#[must_use]
pub fn len(&self) -> usize {
self.sessions.len()
}
/// Whether no terminal session is currently published.
#[must_use]
pub fn is_empty(&self) -> bool {
self.sessions.is_empty()
}
/// Transactionally create an internal terminal identity, PTY, and screen.
pub fn open(
&mut self,
spec: TerminalSpec,
core: &mut EditorCore,
supervisor: &mut ProcessSupervisor,
) -> Result<BufferId, TerminalError> {
spec.validate()?;
let size = CellSize::new(u32::from(spec.rows), u32::from(spec.cols));
let screen = TerminalScreen::new(size, spec.scrollback_rows)
.map_err(|error| TerminalError::Screen(error.to_string()))?;
let buffer_name = spec.buffer_name();
let buffer_id = BufferId::next();
let mut buffer = Buffer::new(buffer_id, buffer_name.clone());
buffer.set_read_only(true);
core.registry.borrow_mut().insert(buffer);
let mut process_spec = ProcessSpec::new(buffer_name, spec.command);
process_spec.args = spec.args;
process_spec.cwd = spec.cwd;
process_spec.env = spec.env;
process_spec.mode = ProcessMode::Pty {
rows: spec.rows,
cols: spec.cols,
mode: TerminalMode::Raw,
};
process_spec.restart = RestartPolicy::Never;
process_spec.ansi_events = true;
process_spec.ansi_profile = AnsiParserProfile::FullScreen;
process_spec.stdin = StdinMode::Piped;
process_spec.group = false;
let process_id = match supervisor.spawn_terminal(process_spec) {
Ok(id) => id,
Err(error) => {
core.registry
.borrow_mut()
.remove(buffer_id)
.map_err(|rollback| {
TerminalError::Buffer(format!(
"spawn failed ({error}); buffer rollback failed: {rollback}"
))
})?;
return Err(TerminalError::Spawn(error));
}
};
let pid =
if let Some(ProcessState::Running { pid, .. } | ProcessState::Exiting { pid, .. }) =
supervisor.state(process_id)
{
*pid
} else {
let _ = supervisor.terminate(process_id);
let _ = core.registry.borrow_mut().remove(buffer_id);
return Err(TerminalError::Spawn(
"supervisor published a PTY without a running pid".into(),
));
};
let previous = self.sessions.insert(
buffer_id,
TerminalSession {
process_id,
pid,
screen,
process: TerminalProcessState::Running,
annotated: false,
},
);
debug_assert!(previous.is_none(), "fresh BufferId collided");
self.process_to_buffer.insert(process_id, buffer_id);
core.set_round_trip_input(buffer_id, true);
Ok(buffer_id)
}
/// Whether `buffer_id` identifies a published terminal session.
#[must_use]
pub fn is_terminal(&self, buffer_id: BufferId) -> bool {
self.sessions.contains_key(&buffer_id)
}
/// Owned process id for a terminal buffer. The OS pid stays in snapshots.
#[must_use]
pub fn process_id(&self, buffer_id: BufferId) -> Option<ProcessId> {
self.sessions
.get(&buffer_id)
.map(|session| session.process_id)
}
/// Capture context-free owned visible state after the latest tick.
#[must_use]
pub fn snapshot(&self, buffer_id: BufferId) -> Option<TerminalSnapshot> {
let session = self.sessions.get(&buffer_id)?;
let screen = session.screen.snapshot();
Some(TerminalSnapshot {
buffer_id,
size: screen.size,
cells: screen.cells,
cursor: screen.cursor,
title: screen.title.map(|title| sanitize_metadata(&title)),
screen_generation: screen.generation,
selection: Vec::new(),
scroll_offset: 0,
at_bottom: true,
pid: session.pid,
process: session.process.clone(),
})
}
/// Drain only terminal-owned process IDs after the supervisor tick.
pub fn tick(&mut self, supervisor: &mut ProcessSupervisor) {
let process_ids: Vec<ProcessId> = self.process_to_buffer.keys().copied().collect();
for process_id in process_ids {
let Some(buffer_id) = self.process_to_buffer.get(&process_id).copied() else {
continue;
};
let events = supervisor.take_events(process_id);
let Some(session) = self.sessions.get_mut(&buffer_id) else {
continue;
};
let mut outcome = None;
for event in events {
match event.kind {
ProcessEventKind::Started { pid } => session.pid = pid,
ProcessEventKind::Ansi(events) => {
for event in events {
if let Some(response) = session.screen.apply_event(event) {
let _ = supervisor.write_stdin(process_id, &response);
}
}
}
ProcessEventKind::Exited { code } => {
outcome = Some(TerminalProcessState::Exited(code));
}
ProcessEventKind::Signaled { signal } => {
outcome = Some(TerminalProcessState::Signaled(sanitize_metadata(&signal)));
}
ProcessEventKind::Crashed { error } => {
outcome = Some(TerminalProcessState::Crashed(sanitize_metadata(&error)));
}
ProcessEventKind::Stdout(_)
| ProcessEventKind::Stderr(_)
| ProcessEventKind::Restarting { .. } => {}
}
}
let _ = session.screen.synchronized_watchdog_expired(Instant::now());
if let Some(outcome) = outcome {
finish_session(session, outcome);
}
}
let closing: Vec<ProcessId> = self.closing.iter().copied().collect();
for process_id in closing {
// Continue owning and discarding every final batch until reaped.
let _ = supervisor.take_events(process_id);
if matches!(
supervisor.state(process_id),
Some(ProcessState::Terminated(_)) | None
) {
let _ = supervisor.forget(process_id);
self.closing.remove(&process_id);
}
}
}
/// Queue raw terminal input for a running child.
pub fn send(
&self,
buffer_id: BufferId,
bytes: &[u8],
supervisor: &mut ProcessSupervisor,
) -> Result<(), TerminalError> {
let session = self
.sessions
.get(&buffer_id)
.ok_or(TerminalError::NotTerminal(buffer_id))?;
supervisor
.write_stdin(session.process_id, bytes)
.map_err(TerminalError::Process)
}
/// Resize a terminal screen and its PTY after validating shared limits.
pub fn resize(
&mut self,
buffer_id: BufferId,
rows: u16,
cols: u16,
supervisor: &mut ProcessSupervisor,
) -> Result<(), TerminalError> {
validate_size(rows, cols)?;
let session = self
.sessions
.get_mut(&buffer_id)
.ok_or(TerminalError::NotTerminal(buffer_id))?;
if matches!(session.process, TerminalProcessState::Running) {
supervisor
.resize_pty(session.process_id, rows, cols)
.map_err(TerminalError::Process)?;
}
session
.screen
.resize(CellSize::new(u32::from(rows), u32::from(cols)))
.map_err(|error| TerminalError::Screen(error.to_string()))
}
/// Request SIGTERM. Snapshot state stays `Running` until the outcome event.
pub fn terminate(
&mut self,
buffer_id: BufferId,
supervisor: &mut ProcessSupervisor,
) -> Result<(), TerminalError> {
let session = self
.sessions
.get(&buffer_id)
.ok_or(TerminalError::NotTerminal(buffer_id))?;
if matches!(session.process, TerminalProcessState::Running) {
supervisor
.terminate(session.process_id)
.map_err(TerminalError::Process)?;
}
Ok(())
}
/// Tear down sessions whose identity buffers were removed by any path.
pub fn prune(&mut self, core: &EditorCore, supervisor: &mut ProcessSupervisor) {
let removed: Vec<BufferId> = {
let registry = core.registry.borrow();
self.sessions
.keys()
.copied()
.filter(|buffer_id| !registry.contains(*buffer_id))
.collect()
};
for buffer_id in removed {
let Some(session) = self.sessions.remove(&buffer_id) else {
continue;
};
self.process_to_buffer.remove(&session.process_id);
match supervisor.state(session.process_id) {
Some(
ProcessState::Starting
| ProcessState::Running { .. }
| ProcessState::Exiting { .. },
) => {
let _ = supervisor.terminate(session.process_id);
self.closing.insert(session.process_id);
}
Some(ProcessState::Terminated(_)) => {
let _ = supervisor.take_events(session.process_id);
let _ = supervisor.forget(session.process_id);
}
None => {}
}
}
}
/// Terminate every terminal child and unpublish all sessions.
///
/// The editor follows this with the supervisor's bounded global shutdown,
/// which performs final TERM/KILL escalation for terminal and non-terminal
/// processes alike.
pub fn shutdown(&mut self, supervisor: &mut ProcessSupervisor) {
let process_ids: Vec<ProcessId> = self.process_to_buffer.keys().copied().collect();
for process_id in process_ids {
if matches!(
supervisor.state(process_id),
Some(
ProcessState::Running { .. }
| ProcessState::Exiting { .. }
| ProcessState::Starting
)
) {
let _ = supervisor.terminate(process_id);
}
self.closing.insert(process_id);
}
self.sessions.clear();
self.process_to_buffer.clear();
}
}
fn finish_session(session: &mut TerminalSession, outcome: TerminalProcessState) {
if session.annotated {
session.process = outcome;
return;
}
session.screen.finish_output();
let annotation = match &outcome {
TerminalProcessState::Running => return,
TerminalProcessState::Exited(0) => {
format!("Process {} exited normally with code 0", session.pid)
}
TerminalProcessState::Exited(code) => {
format!("Process {} exited abnormally with code {code}", session.pid)
}
TerminalProcessState::Signaled(signal) => format!(
"Process {} exited abnormally with signal {signal}",
session.pid
),
TerminalProcessState::Crashed(error) => {
format!("Process {} crashed: {error}", session.pid)
}
};
session.screen.append_process_annotation(&annotation);
session.annotated = true;
// Publish exit metadata only after final bytes and annotation are applied.
session.process = outcome;
}
fn validate_size(rows: u16, cols: u16) -> Result<(), TerminalError> {
let cells = usize::from(rows) * usize::from(cols);
if rows == 0 || rows > MAX_TERMINAL_ROWS {
return Err(TerminalError::InvalidSpec(format!(
"rows must be in 1..={MAX_TERMINAL_ROWS}; got {rows}"
)));
}
if cols == 0 || cols > MAX_TERMINAL_COLS {
return Err(TerminalError::InvalidSpec(format!(
"cols must be in 1..={MAX_TERMINAL_COLS}; got {cols}"
)));
}
if cells > MAX_TERMINAL_VISIBLE_CELLS {
return Err(TerminalError::InvalidSpec(format!(
"visible cell count {cells} exceeds {MAX_TERMINAL_VISIBLE_CELLS}"
)));
}
Ok(())
}
fn reject_nul(field: &str, bytes: &[u8]) -> Result<(), TerminalError> {
if bytes.contains(&0) {
Err(TerminalError::InvalidSpec(format!(
"{field} must not contain NUL"
)))
} else {
Ok(())
}
}
fn sanitize_metadata(value: &str) -> String {
let mut clean = String::with_capacity(value.len().min(MAX_TERMINAL_METADATA_BYTES));
for ch in value.chars() {
let ch = if ch == '\r' || ch == '\n' || ch.is_control() {
' '
} else {
ch
};
if clean.len() + ch.len_utf8() > MAX_TERMINAL_METADATA_BYTES {
break;
}
clean.push(ch);
}
clean
}

View File

@ -0,0 +1,422 @@
//! Shared Stage 1 terminal registry, lifecycle, and read-only acceptance.
use std::time::{Duration, Instant};
use pmacs::buffer::{Buffer, BufferError, BufferId, EditOp};
use pmacs::cell::Glyph;
use pmacs::editor::EditorState;
use pmacs::process::ProcessState;
use pmacs::rope::Range;
use pmacs::terminal::{TerminalProcessState, TerminalSpec};
fn rope_bytes(buffer: &Buffer) -> Vec<u8> {
let mut bytes = vec![0; buffer.len() as usize];
if !bytes.is_empty() {
buffer.snapshot_rope().slice(0, buffer.len(), &mut bytes);
}
bytes
}
fn screen_text(snapshot: &pmacs::terminal::TerminalSnapshot) -> String {
let mut text = String::new();
for (index, cell) in snapshot.cells.iter().enumerate() {
if index > 0 && index % snapshot.size.cols as usize == 0 {
text.push('\n');
}
match &cell.glyph {
Glyph::Char(ch) => text.push(*ch),
Glyph::Cluster(bytes) => text.push_str(&String::from_utf8_lossy(bytes)),
Glyph::Continuation => {}
}
}
text
}
fn tick_until(
state: &mut EditorState,
timeout: Duration,
mut done: impl FnMut(&EditorState) -> bool,
) {
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
state.tick_processes();
if done(state) {
return;
}
std::thread::sleep(Duration::from_millis(5));
}
panic!("terminal condition did not settle before {timeout:?}");
}
#[test]
fn spawn_failure_is_transactional() {
let mut state = EditorState::new();
let buffers_before = state.core.borrow().registry.borrow().len();
let processes_before = state.process_supervisor.borrow().ids().count();
state.process_supervisor.borrow_mut().shutdown();
let result = state.open_terminal(TerminalSpec::new("/bin/sh"));
assert!(result.is_err());
assert_eq!(state.core.borrow().registry.borrow().len(), buffers_before);
assert_eq!(state.terminal_manager.borrow().len(), 0);
assert_eq!(
state.process_supervisor.borrow().ids().count(),
processes_before
);
}
#[test]
fn strict_owned_spec_rejects_before_spawn_and_is_mutation_independent() {
let mut state = EditorState::new();
let buffers_before = state.core.borrow().registry.borrow().len();
let mut invalid = TerminalSpec::new("/bin/sh");
invalid.rows = 0;
assert!(state.open_terminal(invalid).is_err());
assert_eq!(state.core.borrow().registry.borrow().len(), buffers_before);
assert!(state.terminal_manager.borrow().is_empty());
assert_eq!(state.process_supervisor.borrow().ids().count(), 0);
let mut spec = TerminalSpec::new("/bin/sh");
spec.args = vec!["-c".into(), "sleep 30".into()];
spec.env = vec![("PMACS_VTERM_OWNED".into(), "original".into())];
let mut caller_copy = spec.clone();
let buffer_id = state.open_terminal(spec).expect("valid owned spec");
caller_copy.command.clear();
caller_copy.args.clear();
caller_copy.env[0].1 = "mutated".into();
let lua_processes: usize = state
.lua_host
.lua()
.load("return #pmacs.process.list()")
.eval()
.expect("process list");
assert_eq!(
lua_processes, 0,
"terminal-owned ProcessId must not be exposed through pmacs.process"
);
let terminal_module_absent: bool = state
.lua_host
.lua()
.load("return pmacs.terminal == nil")
.eval()
.expect("terminal module absence");
assert!(
terminal_module_absent,
"Stage 1 must not publish an unrenderable interactive Lua terminal API"
);
let process_id = state
.terminal_manager
.borrow()
.process_id(buffer_id)
.expect("terminal process");
let supervisor = state.process_supervisor.borrow();
let process_spec = supervisor.spec(process_id).expect("owned process spec");
assert_eq!(process_spec.command, "/bin/sh");
assert_eq!(
process_spec.args,
[String::from("-c"), String::from("sleep 30")]
);
assert_eq!(
process_spec.env,
[(String::from("PMACS_VTERM_OWNED"), String::from("original"))]
);
}
#[test]
fn read_only_guard_covers_direct_skip_undo_and_redo_without_state_change() {
let mut buffer = Buffer::from_bytes(BufferId::next(), "*protected*", b"abc");
buffer
.apply_edit(EditOp::Insert {
pos: 3,
bytes: b"d",
})
.expect("seed undo");
buffer.undo().expect("seed redo");
buffer.set_read_only(true);
let before = (rope_bytes(&buffer), buffer.revision(), buffer.is_modified());
assert!(matches!(
buffer.begin_edit(),
Err(BufferError::ReadOnly { .. })
));
assert!(!buffer.editing_in_progress());
let results = [
buffer.apply_edit(EditOp::Insert {
pos: 0,
bytes: b"x",
}),
buffer.apply_edit_skip_intercepts(EditOp::Replace {
range: Range::new(0, 1),
bytes: b"y",
}),
buffer.undo(),
buffer.redo(),
];
assert!(
results
.iter()
.all(|result| matches!(result, Err(BufferError::ReadOnly { .. })))
);
assert_eq!(
before,
(rope_bytes(&buffer), buffer.revision(), buffer.is_modified())
);
}
#[cfg(feature = "crdt")]
#[test]
fn read_only_empty_crdt_bootstrap_is_immutable_against_remote_content() {
let mut buffer = Buffer::new(BufferId::next(), "*terminal*");
buffer.set_read_only(true);
buffer
.upgrade_to_crdt(1)
.expect("empty immutable bootstrap is allowed");
let donor = pmacs::crdt::CrdtState::new(2).expect("donor");
let version = donor.version();
donor.insert(0, "forged").expect("donor edit");
let update = donor.export_updates_since(&version).expect("update");
assert!(matches!(
buffer.apply_remote_crdt_op(&update),
Err(BufferError::ReadOnly { .. })
));
assert!(buffer.is_empty());
assert_eq!(buffer.revision(), 0);
assert!(!buffer.is_modified());
}
#[test]
fn final_output_precedes_exact_nonzero_annotation_and_buffer_is_retained() {
let mut state = EditorState::new();
let mut spec = TerminalSpec::new("/bin/sh");
spec.args = vec![
"-c".into(),
concat!(
"printf 'main-home'; ",
"printf '\\033'; sleep 0.03; printf '[?1049h'; ",
"printf '\\033[2;'; sleep 0.03; printf '4HALT'; ",
"IFS= read -r gate; ",
"printf '\\033[?1049l'; ",
"printf '\\033[2;'; sleep 0.03; printf '3Hfinal-'; ",
"sleep 0.03; printf 'output'; exit 7"
)
.into(),
];
spec.rows = 8;
spec.cols = 80;
let buffer_id = state.open_terminal(spec).expect("open terminal");
tick_until(&mut state, Duration::from_secs(5), |state| {
state
.terminal_manager
.borrow()
.snapshot(buffer_id)
.is_some_and(|snapshot| {
matches!(snapshot.process, TerminalProcessState::Running)
&& screen_text(&snapshot)
.lines()
.nth(1)
.is_some_and(|row| row.starts_with(" ALT"))
})
});
let alternate = state
.terminal_manager
.borrow()
.snapshot(buffer_id)
.expect("running alternate-screen snapshot");
let alternate_text = screen_text(&alternate);
assert!(alternate_text.contains("ALT"));
assert!(
!alternate_text.contains("main-home"),
"alternate screen must not expose the preserved main grid"
);
{
let manager = state.terminal_manager.borrow();
let mut supervisor = state.process_supervisor.borrow_mut();
manager
.send(buffer_id, b"\n", &mut supervisor)
.expect("raw stdin unblocks child");
}
tick_until(&mut state, Duration::from_secs(5), |state| {
state
.terminal_manager
.borrow()
.snapshot(buffer_id)
.is_some_and(|snapshot| matches!(snapshot.process, TerminalProcessState::Exited(7)))
});
let snapshot = state
.terminal_manager
.borrow()
.snapshot(buffer_id)
.expect("retained terminal snapshot");
let text = screen_text(&snapshot);
assert!(
text.contains("main-home"),
"leaving alternate screen must restore the main grid"
);
assert!(
!text.contains("ALT"),
"alternate-screen output must not enter the retained main grid"
);
assert!(
text.lines()
.nth(1)
.is_some_and(|row| row.starts_with(" final-output")),
"FullScreen parser/profile must honor CSI cursor addressing"
);
let output_at = text
.find("final-output")
.expect("final child output visible");
let annotation = format!("Process {} exited abnormally with code 7", snapshot.pid);
let annotation_at = text
.find(&annotation)
.expect("exact exit annotation visible");
assert!(
output_at < annotation_at,
"final output must precede annotation"
);
assert!(state.core.borrow().registry.borrow().contains(buffer_id));
let core = state.core.borrow();
let registry = core.registry.borrow();
let buffer = registry.get(buffer_id).expect("identity buffer retained");
assert!(buffer.is_read_only());
assert!(buffer.is_empty());
assert!(!buffer.is_modified());
}
#[test]
fn normal_and_signal_annotations_use_exact_pid_and_outcome() {
for (script, expected, annotation_tail) in [
(
"printf normal-output; exit 0",
TerminalProcessState::Exited(0),
"exited normally with code 0",
),
(
"printf signal-output; kill -TERM $$",
TerminalProcessState::Signaled("SIGTERM".into()),
"exited abnormally with signal SIGTERM",
),
] {
let mut state = EditorState::new();
let mut spec = TerminalSpec::new("/bin/sh");
spec.args = vec!["-c".into(), script.into()];
spec.rows = 6;
spec.cols = 80;
let buffer_id = state.open_terminal(spec).expect("open terminal");
tick_until(&mut state, Duration::from_secs(5), |state| {
state
.terminal_manager
.borrow()
.snapshot(buffer_id)
.is_some_and(|snapshot| snapshot.process == expected)
});
let snapshot = state
.terminal_manager
.borrow()
.snapshot(buffer_id)
.expect("snapshot retained");
assert!(
screen_text(&snapshot).contains(&format!("Process {} {annotation_tail}", snapshot.pid)),
"missing exact annotation for {:?}",
snapshot.process
);
}
}
#[test]
fn killing_terminal_buffer_prunes_session_and_reaps_owned_process() {
let mut state = EditorState::new();
let mut spec = TerminalSpec::new("/bin/sh");
spec.args = vec!["-c".into(), "sleep 30".into()];
let buffer_id = state.open_terminal(spec).expect("open terminal");
let process_id = state
.terminal_manager
.borrow()
.process_id(buffer_id)
.expect("owned process");
state
.core
.borrow_mut()
.kill_buffer(buffer_id)
.expect("kill identity buffer");
state.tick_processes();
assert!(!state.terminal_manager.borrow().is_terminal(buffer_id));
tick_until(&mut state, Duration::from_secs(5), |state| {
state
.process_supervisor
.borrow()
.state(process_id)
.is_none()
});
}
#[test]
fn editor_shutdown_kills_term_ignoring_terminal_child() {
let pid = {
let mut state = EditorState::new();
state
.process_supervisor
.borrow_mut()
.set_grace_period(Duration::from_millis(50));
let mut spec = TerminalSpec::new("/bin/sh");
spec.args = vec![
"-c".into(),
"trap '' TERM; while :; do sleep 1; done".into(),
];
let buffer_id = state.open_terminal(spec).expect("open terminal");
state
.terminal_manager
.borrow()
.snapshot(buffer_id)
.expect("snapshot")
.pid
};
let deadline = Instant::now() + Duration::from_secs(2);
let proc_path = format!("/proc/{pid}");
while Instant::now() < deadline && std::path::Path::new(&proc_path).exists() {
std::thread::sleep(Duration::from_millis(10));
}
assert!(
!std::path::Path::new(&proc_path).exists(),
"terminal child {pid} survived EditorState shutdown"
);
}
#[test]
fn terminal_tick_does_not_take_non_terminal_process_events() {
let mut state = EditorState::new();
let mut process = pmacs::process::ProcessSpec::new("ordinary", "/bin/sh");
process.args = vec!["-c".into(), "printf ordinary".into()];
let ordinary_id = state
.process_supervisor
.borrow_mut()
.spawn(process)
.expect("ordinary process");
tick_until(&mut state, Duration::from_secs(5), |state| {
matches!(
state.process_supervisor.borrow().state(ordinary_id),
Some(ProcessState::Terminated(_))
)
});
let events = state
.process_supervisor
.borrow_mut()
.take_events(ordinary_id);
assert!(
events.iter().any(|event| matches!(
&event.kind,
pmacs::process::ProcessEventKind::Stdout(bytes) if bytes == b"ordinary"
)),
"TerminalManager must not steal ordinary process output"
);
}