Merge pull request #126 from levineuwirth/vterm-core

feat(vterm): add Stage 1 terminal core
This commit is contained in:
Levi Neuwirth 2026-07-21 20:54:30 +00:00 committed by GitHub
commit 643d1e1fcc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 5670 additions and 243 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, after Vterm Stage 1 review round 2 was addressed
and fully gated on `vterm-core` (awaiting merge authorization; 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
@ -11,10 +11,8 @@ next machine reads it the way you just did.
## 1. Where the project stands (2026-07-21)
- Canonical `main` @ `bb17ec9` (#123 merged atop #124), protocol
**v17** (`SUPPORTED=[6..17]`). The rebased `statusline-segments`
branch implements protocol v18, but v18 is **not on main** until
review and merge.
- Canonical `main` @ `7bc0c61` (#125 merged), protocol
**v18** (`SUPPORTED=[6..18]`).
- **Syntax-highlight / language-detection side-quest (#114#118)
LANDED** — a one-shot arc built in sibling worktrees off main while
the user's themes lane (`theme-faces`) ran concurrently in the shared
@ -100,8 +98,7 @@ next machine reads it the way you just did.
`pmacs.editor.take_typed_edit()` (buffer-revision postcondition,
Q#AP9). Substrate: `buf:path()`, `pmacs.lsp.buffer_language(buf)`,
`PMACS_FAKE_LSP_CHANGE_SINK`, `TestDaemon::spawn_with_config`.
- **Themes (Arc 4) stages 1 and 2 LANDED; stage 3 IMPLEMENTED ON ITS
FEATURE BRANCH, AWAITING REVIEW.**
- **Themes (Arc 4) stages 13 LANDED; Arc 4 COMPLETE ON `main`.**
- Stage 1 (#120, `docs/theme-faces-framing.md` rev 9): named UI faces
as reserved `ui`/`ui.*` theme entries; transactional split
syntax/face epochs; protocol-v16 `ThemeFacts`; snapshot/baseline
@ -110,7 +107,7 @@ next machine reads it the way you just did.
`pmacs.gpu.set_font` and authoritative protocol-v17 `FontFacts`;
frontend-local family resolution, live font reload/reflow, and
visual-run caret geometry.
- Stage 3 (`statusline-segments`,
- Stage 3 (#125, `statusline-segments`,
`docs/statusline-segments-framing.md` rev 3): composable strict
`pmacs.statusline` providers; borrow-released per-window evaluation
with failure latches; legacy-preserving TUI composition; a pure
@ -121,8 +118,62 @@ next machine reads it the way you just did.
Clippy clean; 1,619 default + 1,793 CRDT library tests; 7 default +
8 CRDT feature acceptance; 114 M4; 109 required GPU; one-invocation
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**.
`basedpyright` filtered); `git diff --check` clean. Stage 3 landed
as #125 and completed Arc 4 on `main`.
- **Vterm Stage 1 terminal core IMPLEMENTED ON `vterm-core`, FULLY GATED,
AWAITING MERGE AUTHORIZATION, NOT MERGED** (`docs/vterm-framing.md` rev 5).
- Implementation commits: `bbc1f33` (Stage 1), `962944b` (Darwin signal
normalization), first-review fixes `f0a235f`, `28f2e6c`, `bf972a7`, and
second-review hardening `9797ada`; pull request: #126,
<https://github.com/levineuwirth/pmacs/pull/126> (open, non-draft,
targeting `main`).
- `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.
- Review round 1 added typed IND/NEL/RI with margin-correct screen behavior,
defaults absent `TERM` to `xterm-256color`, makes shutdown liveness
acceptance portable with `kill(pid, 0)`, and preserves custom tab stops on
resize. Review round 2 rejects C0/C1 controls before they enter screen
cells, preserves the released button code in SGR mouse reports, removes
dead screen paths, and clears stale round-trip state during prune. Stage 2
must uniquify default terminal buffer names.
- Exact CUU/CUD and out-of-range DECSTBM clamping, combining across controls,
xterm alternate-screen details, legacy non-SGR mouse, printable ASCII and
CSI-dispatch allocation fast paths, and scrollback-cap naming are explicit
post-arc deferrals in the framing.
- Final from-start rerun after review round 2: Clippy clean; 1,661 default +
1,837 CRDT library tests (3 ignored each); 9 default + 10 CRDT vterm
acceptance; M4 114 passed (3 ignored, 1 filtered); required GPU 109;
workspace 2,769 passed across 79 suites (19 ignored, 1 filtered); diff
check clean. `scripts/bite HEAD^ src/terminal/screen.rs --test
vterm_stage1_acceptance terminal_cells_reject_child_control_characters`
is a clean behavioral bite. The parser dispatch has its independent clean
behavioral bite; the original `main`/crate-root bite remains explicitly
weaker compile-time API evidence.
- Stage 2 reviews require a durable focus/input resize owner, owning
`FrontendId` for the global `C-c` continuation, and local clipboard/BEL
signal drainage. Stage 3 additionally owns `pmacs-gpu/src/attach.rs`,
authenticated source routing, protocol-owned wire types/limits, and a
deliberate complete-frame limit decision: 16 MiB is insufficient; use a
measured legal-worst cap or aggregate bound, never silent chunking.
- 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

@ -80,26 +80,34 @@ saveplace, autosave + crash recovery, optional backups. Generalize the
question: what is a "session" in a daemon world; do CRDT snapshots
ride along.
### Arc 4 — Themes + extensibility surface — COMPLETE ON FEATURE BRANCH
### Arc 4 — Themes + extensibility surface — COMPLETE ON `main`
Stages 1 and 2 landed as #120 and #124: named `ui.*` faces with
daemon-resolved `ThemeFacts`, then the live global
`pmacs.gpu.set_font` preference at protocol v17. Stage 3 is implemented
and fully gated on `statusline-segments`, awaiting review and **not yet
merged**: composable `pmacs.statusline` providers, per-window TUI
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`.
All three stages landed: #120 added named `ui.*` faces and daemon-resolved
`ThemeFacts`; #124 added the live global `pmacs.gpu.set_font` preference at
protocol v17; and #125 added composable `pmacs.statusline` providers,
per-window TUI composition, a pure built-in LSP segment, dynamic modeline
faces, and semantic/GPU transport through protocol v18.
### 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, two review rounds are
addressed, and the branch is fully gated on `vterm-core`; PR #126 awaits
merge authorization and is **not merged**. It adds compatibility parser
profiles, bounded VT screen/scrollback/reflow state, IND/NEL/RI, input
encoders, internal `TerminalManager`, read-only identity buffers, process
lifecycle, renderer-safe control-free cells, 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)

1009
docs/vterm-framing.md Normal file

File diff suppressed because it is too large Load Diff

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,61 @@ pub enum AnsiEvent {
/// `CSI ? 1049 l`: alternate-screen exited. `Text` and
/// `SetStyle` resume.
AlternateScreenExit,
/// Full-screen-only terminal operations.
Bell,
LineFeed,
/// `ESC D`: advance one row, scrolling at the bottom margin.
Index,
/// `ESC E`: return to column zero and advance one row.
NextLine,
/// `ESC M`: move up one row, scrolling down at the top margin.
ReverseIndex,
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 +285,7 @@ enum State {
Ground,
Escape,
EscapeIntermediate,
EscapeIgnore,
CsiEntry,
CsiParam,
CsiIntermediate,
@ -326,6 +457,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 +471,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 +500,7 @@ impl AnsiParser {
csi: CsiCollector::default(),
osc_body: Vec::new(),
escape_intermediates: Vec::new(),
profile,
config,
}
}
@ -396,11 +541,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 +576,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 +616,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 +699,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 +717,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 +752,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 +908,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 +922,66 @@ 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'D' | b'E' | b'H' | b'M' | b'=' | b'>'
if self.profile == AnsiParserProfile::FullScreen =>
{
let event = match b {
b'7' => AnsiEvent::SaveCursor,
b'8' => AnsiEvent::RestoreCursor,
b'D' => AnsiEvent::Index,
b'E' => AnsiEvent::NextLine,
b'H' => AnsiEvent::SetTabStop,
b'M' => AnsiEvent::ReverseIndex,
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 +1056,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 +1224,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 +1244,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 +1307,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 +1352,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 +1368,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 +2317,177 @@ 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\x1bD\x1bE\x1bM\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::Index));
assert!(expected.contains(&AnsiEvent::NextLine));
assert!(expected.contains(&AnsiEvent::ReverseIndex));
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 mut core = self.core.borrow_mut();
manager.prune(&mut 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,151 @@ 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::Index => t.set("kind", "index")?,
AnsiEvent::NextLine => t.set("kind", "next_line")?,
AnsiEvent::ReverseIndex => t.set("kind", "reverse_index")?,
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 +7731,7 @@ fn lua_to_spec(table: &Table) -> mlua::Result<ProcessSpec> {
mode,
restart,
ansi_events,
ansi_profile: crate::ansi::AnsiParserProfile::LineOriented,
stdin,
group,
})
@ -7787,7 +7937,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,
}
@ -749,29 +753,36 @@ impl TermStatus {
}
/// Map `libc::strsignal` description strings (as surfaced by
/// `portable-pty`) to symbolic SIGFOO names. Unknown descriptions pass
/// through unchanged — better to surface an unfamiliar string than to
/// fabricate a wrong name. Covers every signal in
/// `portable-pty`) to symbolic SIGFOO names. Darwin appends the signal
/// number (for example, `"Terminated: 15"`), while glibc returns only
/// the description. Unknown descriptions pass through unchanged —
/// better to surface an unfamiliar string than to fabricate a wrong
/// name. Covers every signal in
/// [`super::lua_bindings::parse_signal`]'s accept-list plus the common
/// fault signals that surface during process crashes.
fn canonicalize_pty_signal_name(desc: &str) -> String {
match desc {
"Interrupt" => "SIGINT".to_owned(),
"Terminated" => "SIGTERM".to_owned(),
"Killed" => "SIGKILL".to_owned(),
"Hangup" => "SIGHUP".to_owned(),
"Quit" => "SIGQUIT".to_owned(),
"User defined signal 1" => "SIGUSR1".to_owned(),
"User defined signal 2" => "SIGUSR2".to_owned(),
"Aborted" => "SIGABRT".to_owned(),
"Segmentation fault" => "SIGSEGV".to_owned(),
"Floating point exception" => "SIGFPE".to_owned(),
"Illegal instruction" => "SIGILL".to_owned(),
"Broken pipe" => "SIGPIPE".to_owned(),
"Alarm clock" => "SIGALRM".to_owned(),
"Bus error" => "SIGBUS".to_owned(),
other => other.to_owned(),
let base = desc
.rsplit_once(": ")
.filter(|(_, number)| number.parse::<u32>().is_ok())
.map_or(desc, |(description, _)| description);
match base {
"Interrupt" => "SIGINT",
"Terminated" => "SIGTERM",
"Killed" => "SIGKILL",
"Hangup" => "SIGHUP",
"Quit" => "SIGQUIT",
"User defined signal 1" => "SIGUSR1",
"User defined signal 2" => "SIGUSR2",
"Aborted" => "SIGABRT",
"Segmentation fault" => "SIGSEGV",
"Floating point exception" => "SIGFPE",
"Illegal instruction" => "SIGILL",
"Broken pipe" => "SIGPIPE",
"Alarm clock" => "SIGALRM",
"Bus error" => "SIGBUS",
_ => desc,
}
.to_owned()
}
impl Default for ProcessSupervisor {
@ -823,6 +834,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 +862,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 +898,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 +1242,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 +1582,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 +1994,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 +2005,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 +2080,30 @@ mod tests {
})
}
#[test]
fn pty_signal_names_are_canonical_across_libc_variants() {
assert_eq!(canonicalize_pty_signal_name("Terminated"), "SIGTERM");
assert_eq!(canonicalize_pty_signal_name("Terminated: 15"), "SIGTERM");
assert_eq!(canonicalize_pty_signal_name("Killed: 9"), "SIGKILL");
assert_eq!(
canonicalize_pty_signal_name("Unknown signal: 99"),
"Unknown signal: 99"
);
}
#[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 +2696,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(button) => (button_code(button), 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::Right),
CellCoord::new(4, 9),
Modifiers::NONE,
m
),
Some(b"\x1b[<2;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;

2019
src/terminal/screen.rs Normal file

File diff suppressed because it is too large Load Diff

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

@ -0,0 +1,606 @@
//! 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. `TERM` defaults to
/// `xterm-256color` when the caller does not provide it.
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;
if !process_spec.env.iter().any(|(name, _)| name == "TERM") {
process_spec
.env
.push(("TERM".into(), "xterm-256color".into()));
}
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: &mut 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 {
core.set_round_trip_input(buffer_id, false);
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,436 @@
//! Shared Stage 1 terminal registry, lifecycle, and read-only acceptance.
use std::time::{Duration, Instant};
use pmacs::ansi::AnsiEvent;
use pmacs::buffer::{Buffer, BufferError, BufferId, EditOp};
use pmacs::cell::{CellSize, Glyph};
use pmacs::editor::EditorState;
use pmacs::process::ProcessState;
use pmacs::rope::Range;
use pmacs::terminal::screen::TerminalScreen;
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 terminal_cells_reject_child_control_characters() {
let mut screen = TerminalScreen::new(CellSize::new(2, 4), 0).expect("valid screen");
let before = screen.snapshot();
screen.apply_event(AnsiEvent::Text("\u{9b}\n\0".into()));
assert_eq!(screen.snapshot(), before);
}
#[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")),
(String::from("TERM"), String::from("xterm-256color")),
]
);
}
#[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 pid = nix::unistd::Pid::from_raw(i32::try_from(pid).expect("pid fits i32"));
let deadline = Instant::now() + Duration::from_secs(2);
while Instant::now() < deadline && nix::sys::signal::kill(pid, None).is_ok() {
std::thread::sleep(Duration::from_millis(10));
}
assert_eq!(
nix::sys::signal::kill(pid, None),
Err(nix::errno::Errno::ESRCH),
"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"
);
}