From bbc1f33a7c106573e82cfb8da46e78d967dd4595 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 21 Jul 2026 14:20:28 -0400 Subject: [PATCH] feat(vterm): add Stage 1 terminal core Add compatibility-preserving full-screen ANSI operations, the bounded terminal screen and input encoders, and a transactional TerminalManager owning one read-only identity buffer, PTY process, and screen per session. Drain terminal-owned process events before process.after-tick, retain exact final output and PID/outcome annotations, reap killed buffers and shutdown children safely, and enforce buffer-owned read-only checks across ordinary, host, undo/redo, and CRDT mutation paths. Cover split parser and grapheme boundaries, screen/reflow/history invariants, device responses, lifecycle cleanup, and a real adversarial alternate-screen PTY. Record the fully gated Stage 1 delivery and downstream TUI/GPU contracts. Co-Authored-By: Claude Opus 4.7 --- docs/agent-handoff.md | 43 +- docs/roadmap-2026-07.md | 25 +- docs/vterm-framing.md | 140 ++- src/ansi.rs | 789 +++++++++--- src/buffer.rs | 138 +++ src/editor.rs | 46 +- src/lib.rs | 1 + src/lua_bindings/mod.rs | 156 ++- src/process.rs | 127 +- src/terminal/input.rs | 366 ++++++ src/terminal/mod.rs | 30 + src/terminal/screen.rs | 1945 ++++++++++++++++++++++++++++++ src/terminal/session.rs | 599 +++++++++ tests/vterm_stage1_acceptance.rs | 422 +++++++ 14 files changed, 4611 insertions(+), 216 deletions(-) create mode 100644 src/terminal/input.rs create mode 100644 src/terminal/mod.rs create mode 100644 src/terminal/screen.rs create mode 100644 src/terminal/session.rs create mode 100644 tests/vterm_stage1_acceptance.rs diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index 8252577..4d447a4 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -1,8 +1,8 @@ # Agent handoff — cross-machine continuity -**Last updated: 2026-07-21, with Themes Arc 4 stage 3 implemented and -fully gated on the `statusline-segments` feature branch (awaiting -review; not merged).** This file is the bridge between development +**Last updated: 2026-07-21, with Vterm Stage 1 implemented and fully gated on +the `vterm-core` feature branch (awaiting review; not merged). Vterm Stages 2 +and 3 are not implemented.** This file is the bridge between development machines. If you are an agent reading on a fresh clone: this document plus the `docs/*-framing.md` files ARE your memory. Read this fully before taking on work, seed persistent memory from it, and **update this @@ -123,6 +123,43 @@ next machine reads it the way you just did. workspace sweep 2,718 passed across 78 suites (19 ignored, `basedpyright` filtered); `git diff --check` clean. This branch is awaiting review and **must not be described as merged**. +- **Vterm Stage 1 terminal core IMPLEMENTED ON `vterm-core`, FULLY GATED, + AWAITING REVIEW, NOT MERGED** (`docs/vterm-framing.md` rev 3). + - `AnsiParserProfile::{LineOriented, FullScreen}` preserves compile/REPL + behavior while terminal PTYs emit the full cursor/mode/device operation + set. `src/terminal/{screen,input,session}.rs` owns the state machine, + encoders, and lifecycle registry. + - Public session seam: owned strict `TerminalSpec`; owned + `TerminalSnapshot`; `TerminalProcessState`; and + `SharedTerminalManager = Rc>` 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 1–14 is mapped in the framing. The real PTY bite splits + ESC/CSI writes, observes alternate-screen cursor addressing, blocks and + resumes through raw `send`, restores the main screen, and pins final + output before exact PID/outcome annotation. One-row annotation visibility, + TERM-ignoring shutdown, spawn rollback, buffer-kill prune, and immutable + empty CRDT bootstrap are pinned. + - Final from-start rerun: Clippy clean; 1,657 default + 1,833 CRDT library + tests (3 ignored each); 8 default + 9 CRDT vterm acceptance; M4 114 passed + (3 ignored, 1 filtered); required GPU 109; workspace 2,764 passed across + 79 suites (19 ignored, 1 filtered); diff check clean. The first Clippy + attempt found only missing crate docs in the new acceptance; it was fixed + and the whole sequence restarted. `scripts/bite main src/lib.rs --test + vterm_stage1_acceptance` is green only as the helper's explicitly weaker + compile-time API bite. + - Stage 2 reviews require a durable focus/input resize owner, owning + `FrontendId` for the global `C-c` continuation, and local clipboard/BEL + signal drainage. Stage 3 additionally owns `pmacs-gpu/src/attach.rs`, + authenticated source routing, protocol-owned wire types/limits, and a + deliberate complete-frame limit decision: 16 MiB is insufficient; use a + measured legal-worst cap or aggregate bound, never silent chunking. - Roadmap: `docs/roadmap-2026-07.md` (ranked arcs). Position: - **Arc 1 (LSP utility surface) COMPLETE** — completion popup (#92/#93), panels/references/outline/hover (#94–#96), plus diff --git a/docs/roadmap-2026-07.md b/docs/roadmap-2026-07.md index 412de58..a9173b0 100644 --- a/docs/roadmap-2026-07.md +++ b/docs/roadmap-2026-07.md @@ -91,15 +91,24 @@ composition, a pure built-in LSP segment, dynamic modeline faces, and semantic/GPU transport through protocol v18. Merging stage 3 completes Arc 4 on `main`. -### Arc 5 — Terminal, staged +### Arc 5 — Terminal, staged — VTERM STAGE 1 ON FEATURE BRANCH -- **Stage 1**: compile-mode / grep-mode / shell-command on the existing - PTY + ANSI + REPL-package substrate (line-oriented output buffer, - error-regex jump-to-file, `M-x compile`). Cheap, transformative. -- **Stage 2 (vterm)**: extend `ansi.rs` into a 2D grid model - (alt-screen, cursor addressing, scrollback — parser already - recognizes and discards these), grid-backed buffer view, GPU - rendering question (grid cells vs text buffer). +- **Compile-mode landed** in #113: line-oriented PTY/ANSI output, + error-regex navigation, and `M-x compile`. +- **Vterm Stage 1 terminal core** is implemented and fully gated on + `vterm-core`, awaiting review and **not merged**. It adds the compatibility + parser profiles, bounded VT screen/scrollback/reflow state machine, input + encoders, internal `TerminalManager`, read-only identity-buffer invariant, + process lifecycle/final annotations, and headless real-PTY acceptance. It + intentionally adds no interactive Lua command or frontend rendering. +- **Vterm Stage 2 TUI** starts only after Stage 1 merges: terminal-window + composition, input/resize, per-context scroll/selection/copy, and the Lua + surface. +- **Vterm Stage 3 protocol/GPU** starts only after Stage 2 merges: additive + protocol v19 complete frames, authenticated daemon routing, and native GPU + cell rendering. Its framing must resolve the current 16 MiB transport cap's + incompatibility with the legal worst complete terminal frame; never silently + chunk. ### Arc 6 — Folding (keystone gutter rider) diff --git a/docs/vterm-framing.md b/docs/vterm-framing.md index 599db62..c460b11 100644 --- a/docs/vterm-framing.md +++ b/docs/vterm-framing.md @@ -1,13 +1,16 @@ # Vterm — framing (Arc 5 stage 2, three-PR delivery) -**Revision 2 — 2026-07-21. Status: decisions folded; awaiting approval, no implementation.** +**Revision 3 — 2026-07-21. Status: Stage 1 terminal core implemented and fully +gated on `vterm-core`, awaiting review; not merged. Stages 2 and 3 are not +implemented.** -Revision 2 records the architecture discussion: `C-c` is the terminal editor -escape (`C-c C-c` sends interrupt); main-screen resize reflows while alternate -screen clips/pads; exited buffers remain with an Emacs-style process message; -protocol v19 is additive with complete frames; shared `Style` stays unchanged; -and one `BufferId` owns one shared process/screen whose most recently active -frontend controls size. +Revision 3 records the Stage 1 implementation and downstream consumer reviews. +The architecture remains unchanged: `C-c` is the terminal editor escape +(`C-c C-c` sends interrupt); main-screen resize reflows while alternate screen +clips/pads; exited buffers remain with an Emacs-style process message; protocol +v19 is additive with complete frames; shared `Style` stays unchanged; and one +`BufferId` owns one shared process/screen whose most recently active frontend +controls size. This framing follows the compile-mode terminal substrate that landed in PR #113. `src/process.rs` already owns PTY creation, process groups, bounded @@ -29,6 +32,123 @@ Arc 5 stage 2 ships as three separately reviewed PRs: There is no single mega-PR. Each stage is useful and testable by itself, and a later stage starts only after the preceding stage lands on `main`. +## 0. Revision 3 — Stage 1 implementation record + +The first of the three vterm PRs is implemented on `vterm-core`, fully gated, +and awaiting review. It is deliberately headless: there is no `pmacs.terminal` +Lua module, interactive terminal command, TUI paint branch, or GPU/protocol +surface yet. + +### 0.1 Public seam and ownership + +`src/terminal/session.rs` exports: + +- owned `TerminalSpec { command, args, cwd, env, name, rows, cols, + scrollback_rows }`, with `new` and strict pre-side-effect `validate`; +- `TerminalProcessState::{Running, Exited(i32), Signaled(String), + Crashed(String)}`; +- owned `TerminalSnapshot { buffer_id, size, cells, cursor, title, + screen_generation, selection, scroll_offset, at_bottom, pid, process }`; +- `SharedTerminalManager = Rc>`; +- `TerminalManager::{new, len, is_empty, open, is_terminal, process_id, + snapshot, tick, send, resize, terminate, prune, shutdown}`. + +The Stage 1 `snapshot(BufferId)` is intentionally context-free: +`selection=[]`, `scroll_offset=0`, and `at_bottom=true`. Stage 2 adds +per-`(FrontendId, WindowId, BufferId)` state and an owned +`snapshot_for_view(...)`; it must not add a second screen. + +`EditorState` owns the one shared manager. Tick order is supervisor `tick` → +terminal-owned PID drain/watchdog/prune → `process.after-tick`; LSP, MCP, and +ordinary Lua process events retain their existing owners. `ProcessSpec` gained +an `AnsiParserProfile`: `ProcessSpec::new` and Lua `ansi=true` stay +`LineOriented`, while terminal sessions explicitly request `FullScreen`. +Synchronous unpublished terminal spawn failure emits no orphan process event. + +Terminal identity buffers are pathless, clean, empty, and buffer-read-only. +The guard runs before direct edits, split Lua begin/skip-intercept edits, +undo/redo, and local/remote CRDT content import. Attaching an immutable empty +CRDT for semantic identity remains valid. + +### 0.2 Stage 1 acceptance mapping + +All fourteen Stage 1 criteria are implemented: + +1. whole/split parser equivalence: + `screen::tests::parser_split_points_produce_identical_screen` plus the ANSI + split matrix; +2. malformed/truncated/over-cap recovery: the 44 `ansi::tests`, including + split UTF-8, ignored CSI/OSC/DCS, and forward-progress cases; +3. cursor/region/edit exactness: + `cursor_erase_insert_delete_scroll_and_margins_mutate_exact_regions`; +4. pending wrap/wide/combining invariants: wide overwrite, split ZWJ/RI/ + modifier/variation, right-edge, and continuation tests; +5. alternate/synchronized publication: + `alternate_screen_preserves_main_and_has_no_history`, + `synchronized_output_gates_snapshot_and_finish_releases`, and watchdog; +6. DEC G0/G1 + SI/SO: `acs_and_device_replies_are_exact`; +7. SGR fidelity and ignored attributes: ANSI SGR/color/underline tests plus + screen operation coverage; +8. resize semantics: soft-wrap reflow, wide-boundary/cursor/hard-break tests, + alternate clipping, and atomic invalid resize; +9. dual history limits: `history_obeys_row_and_cell_caps`; +10. bounded DA/DSR/CPR and unsupported-output safety: + `acs_and_device_replies_are_exact` plus parser ignore cases; +11. strict owned specifications: + `strict_owned_spec_rejects_before_spawn_and_is_mutation_independent`; +12. real adversarial PTY/final drain: + `final_output_precedes_exact_nonzero_annotation_and_buffer_is_retained` + splits ESC/CSI writes, observes addressed alternate-screen output while + running, unblocks raw stdin through `send`, restores the main screen, and + proves final output precedes the exact PID/outcome annotation; zero, + non-zero, signal, wrapped, and one-row annotations are separately pinned; +13. lifecycle cleanup: transactional failure, live buffer-kill prune/reap, and + TERM-ignoring editor shutdown acceptance; +14. read-only/CRDT invariants: default + CRDT shared acceptance and focused + buffer unit tests cover every mutation route and empty bootstrap. + +### 0.3 Final gates and bite + +After one initial Clippy-only failure for the new acceptance crate's missing +module documentation, that source issue was fixed and the complete sequence was +restarted from gate 1: + +- `cargo fmt --check`: clean; +- `cargo clippy --workspace --all-targets -- -D warnings`: clean; +- default library: 1,657 passed, 3 ignored; +- CRDT library: 1,833 passed, 3 ignored; +- Stage 1 acceptance: 8 default + 9 CRDT passed; +- M4 acceptance: 114 passed, 3 ignored, 1 `basedpyright` filtered; +- required GPU: 109 passed; +- workspace: 2,764 passed across 79 suites, 19 ignored, 1 filtered; +- `git diff --check`: clean. + +`scripts/bite main src/lib.rs --test vterm_stage1_acceptance` returned +`bite: OK`: the swapped pre-stage crate root cannot compile the new terminal +API. This is explicitly the helper's weaker compile-time API bite, not a clean +behavioral assertion failure. + +### 0.4 Downstream review findings (not implemented) + +Stage 2 must derive PTY resize ownership from a durable accepted-input/focus +owner before render fan-out, never transient `EditorCore::active_frontend`. +Because `KeyDispatcher` pending state is global, a terminal `C-c` continuation +must carry its owning `FrontendId`. Terminal copy should use the existing core +kill-ring/clipboard setter, while the local run loop must drain/present +clipboard signals; active-terminal BEL likewise uses the out-of-band frontend +signal path. + +Stage 3 additionally owns `pmacs-gpu/src/attach.rs` for gated terminal +resize/pointer sending and coalescing. Daemon handlers must authenticate source +frontend/buffer ownership before input, resize, or pointer routing. Wire-facing +terminal state, selection, and limits must live in or be re-exported from +`pmacs-protocol`. The current 16 MiB transport frame cap cannot hold the legal +worst complete terminal frame (up to roughly 64 MiB of cluster bytes before +encoding overhead): Stage 3 must either raise and test a measured cap at least +as large as the legal worst case (review estimate at least 80 MiB), or add a +shared aggregate payload bound. It must never silently chunk the locked +complete-frame protocol. + ## 1. Problem and ownership boundary Pmacs can supervise a PTY and can parse enough ANSI to turn command output into @@ -651,10 +771,10 @@ Per-stage utilization: ## 8. Branch and PR plan -After this framing is approved: +Stage 1 is now implemented and fully gated on `vterm-core`, awaiting review and +not merged. Continue the approved plan only after the preceding PR lands: -1. create sibling worktree `pmacs-vterm-core`, branch `vterm-core`, from current - canonical `main`; implement/gate/open PR; merge only when the user says; +1. review `vterm-core`; merge only when the user says; 2. after stage 1 merges, create `pmacs-vterm-tui`, branch `vterm-tui`, from the new `main`; implement/gate/open a second PR; 3. after stage 2 merges, create `pmacs-vterm-gpu`, branch `vterm-gpu`, from the diff --git a/src/ansi.rs b/src/ansi.rs index 4455fe7..e876b01 100644 --- a/src/ansi.rs +++ b/src/ansi.rs @@ -43,6 +43,80 @@ use crate::cell::{Color, Style, UnderlineStyle}; +/// Parser compatibility profile. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum AnsiParserProfile { + /// Preserve the compile/REPL byte-stream contract. + #[default] + LineOriented, + /// Emit terminal operations for a stateful full-screen consumer. + FullScreen, +} + +#[allow(missing_docs)] +/// Erase direction for display and line operations. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EraseMode { + ToEnd, + ToStart, + All, + Saved, +} + +#[allow(missing_docs)] +/// DEC alternate-screen selector. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AlternateScreenMode { + Mode47, + Mode1047, + Mode1049, +} + +#[allow(missing_docs)] +/// Terminal modes understood by the screen/input core. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TerminalMode { + Insert, + Origin, + AutoWrap, + ApplicationCursor, + ApplicationKeypad, + CursorVisible, + BracketedPaste, + FocusReporting, + SynchronizedOutput, + MouseX10, + MouseButton, + MouseAny, + MouseSgr, +} + +#[allow(missing_docs)] +/// G0/G1 designation target and supported character set. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CharacterSetSlot { + G0, + G1, +} + +/// Character set designated into a DEC G0/G1 slot. +#[allow(missing_docs)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CharacterSet { + Ascii, + DecSpecialGraphics, +} + +#[allow(missing_docs)] +/// Typed terminal query. Only these requests may generate PTY input. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DeviceRequest { + PrimaryAttributes, + SecondaryAttributes, + OperatingStatus, + CursorPosition, +} + // --------------------------------------------------------------------------- // Public output // --------------------------------------------------------------------------- @@ -53,6 +127,7 @@ use crate::cell::{Color, Style, UnderlineStyle}; /// at the moment the parser has enough context to commit to it /// (e.g., `Text` is emitted at every transition out of Ground, not /// per byte). +#[allow(missing_docs)] #[derive(Clone, Debug, PartialEq, Eq)] pub enum AnsiEvent { /// Append literal text to the consumer's rope. Text never @@ -98,6 +173,55 @@ pub enum AnsiEvent { /// `CSI ? 1049 l`: alternate-screen exited. `Text` and /// `SetStyle` resume. AlternateScreenExit, + /// Full-screen-only terminal operations. + Bell, + LineFeed, + HorizontalTab, + SetTabStop, + ClearTabStop, + ClearAllTabStops, + CursorUp(u32), + CursorDown(u32), + CursorForward(u32), + CursorBackward(u32), + CursorNextLine(u32), + CursorPreviousLine(u32), + CursorHorizontalAbsolute(u32), + CursorVerticalAbsolute(u32), + CursorPosition { + row: u32, + col: u32, + }, + EraseDisplay(EraseMode), + EraseLineMode(EraseMode), + EraseCharacters(u32), + InsertCharacters(u32), + DeleteCharacters(u32), + InsertLines(u32), + DeleteLines(u32), + ScrollUp(u32), + ScrollDown(u32), + SetScrollingRegion { + top: u32, + bottom: Option, + }, + SaveCursor, + RestoreCursor, + AlternateScreen { + mode: AlternateScreenMode, + enabled: bool, + }, + SetMode { + mode: TerminalMode, + enabled: bool, + }, + DesignateCharacterSet { + slot: CharacterSetSlot, + charset: CharacterSet, + }, + ShiftOut, + ShiftIn, + DeviceRequest(DeviceRequest), } /// Tunable knobs for [`AnsiParser`]. @@ -155,6 +279,7 @@ enum State { Ground, Escape, EscapeIntermediate, + EscapeIgnore, CsiEntry, CsiParam, CsiIntermediate, @@ -326,6 +451,7 @@ pub struct AnsiParser { osc_body: Vec, /// Intermediate bytes for plain ESC sequences (`ESC` + 0x20..=0x2F). escape_intermediates: Vec, + profile: AnsiParserProfile, config: AnsiParserConfig, } @@ -339,12 +465,24 @@ impl AnsiParser { /// Construct a parser with default configuration. #[must_use] pub fn new() -> Self { - Self::with_config(AnsiParserConfig::default()) + Self::with_profile(AnsiParserProfile::LineOriented) } - /// Construct a parser with custom configuration. + /// Construct a parser using the selected compatibility profile. + #[must_use] + pub fn with_profile(profile: AnsiParserProfile) -> Self { + Self::with_profile_and_config(profile, AnsiParserConfig::default()) + } + + /// Construct a line-oriented parser with custom configuration. #[must_use] pub fn with_config(config: AnsiParserConfig) -> Self { + Self::with_profile_and_config(AnsiParserProfile::LineOriented, config) + } + + /// Construct a parser with both an explicit profile and configuration. + #[must_use] + pub fn with_profile_and_config(profile: AnsiParserProfile, config: AnsiParserConfig) -> Self { Self { state: State::Ground, current_style: Style::default(), @@ -356,6 +494,7 @@ impl AnsiParser { csi: CsiCollector::default(), osc_body: Vec::new(), escape_intermediates: Vec::new(), + profile, config, } } @@ -396,11 +535,11 @@ impl AnsiParser { // transition path (flush_text_run) does emit U+FFFD for // pending bytes because a non-text byte genuinely // interrupts the sequence; feed-boundary doesn't. - if !self.text_run.is_empty() && !self.alt_screen_active { + if !self.text_run.is_empty() { let run = std::mem::take(&mut self.text_run); - events.push(AnsiEvent::Text(run)); - } else { - self.text_run.clear(); + if !self.suppress_visible() { + events.push(AnsiEvent::Text(run)); + } } events } @@ -431,23 +570,22 @@ impl AnsiParser { pub fn finish(&mut self) -> Vec { let mut events = Vec::new(); self.flush_pending_utf8_as_replacement(); - if !self.text_run.is_empty() && !self.alt_screen_active { + if !self.text_run.is_empty() { let run = std::mem::take(&mut self.text_run); - events.push(AnsiEvent::Text(run)); - } else { - self.text_run.clear(); + if !self.suppress_visible() { + events.push(AnsiEvent::Text(run)); + } } - // Balancing state events, in unwind order. `reset` alone - // deliberately preserves alt-screen suppression (a - // mid-stream reset must not unhide alt-screen contents); a - // stream END does end it, observably. - if self.alt_screen_active { - self.alt_screen_active = false; - events.push(AnsiEvent::AlternateScreenExit); - } - if self.emitted_style != Style::default() { - events.push(AnsiEvent::SetStyle(Style::default())); + if self.profile == AnsiParserProfile::LineOriented { + if self.alt_screen_active { + self.alt_screen_active = false; + events.push(AnsiEvent::AlternateScreenExit); + } + if self.emitted_style != Style::default() { + events.push(AnsiEvent::SetStyle(Style::default())); + } } + self.alt_screen_active = false; self.current_style = Style::default(); self.emitted_style = Style::default(); self.reset(); @@ -472,28 +610,53 @@ impl AnsiParser { return; } - // Per-state byte cap. The counter increments for every byte - // consumed in any non-Ground state, and is reset to zero at - // every transition into a fresh sequence (ESC-anywhere) or - // back to Ground (normal dispatch / force-recover). At the - // limit, the parser drops the in-flight sequence and - // returns to Ground; the *current* byte is dropped on the - // floor, but subsequent bytes are processed normally as - // ordinary text. Spec §sec:ansi-scope: "drops back to ground - // state at the next ESC or after a bounded number of bytes - // (1 KiB), whichever comes first." - if self.state != State::Ground { + // Bound retained control-string payload without ever exposing its + // overflow as printable text. Once capped, remain in a zero-storage + // ignore state until BEL/ST or a fresh ESC sequence provides a safe + // recovery boundary. + if self.state != State::Ground + && !matches!( + self.state, + State::EscapeIgnore | State::CsiIgnore | State::OscIgnore | State::DcsIgnore + ) + { self.ignore_byte_count = self.ignore_byte_count.saturating_add(1); if self.ignore_byte_count > self.config.unknown_sequence_byte_limit { - self.recover_to_ground(); + match self.state { + State::OscString | State::OscEscPending => { + self.osc_body.clear(); + self.state = State::OscIgnore; + self.ignore_byte_count = 0; + } + State::DcsEntry + | State::DcsParam + | State::DcsIntermediate + | State::DcsPassthrough + | State::SosPmApcString => { + self.state = State::DcsIgnore; + self.ignore_byte_count = 0; + } + State::Escape | State::EscapeIntermediate => { + self.escape_intermediates.clear(); + self.state = State::EscapeIgnore; + self.ignore_byte_count = 0; + } + State::CsiEntry | State::CsiParam | State::CsiIntermediate => { + self.csi.reset(); + self.state = State::CsiIgnore; + self.ignore_byte_count = 0; + } + _ => self.recover_to_ground(), + } return; } } match self.state { State::Ground => self.feed_ground(b, events), - State::Escape => self.feed_escape(b), - State::EscapeIntermediate => self.feed_escape_intermediate(b), + State::Escape => self.feed_escape(b, events), + State::EscapeIntermediate => self.feed_escape_intermediate(b, events), + State::EscapeIgnore => self.feed_escape_ignore(b), State::CsiEntry => self.feed_csi_entry(b, events), State::CsiParam => self.feed_csi_param(b, events), State::CsiIntermediate => self.feed_csi_intermediate(b, events), @@ -530,13 +693,13 @@ impl AnsiParser { return; } let run = std::mem::take(&mut self.text_run); - if !self.alt_screen_active { + if !self.suppress_visible() { events.push(AnsiEvent::Text(run)); } } fn emit_set_style(&mut self, events: &mut Vec) { - if self.alt_screen_active { + if self.suppress_visible() { return; } self.emitted_style = self.current_style; @@ -548,11 +711,15 @@ impl AnsiParser { /// paste / `SetTitle`). The alt-screen markers themselves /// bypass this. fn push_visible(&self, ev: AnsiEvent, events: &mut Vec) { - if !self.alt_screen_active { + if !self.suppress_visible() { events.push(ev); } } + fn suppress_visible(&self) -> bool { + self.profile == AnsiParserProfile::LineOriented && self.alt_screen_active + } + /// Begin a fresh escape sequence (called from ESC-anywhere). /// Resets the byte budget and all in-flight sequence state. fn start_new_sequence(&mut self) { @@ -579,44 +746,53 @@ impl AnsiParser { // ----------------------------------------------------------------------- fn feed_ground(&mut self, b: u8, events: &mut Vec) { - match b { - // CR: flush text, emit CarriageReturn. - 0x0D => { - self.flush_text_run(events); - if !self.alt_screen_active { - events.push(AnsiEvent::CarriageReturn); + if self.profile == AnsiParserProfile::LineOriented { + match b { + 0x0D => { + self.flush_text_run(events); + self.push_visible(AnsiEvent::CarriageReturn, events); } + 0x08 => { + self.flush_text_run(events); + self.push_visible(AnsiEvent::Backspace, events); + } + 0x07 | 0x09 | 0x0A | 0x0B | 0x0C | 0x20..=0x7E | 0x80..=0xFF => { + self.push_text_byte(b); + } + 0x00..=0x1F | 0x7F => {} + } + return; + } + match b { + 0x07 => { + self.flush_text_run(events); + events.push(AnsiEvent::Bell); } - // BS: flush text, emit Backspace. 0x08 => { self.flush_text_run(events); - if !self.alt_screen_active { - events.push(AnsiEvent::Backspace); - } + events.push(AnsiEvent::Backspace); } - // BEL (0x07), VT (0x0B), FF (0x0C), HT (0x09), LF - // (0x0A): pass through to text alongside printable - // ASCII (0x20..=0x7E). The REPL view treats LF as a - // line break in the rope; HT as a literal tab. Other - // C0 controls (0x00..=0x06, 0x0E..=0x1F) and DEL - // (0x7F) are dropped silently. - // - // 0x80..=0xFF: UTF-8 lead or continuation byte. Goes - // through `push_text_byte`'s stateful decoder so - // multi-byte sequences across feeds are buffered until - // complete. - // - // All text bytes route through `push_text_byte` (not - // just non-ASCII): an ASCII byte arriving while a - // partial UTF-8 sequence is pending invalidates that - // sequence (the partial prefix's expected continuation - // didn't arrive), and `push_text_byte` is the only - // place that knows to flush the partial as `U+FFFD`. - // The fast path inside `push_text_byte` keeps the - // pure-ASCII case allocation-free. - 0x07 | 0x09 | 0x0A | 0x0B | 0x0C | 0x20..=0x7E | 0x80..=0xFF => { - self.push_text_byte(b); + 0x09 => { + self.flush_text_run(events); + events.push(AnsiEvent::HorizontalTab); } + 0x0A..=0x0C => { + self.flush_text_run(events); + events.push(AnsiEvent::LineFeed); + } + 0x0D => { + self.flush_text_run(events); + events.push(AnsiEvent::CarriageReturn); + } + 0x0E => { + self.flush_text_run(events); + events.push(AnsiEvent::ShiftOut); + } + 0x0F => { + self.flush_text_run(events); + events.push(AnsiEvent::ShiftIn); + } + 0x20..=0x7E | 0x80..=0xFF => self.push_text_byte(b), 0x00..=0x1F | 0x7F => {} } } @@ -726,7 +902,7 @@ impl AnsiParser { // Escape // ----------------------------------------------------------------------- - fn feed_escape(&mut self, b: u8) { + fn feed_escape(&mut self, b: u8, events: &mut Vec) { match b { 0x20..=0x2F => { self.escape_intermediates.push(b); @@ -740,42 +916,61 @@ impl AnsiParser { self.osc_body.clear(); self.state = State::OscString; } - // DCS / SOS / PM / APC introducers --- parse and discard. - b'P' => { - self.state = State::DcsEntry; - } - b'X' | b'^' | b'_' => { - self.state = State::SosPmApcString; - } - // ESC \ in Escape state is a stray ST; final byte for - // a bare ESC sequence (0x30..=0x7E) lands here too. We - // don't dispatch any single-byte ESC commands in v0.1 - // (cursor save/restore `ESC 7`/`ESC 8` are deliberately - // unsupported per spec); both cases consume and return - // to Ground. - b'\\' | 0x30..=0x7E => { + b'P' => self.state = State::DcsEntry, + b'X' | b'^' | b'_' => self.state = State::SosPmApcString, + b'7' | b'8' | b'H' | b'=' | b'>' if self.profile == AnsiParserProfile::FullScreen => { + let event = match b { + b'7' => AnsiEvent::SaveCursor, + b'8' => AnsiEvent::RestoreCursor, + b'H' => AnsiEvent::SetTabStop, + b'=' => AnsiEvent::SetMode { + mode: TerminalMode::ApplicationKeypad, + enabled: true, + }, + _ => AnsiEvent::SetMode { + mode: TerminalMode::ApplicationKeypad, + enabled: false, + }, + }; + events.push(event); self.recover_to_ground(); } - // C0 controls inside Escape: drop, stay in Escape. + b'\\' | 0x30..=0x7E => self.recover_to_ground(), _ => {} } } - fn feed_escape_intermediate(&mut self, b: u8) { + fn feed_escape_intermediate(&mut self, b: u8, events: &mut Vec) { match b { - 0x20..=0x2F => { - self.escape_intermediates.push(b); - } - // Final byte: drop the sequence (no ESC + intermediate - // dispatches in v0.1 --- charsets are deliberately - // unsupported per spec) and return to Ground. + 0x20..=0x2F => self.escape_intermediates.push(b), 0x30..=0x7E => { + if self.profile == AnsiParserProfile::FullScreen { + let slot = match self.escape_intermediates.as_slice() { + [b'('] => Some(CharacterSetSlot::G0), + [b')'] => Some(CharacterSetSlot::G1), + _ => None, + }; + let charset = match b { + b'0' => Some(CharacterSet::DecSpecialGraphics), + b'B' => Some(CharacterSet::Ascii), + _ => None, + }; + if let (Some(slot), Some(charset)) = (slot, charset) { + events.push(AnsiEvent::DesignateCharacterSet { slot, charset }); + } + } self.recover_to_ground(); } _ => {} } } + fn feed_escape_ignore(&mut self, b: u8) { + if matches!(b, 0x30..=0x7E) { + self.recover_to_ground(); + } + } + // ----------------------------------------------------------------------- // CSI // ----------------------------------------------------------------------- @@ -850,70 +1045,129 @@ impl AnsiParser { /// Dispatch a fully-collected CSI sequence. `final_byte` is the /// terminating byte (`0x40..=0x7E`). The collected parameters /// are taken from `self.csi`. + #[allow(clippy::too_many_lines)] fn dispatch_csi(&mut self, final_byte: u8, events: &mut Vec) { - 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(¶ms, 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(¶ms, events); + self.csi.reset(); + return; + } + if self.profile == AnsiParserProfile::LineOriented { + match (private, final_byte) { + (None, b'K') => match param(¶ms, 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(¶ms, 0, 0) { 200 => self.push_visible(AnsiEvent::BracketedPasteBegin, events), 201 => self.push_visible(AnsiEvent::BracketedPasteEnd, events), _ => {} - } - } - // DEC private mode set / reset: `CSI ? 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 ¶ms { - 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 ¶ms { + 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(¶ms, 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(¶ms, 0, 1).max(1), + )), + (None, [], b'd') => Some(AnsiEvent::CursorVerticalAbsolute( + param(¶ms, 0, 1).max(1), + )), + (None, [], b'H' | b'f') => Some(AnsiEvent::CursorPosition { + row: param(¶ms, 0, 1).max(1), + col: param(¶ms, 1, 1).max(1), + }), + (None, [], b'J') => erase_mode(param(¶ms, 0, 0)).map(AnsiEvent::EraseDisplay), + (None, [], b'K') => erase_mode(param(¶ms, 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(¶ms, 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(¶ms, 0, 0) { + 0 => Some(AnsiEvent::ClearTabStop), + 3 => Some(AnsiEvent::ClearAllTabStops), + _ => None, + }, + (None, [], b'~') => match param(¶ms, 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 ¶ms { + 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 ¶ms { + 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(¶ms, 0, 0) { + 5 => Some(AnsiEvent::DeviceRequest(DeviceRequest::OperatingStatus)), + 6 => Some(AnsiEvent::DeviceRequest(DeviceRequest::CursorPosition)), + _ => None, + }, + _ => None, + }; + if let Some(event) = event { + events.push(event); + } self.csi.reset(); } @@ -959,9 +1213,13 @@ impl AnsiParser { _ => UnderlineStyle::Single, }; } - // 5/6 (blink, rapid blink): mapped to bold per spec - // §sec:ansi-scope ("blink-as-bold"). - 5 | 6 => self.current_style.bold = true, + // Line-oriented compile/REPL consumers historically render + // blink as bold. Full-screen preserves the shared Style + // contract: blink is unsupported and leaves it unchanged. + 5 | 6 if self.profile == AnsiParserProfile::LineOriented => { + self.current_style.bold = true; + } + 5 | 6 => {} 7 => self.current_style.reverse = true, // 8 (concealed/invisible): no-op. Out of scope. 8 => {} @@ -975,9 +1233,8 @@ impl AnsiParser { 22 => self.current_style.bold = false, 23 => self.current_style.italic = false, 24 => self.current_style.underline = UnderlineStyle::None, - // 25 (blink off): no-op. Symmetry with 5/6 → bold: - // we do not unset bold here, since that would also - // unset bold acquired via SGR 1. + // 25 (blink off): unsupported. It must not unset bold + // acquired through SGR 1. 25 => {} 27 => self.current_style.reverse = false, 28 => {} @@ -1039,11 +1296,12 @@ impl AnsiParser { } // ESC: begin ST-terminator check (ESC \). 0x1B => self.state = State::OscEscPending, - // 0x20..=0x7F: body bytes. The per-state byte cap - // (enforced at the top of `feed_byte`) bounds how many - // bytes we'll accept before force-recovering. - 0x20..=0x7F => self.osc_body.push(b), - // Other C0/C1 controls: drop silently, stay in OSC. + // OSC payload is UTF-8 bytes, not ASCII. Retain printable ASCII, + // DEL (compatibility), and all high bytes; lossy UTF-8 decoding at + // dispatch replaces malformed sequences. The per-state cap bounds + // retained storage. + 0x20..=0xFF => self.osc_body.push(b), + // Other C0 controls: drop silently, stay in OSC. _ => {} } } @@ -1083,7 +1341,7 @@ impl AnsiParser { let num: Option = std::str::from_utf8(num_part) .ok() .and_then(|s| s.parse().ok()); - if matches!(num, Some(133)) && !self.alt_screen_active { + if matches!(num, Some(133)) && !self.suppress_visible() { match text_part.first().copied() { Some(b'A') => events.push(AnsiEvent::PromptStart), Some(b'B') => events.push(AnsiEvent::PromptEnd), @@ -1099,13 +1357,66 @@ impl AnsiParser { // above produce events. Other OSC numbers are parsed and // discarded per spec §sec:ansi-scope, with the critical // guarantee that state alignment is preserved. - if matches!(num, Some(0 | 2)) && !self.alt_screen_active { + if matches!(num, Some(0 | 2)) && !self.suppress_visible() { let title = String::from_utf8_lossy(text_part).into_owned(); events.push(AnsiEvent::SetTitle(title)); } } } +fn param(params: &CsiParams, index: usize, default: u32) -> u32 { + params + .get(index) + .map_or(default, |p| if p.main == 0 { default } else { p.main }) +} + +fn erase_mode(value: u32) -> Option { + 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 { + let mode = match value { + 47 => { + return Some(AnsiEvent::AlternateScreen { + mode: AlternateScreenMode::Mode47, + enabled, + }); + } + 1047 => { + return Some(AnsiEvent::AlternateScreen { + mode: AlternateScreenMode::Mode1047, + enabled, + }); + } + 1049 => { + return Some(AnsiEvent::AlternateScreen { + mode: AlternateScreenMode::Mode1049, + enabled, + }); + } + 1 => TerminalMode::ApplicationCursor, + 6 => TerminalMode::Origin, + 7 => TerminalMode::AutoWrap, + 25 => TerminalMode::CursorVisible, + 66 => TerminalMode::ApplicationKeypad, + 1000 => TerminalMode::MouseX10, + 1002 => TerminalMode::MouseButton, + 1003 => TerminalMode::MouseAny, + 1004 => TerminalMode::FocusReporting, + 1006 => TerminalMode::MouseSgr, + 2004 => TerminalMode::BracketedPaste, + 2026 => TerminalMode::SynchronizedOutput, + _ => return None, + }; + Some(AnsiEvent::SetMode { mode, enabled }) +} + /// Parse a CSI 38/48 extended-color suffix into a `Color` plus /// the number of *additional* params consumed (legacy form only; /// the modern subparam form keeps everything inside `p.sub` so @@ -1995,4 +2306,174 @@ mod tests { even though the internal style is already default" ); } + + #[test] + fn full_screen_emits_typed_operation_set_across_every_split() { + let bytes = b"\x07\t\n\x1bH\x1b[2A\x1b[3B\x1b[4C\x1b[5D\x1b[2E\x1b[2F\ + \x1b[7G\x1b[8d\x1b[2;3H\x1b[J\x1b[1K\x1b[2X\x1b[3@\x1b[4P\ + \x1b[2L\x1b[2M\x1b[3S\x1b[2T\x1b[2;20r\x1b[s\x1b[u\x1b[3g\ + \x1b[?1;6;7;25;1000;1002;1003;1004;1006;2004;2026h\ + \x1b[?47h\x1b[?1047h\x1b[?1049h\x1b[c\x1b[>c\x1b[5n\x1b[6n"; + let mut whole = AnsiParser::with_profile(AnsiParserProfile::FullScreen); + let expected = whole.feed(bytes); + assert!(expected.contains(&AnsiEvent::Bell)); + assert!(expected.contains(&AnsiEvent::CursorPosition { row: 2, col: 3 })); + assert!(expected.contains(&AnsiEvent::SetScrollingRegion { + top: 2, + bottom: Some(20) + })); + assert!(expected.contains(&AnsiEvent::SetMode { + mode: TerminalMode::SynchronizedOutput, + enabled: true + })); + assert!(expected.contains(&AnsiEvent::AlternateScreen { + mode: AlternateScreenMode::Mode1049, + enabled: true + })); + assert!(expected.contains(&AnsiEvent::DeviceRequest(DeviceRequest::CursorPosition))); + for split in 0..=bytes.len() { + let mut parser = AnsiParser::with_profile(AnsiParserProfile::FullScreen); + let mut actual = parser.feed(&bytes[..split]); + actual.extend(parser.feed(&bytes[split..])); + assert_eq!(actual, expected, "split {split}"); + } + } + + #[test] + fn full_screen_finish_flushes_without_synthetic_balancing() { + let mut parser = AnsiParser::with_profile(AnsiParserProfile::FullScreen); + let events = parser.feed(b"\x1b[?1049h\x1b[31mred"); + assert!(events.contains(&AnsiEvent::AlternateScreen { + mode: AlternateScreenMode::Mode1049, + enabled: true, + })); + assert!( + events + .iter() + .any(|event| matches!(event, AnsiEvent::SetStyle(_))) + ); + assert!(parser.finish().is_empty()); + assert!(!parser.alt_screen_active); + assert!(parser.finish().is_empty()); + assert_eq!(parser.feed(b"x"), vec![AnsiEvent::Text("x".into())]); + } + + #[test] + fn full_screen_charset_designation_and_shift_are_typed() { + let mut parser = AnsiParser::with_profile(AnsiParserProfile::FullScreen); + assert_eq!( + parser.feed(b"\x1b(0\x1b)B\x0e\x0f"), + vec![ + AnsiEvent::DesignateCharacterSet { + slot: CharacterSetSlot::G0, + charset: CharacterSet::DecSpecialGraphics, + }, + AnsiEvent::DesignateCharacterSet { + slot: CharacterSetSlot::G1, + charset: CharacterSet::Ascii, + }, + AnsiEvent::ShiftOut, + AnsiEvent::ShiftIn, + ] + ); + } + + #[test] + fn full_screen_capped_control_strings_recover_invisibly() { + let config = AnsiParserConfig { + unknown_sequence_byte_limit: 8, + }; + let mut parser = AnsiParser::with_profile_and_config(AnsiParserProfile::FullScreen, config); + let events = parser.feed(b"\x1b]52;AAAAAAAABsecret\x1b\\ok\x1bPAAAAAAAAAAAA\x1b\\done"); + let visible: String = events + .iter() + .filter_map(|event| match event { + AnsiEvent::Text(text) => Some(text.as_str()), + _ => None, + }) + .collect(); + assert!(!visible.contains("secret")); + assert!(!visible.contains("AAAA")); + assert!(visible.ends_with("done")); + assert!(!visible.contains('\x1b')); + } + + #[test] + fn capped_csi_and_escape_intermediate_never_leak_payload() { + let config = AnsiParserConfig { + unknown_sequence_byte_limit: 8, + }; + for input in [ + b"\x1b[12345678901234567890mOK".as_slice(), + b"\x1b[?999999999999999999hOK".as_slice(), + b"\x1b 0OK".as_slice(), + ] { + let mut parser = + AnsiParser::with_profile_and_config(AnsiParserProfile::FullScreen, config); + let visible: String = parser + .feed(input) + .into_iter() + .filter_map(|event| { + if let AnsiEvent::Text(text) = event { + Some(text) + } else { + None + } + }) + .collect(); + assert_eq!(visible, "OK", "input {input:?}"); + } + } + + #[test] + fn full_screen_unsupported_sgr_attributes_leave_style_unchanged() { + let mut parser = AnsiParser::with_profile(AnsiParserProfile::FullScreen); + parser.feed(b"\x1b[1;3;31m"); + let before = parser.current_style; + parser.feed(b"\x1b[2;5;6;8;9;25;28;29m"); + assert_eq!(parser.current_style, before); + assert!(before.bold); + + let mut line = AnsiParser::new(); + line.feed(b"\x1b[5m"); + assert!( + line.current_style.bold, + "line-oriented blink-as-bold compatibility" + ); + } + + #[test] + fn unicode_osc_titles_survive_every_feed_split() { + let bytes = "\u{1b}]2;héllo 世界\u{7}".as_bytes(); + let mut whole = AnsiParser::with_profile(AnsiParserProfile::FullScreen); + let expected = whole.feed(bytes); + assert_eq!(expected, vec![AnsiEvent::SetTitle("héllo 世界".into())]); + for split in 0..=bytes.len() { + let mut parser = AnsiParser::with_profile(AnsiParserProfile::FullScreen); + let mut actual = parser.feed(&bytes[..split]); + actual.extend(parser.feed(&bytes[split..])); + assert_eq!(actual, expected, "split {split}"); + } + } + + #[test] + fn malformed_utf8_osc_title_is_replaced_and_bounded() { + let config = AnsiParserConfig { + unknown_sequence_byte_limit: 32, + }; + let mut parser = AnsiParser::with_profile_and_config(AnsiParserProfile::FullScreen, config); + let events = parser.feed(b"\x1b]0;bad\xfftitle\x07"); + let title = events + .into_iter() + .find_map(|event| { + if let AnsiEvent::SetTitle(title) = event { + Some(title) + } else { + None + } + }) + .expect("title event"); + assert_eq!(title, "bad\u{fffd}title"); + assert!(title.len() <= config.unknown_sequence_byte_limit); + } } diff --git a/src/buffer.rs b/src/buffer.rs index c90578a..c8fbb0a 100644 --- a/src/buffer.rs +++ b/src/buffer.rs @@ -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 { + 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, 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 { + 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 { + 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 { + 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 { diff --git a/src/editor.rs b/src/editor.rs index 20c91d9..09fa0ac 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -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 { + let mut manager = self.terminal_manager.borrow_mut(); + let mut core = self.core.borrow_mut(); + let mut supervisor = self.process_supervisor.borrow_mut(); + manager.open(spec, &mut core, &mut supervisor) + } + + /// One pass of the process supervisor and terminal-owned event drain. + /// + /// Ordering is supervisor tick → terminal drain/prune → + /// `process.after-tick`. `TerminalManager` calls `take_events` only for its + /// own `ProcessId`s; existing Lua/LSP/MCP ownership remains unchanged. pub fn tick_processes(&mut self) { - self.process_supervisor.borrow_mut().tick(); + { + let mut supervisor = self.process_supervisor.borrow_mut(); + supervisor.tick(); + let mut manager = self.terminal_manager.borrow_mut(); + manager.tick(&mut supervisor); + let core = self.core.borrow(); + manager.prune(&core, &mut supervisor); + } self.lua_host .run_hook("process.after-tick", mlua::MultiValue::new()); } diff --git a/src/lib.rs b/src/lib.rs index 921ea27..33e9338 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index 24caee1..f2acb6c 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -4912,6 +4912,10 @@ fn installed_package_to_lua(lua: &Lua, pkg: &InstalledPackage) -> mlua::Result }` only /// - `set_title`: `{ kind="set_title", title= }` +#[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 { use crate::ansi::AnsiEvent; let t = lua.create_table()?; @@ -4964,6 +4968,148 @@ fn event_to_lua_table(lua: &Lua, ev: &crate::ansi::AnsiEvent) -> mlua::Result { t.set("kind", "alt_screen_exit")?; } + AnsiEvent::Bell => t.set("kind", "bell")?, + AnsiEvent::LineFeed => t.set("kind", "line_feed")?, + AnsiEvent::HorizontalTab => t.set("kind", "horizontal_tab")?, + AnsiEvent::SetTabStop => t.set("kind", "set_tab_stop")?, + AnsiEvent::ClearTabStop => t.set("kind", "clear_tab_stop")?, + AnsiEvent::ClearAllTabStops => t.set("kind", "clear_all_tab_stops")?, + AnsiEvent::CursorUp(count) + | AnsiEvent::CursorDown(count) + | AnsiEvent::CursorForward(count) + | AnsiEvent::CursorBackward(count) + | AnsiEvent::CursorNextLine(count) + | AnsiEvent::CursorPreviousLine(count) + | AnsiEvent::EraseCharacters(count) + | AnsiEvent::InsertCharacters(count) + | AnsiEvent::DeleteCharacters(count) + | AnsiEvent::InsertLines(count) + | AnsiEvent::DeleteLines(count) + | AnsiEvent::ScrollUp(count) + | AnsiEvent::ScrollDown(count) => { + let kind = match ev { + AnsiEvent::CursorUp(_) => "cursor_up", + AnsiEvent::CursorDown(_) => "cursor_down", + AnsiEvent::CursorForward(_) => "cursor_forward", + AnsiEvent::CursorBackward(_) => "cursor_backward", + AnsiEvent::CursorNextLine(_) => "cursor_next_line", + AnsiEvent::CursorPreviousLine(_) => "cursor_previous_line", + AnsiEvent::EraseCharacters(_) => "erase_characters", + AnsiEvent::InsertCharacters(_) => "insert_characters", + AnsiEvent::DeleteCharacters(_) => "delete_characters", + AnsiEvent::InsertLines(_) => "insert_lines", + AnsiEvent::DeleteLines(_) => "delete_lines", + AnsiEvent::ScrollUp(_) => "scroll_up", + AnsiEvent::ScrollDown(_) => "scroll_down", + _ => unreachable!("outer match restricts the event"), + }; + t.set("kind", kind)?; + t.set("count", *count)?; + } + AnsiEvent::CursorHorizontalAbsolute(col) => { + t.set("kind", "cursor_horizontal_absolute")?; + t.set("col", *col)?; + } + AnsiEvent::CursorVerticalAbsolute(row) => { + t.set("kind", "cursor_vertical_absolute")?; + t.set("row", *row)?; + } + AnsiEvent::CursorPosition { row, col } => { + t.set("kind", "cursor_position")?; + t.set("row", *row)?; + t.set("col", *col)?; + } + AnsiEvent::EraseDisplay(mode) | AnsiEvent::EraseLineMode(mode) => { + t.set( + "kind", + if matches!(ev, AnsiEvent::EraseDisplay(_)) { + "erase_display" + } else { + "erase_line_mode" + }, + )?; + t.set( + "mode", + match mode { + crate::ansi::EraseMode::ToEnd => "to_end", + crate::ansi::EraseMode::ToStart => "to_start", + crate::ansi::EraseMode::All => "all", + crate::ansi::EraseMode::Saved => "saved", + }, + )?; + } + AnsiEvent::SetScrollingRegion { top, bottom } => { + t.set("kind", "set_scrolling_region")?; + t.set("top", *top)?; + t.set("bottom", *bottom)?; + } + AnsiEvent::SaveCursor => t.set("kind", "save_cursor")?, + AnsiEvent::RestoreCursor => t.set("kind", "restore_cursor")?, + AnsiEvent::AlternateScreen { mode, enabled } => { + t.set("kind", "alternate_screen")?; + t.set( + "mode", + match mode { + crate::ansi::AlternateScreenMode::Mode47 => 47, + crate::ansi::AlternateScreenMode::Mode1047 => 1047, + crate::ansi::AlternateScreenMode::Mode1049 => 1049, + }, + )?; + t.set("enabled", *enabled)?; + } + AnsiEvent::SetMode { mode, enabled } => { + t.set("kind", "set_mode")?; + t.set( + "mode", + match mode { + crate::ansi::TerminalMode::Insert => "insert", + crate::ansi::TerminalMode::Origin => "origin", + crate::ansi::TerminalMode::AutoWrap => "auto_wrap", + crate::ansi::TerminalMode::ApplicationCursor => "application_cursor", + crate::ansi::TerminalMode::ApplicationKeypad => "application_keypad", + crate::ansi::TerminalMode::CursorVisible => "cursor_visible", + crate::ansi::TerminalMode::BracketedPaste => "bracketed_paste", + crate::ansi::TerminalMode::FocusReporting => "focus_reporting", + crate::ansi::TerminalMode::SynchronizedOutput => "synchronized_output", + crate::ansi::TerminalMode::MouseX10 => "mouse_x10", + crate::ansi::TerminalMode::MouseButton => "mouse_button", + crate::ansi::TerminalMode::MouseAny => "mouse_any", + crate::ansi::TerminalMode::MouseSgr => "mouse_sgr", + }, + )?; + t.set("enabled", *enabled)?; + } + AnsiEvent::DesignateCharacterSet { slot, charset } => { + t.set("kind", "designate_character_set")?; + t.set( + "slot", + match slot { + crate::ansi::CharacterSetSlot::G0 => "g0", + crate::ansi::CharacterSetSlot::G1 => "g1", + }, + )?; + t.set( + "charset", + match charset { + crate::ansi::CharacterSet::Ascii => "ascii", + crate::ansi::CharacterSet::DecSpecialGraphics => "dec_special_graphics", + }, + )?; + } + AnsiEvent::ShiftOut => t.set("kind", "shift_out")?, + AnsiEvent::ShiftIn => t.set("kind", "shift_in")?, + AnsiEvent::DeviceRequest(request) => { + t.set("kind", "device_request")?; + t.set( + "request", + match request { + crate::ansi::DeviceRequest::PrimaryAttributes => "primary_attributes", + crate::ansi::DeviceRequest::SecondaryAttributes => "secondary_attributes", + crate::ansi::DeviceRequest::OperatingStatus => "operating_status", + crate::ansi::DeviceRequest::CursorPosition => "cursor_position", + }, + )?; + } } Ok(t) } @@ -7582,6 +7728,7 @@ fn lua_to_spec(table: &Table) -> mlua::Result { mode, restart, ansi_events, + ansi_profile: crate::ansi::AnsiParserProfile::LineOriented, stdin, group, }) @@ -7787,7 +7934,14 @@ pub fn install_process(lua: &Lua, supervisor: &SharedProcessSupervisor) -> mlua: "list", lua.create_function(move |lua, ()| { let sup = s.borrow(); - let ids: Vec = sup.ids().collect(); + let ids: Vec = 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)?; diff --git a/src/process.rs b/src/process.rs index 72b4f15..17ae0e7 100644 --- a/src/process.rs +++ b/src/process.rs @@ -60,7 +60,7 @@ use crossbeam::channel::{self, Receiver, Sender}; use nix::sys::signal::Signal; use nix::unistd::Pid; -use crate::ansi::{AnsiEvent, AnsiParser}; +use crate::ansi::{AnsiEvent, AnsiParser, AnsiParserProfile}; // --------------------------------------------------------------------------- // Identity and configuration @@ -216,6 +216,9 @@ pub struct ProcessSpec { /// instead of raw stdout bytes. Opt-in so LSP and other byte-stream /// consumers keep their existing stdout/stderr contract. pub ansi_events: bool, + /// Compatibility profile for structured ANSI parsing. Ignored unless + /// `ansi_events` is true; ordinary process/Lua callers remain line-oriented. + pub ansi_profile: AnsiParserProfile, /// Stdin disposition (pipe-mode only; rejected under PTY). pub stdin: StdinMode, /// Compile-mode group lifecycle (Q#CM3; pipe-mode only, rejected @@ -245,6 +248,7 @@ impl ProcessSpec { mode: ProcessMode::Pipes, restart: RestartPolicy::Never, ansi_events: false, + ansi_profile: AnsiParserProfile::LineOriented, stdin: StdinMode::Piped, group: false, } @@ -823,6 +827,23 @@ impl ProcessSupervisor { /// crashes *after* spawn shows up as a [`Termination::Crashed`] /// in the event stream, not as a return error. pub fn spawn(&mut self, spec: ProcessSpec) -> Result { + 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 { + self.spawn_inner(spec, false) + } + + fn spawn_inner( + &mut self, + spec: ProcessSpec, + publish_synchronous_failure: bool, + ) -> Result { if self.shut_down { return Err("supervisor is shut down".to_owned()); } @@ -834,15 +855,20 @@ impl ProcessSupervisor { attempt_count: 0, next_restart_at: None, }; - self.start_generation(id, &mut managed)?; + self.start_generation(id, &mut managed, publish_synchronous_failure)?; self.processes.insert(id, managed); Ok(id) } - /// Start a fresh generation for `managed`. Mutates `managed` - /// in place; on failure the state is left as - /// `Terminated(Crashed{...})` and an event is emitted. - fn start_generation(&self, id: ProcessId, managed: &mut ManagedProcess) -> Result<(), String> { + /// Start a fresh generation for `managed`. Mutates `managed` in place; on + /// failure its state is `Terminated(Crashed{...})`, and the event is emitted + /// only when `publish_failure` is true. + fn start_generation( + &self, + id: ProcessId, + managed: &mut ManagedProcess, + publish_failure: bool, + ) -> Result<(), String> { managed.attempt_count += 1; managed.next_restart_at = None; match build_runtime(&managed.spec, id) { @@ -865,11 +891,13 @@ impl ProcessSupervisor { ended: now, }); managed.runtime = None; - let _ = self.events_tx.send(ProcessEvent { - id, - kind: ProcessEventKind::Crashed { error: e.clone() }, - at: now, - }); + if publish_failure { + let _ = self.events_tx.send(ProcessEvent { + id, + kind: ProcessEventKind::Crashed { error: e.clone() }, + at: now, + }); + } Err(e) } } @@ -1207,7 +1235,7 @@ impl ProcessSupervisor { kind: ProcessEventKind::Restarting { attempt }, at: now, }); - let _ = self.start_generation(id, &mut managed); + let _ = self.start_generation(id, &mut managed, true); self.processes.insert(id, managed); } else { // Schedule a restart attempt for `restart_backoff` from @@ -1547,7 +1575,12 @@ fn build_pty_runtime( )]; let output_rx = if spec.ansi_events { let (ansi_tx, ansi_rx) = channel::bounded::(ANSI_EVENT_CHANNEL_CAP); - readers.push(spawn_ansi_parser(byte_rx, ansi_tx, Arc::clone(&cancel))); + readers.push(spawn_ansi_parser( + byte_rx, + ansi_tx, + Arc::clone(&cancel), + spec.ansi_profile, + )); RuntimeOutputRx::Ansi(ansi_rx) } else { RuntimeOutputRx::Bytes(byte_rx) @@ -1954,9 +1987,10 @@ fn spawn_ansi_parser( byte_rx: Receiver, ansi_tx: Sender, cancel: Arc, + profile: AnsiParserProfile, ) -> JoinHandle<()> { std::thread::spawn(move || { - let mut parser = AnsiParser::new(); + let mut parser = AnsiParser::with_profile(profile); loop { if cancel.load(Ordering::Relaxed) { return; @@ -1964,31 +1998,44 @@ fn spawn_ansi_parser( let (kind, bytes) = match byte_rx.recv_timeout(READER_SEND_POLL_INTERVAL) { Ok(chunk) => chunk, Err(crossbeam::channel::RecvTimeoutError::Timeout) => continue, - Err(crossbeam::channel::RecvTimeoutError::Disconnected) => return, + Err(crossbeam::channel::RecvTimeoutError::Disconnected) => { + let events = parser.finish(); + if !events.is_empty() { + let _ = send_ansi_batch(&ansi_tx, &cancel, events); + } + return; + } }; if !matches!(kind, ReaderKind::Stdout) { continue; } - let mut events = parser.feed(&bytes); - if events.is_empty() { - continue; - } - loop { - match ansi_tx.send_timeout(events, READER_SEND_POLL_INTERVAL) { - Ok(()) => break, - Err(crossbeam::channel::SendTimeoutError::Timeout(rejected)) => { - if cancel.load(Ordering::Relaxed) { - return; - } - events = rejected; - } - Err(crossbeam::channel::SendTimeoutError::Disconnected(_)) => return, - } + let events = parser.feed(&bytes); + if !events.is_empty() && !send_ansi_batch(&ansi_tx, &cancel, events) { + return; } } }) } +fn send_ansi_batch( + ansi_tx: &Sender, + cancel: &AtomicBool, + mut events: AnsiBatch, +) -> bool { + loop { + match ansi_tx.send_timeout(events, READER_SEND_POLL_INTERVAL) { + Ok(()) => return true, + Err(crossbeam::channel::SendTimeoutError::Timeout(rejected)) => { + if cancel.load(Ordering::Relaxed) { + return false; + } + events = rejected; + } + Err(crossbeam::channel::SendTimeoutError::Disconnected(_)) => return false, + } + } +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -2026,6 +2073,19 @@ mod tests { }) } + #[test] + fn terminal_transactional_spawn_failure_has_no_event_or_process_residue() { + let mut supervisor = ProcessSupervisor::new(); + let spec = ProcessSpec::new( + "unpublished-terminal", + "/definitely/not/a/real/pmacs-terminal-program", + ); + assert!(supervisor.spawn_terminal(spec).is_err()); + supervisor.tick(); + assert_eq!(supervisor.ids().count(), 0); + assert!(supervisor.take_all_events().is_empty()); + } + #[test] fn spawn_pipes_lifecycle_started_then_exited() { let mut sup = ProcessSupervisor::new(); @@ -2618,7 +2678,12 @@ mod tests { let (byte_tx, byte_rx) = channel::bounded::(1); let (ansi_tx, _ansi_rx) = channel::bounded::(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); diff --git a/src/terminal/input.rs b/src/terminal/input.rs new file mode 100644 index 0000000..dfa3e71 --- /dev/null +++ b/src/terminal/input.rs @@ -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> { + 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 { + 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> { + 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> { + if mods.contains(Modifiers::META) || mods.contains(Modifiers::HYPER) { + return None; + } + if !modes.mouse_sgr || modes.mouse_tracking == MouseTrackingMode::Off { + return None; + } + let allowed = match modes.mouse_tracking { + MouseTrackingMode::Off => false, + MouseTrackingMode::X10 => matches!(kind, MouseKind::Down(_)), + MouseTrackingMode::Button => !matches!(kind, MouseKind::Move), + MouseTrackingMode::Any => true, + }; + if !allowed { + return None; + } + let (mut code, release) = match kind { + MouseKind::Down(button) => (button_code(button), false), + MouseKind::Up(_) => (3, true), + MouseKind::Drag(button) => (button_code(button) + 32, false), + MouseKind::Move => (35, false), + MouseKind::ScrollUp => (64, false), + MouseKind::ScrollDown => (65, false), + MouseKind::ScrollLeft => (66, false), + MouseKind::ScrollRight => (67, false), + }; + if mods.contains(Modifiers::SHIFT) { + code += 4; + } + if mods.contains(Modifiers::ALT) { + code += 8; + } + if mods.contains(Modifiers::CTRL) { + code += 16; + } + let final_byte = if release { 'm' } else { 'M' }; + Some( + format!( + "\x1b[<{code};{};{}{final_byte}", + coord.col.saturating_add(1), + coord.row.saturating_add(1) + ) + .into_bytes(), + ) +} + +fn control_byte(ch: char) -> Option { + 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 { + 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 { + 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 { + 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) -> Vec { + let modifier = modifier_parameter(mods); + if modifier == 1 && first.is_none() { + return vec![0x1b, b'[', final_byte]; + } + let first = first.unwrap_or(1); + format!("\x1b[{first};{modifier}{}", final_byte as char).into_bytes() +} + +fn modifier_parameter(mods: Modifiers) -> u8 { + 1 + u8::from(mods.contains(Modifiers::SHIFT)) + + 2 * u8::from(mods.contains(Modifiers::ALT)) + + 4 * u8::from(mods.contains(Modifiers::CTRL)) +} + +fn button_code(button: MouseButton) -> u8 { + match button { + MouseButton::Left => 0, + MouseButton::Middle => 1, + MouseButton::Right => 2, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn modes() -> TerminalModes { + TerminalModes::default() + } + + #[test] + fn utf8_ctrl_and_alt_boundaries() { + assert_eq!( + encode_key(Key::Char('é'), Modifiers::NONE, modes()), + Some("é".as_bytes().to_vec()) + ); + assert_eq!( + encode_key(Key::Char('c'), Modifiers::CTRL, modes()), + Some(vec![3]) + ); + assert_eq!( + encode_key(Key::Char('?'), Modifiers::CTRL | Modifiers::ALT, modes()), + Some(vec![0x1b, 0x7f]) + ); + assert_eq!(encode_key(Key::Char('é'), Modifiers::CTRL, modes()), None); + } + + #[test] + fn application_cursor_and_xterm_modifiers() { + let mut app = modes(); + app.application_cursor = true; + assert_eq!( + encode_key(Key::Up, Modifiers::NONE, app), + Some(b"\x1bOA".to_vec()) + ); + assert_eq!( + encode_key(Key::Up, Modifiers::CTRL | Modifiers::SHIFT, app), + Some(b"\x1b[1;6A".to_vec()) + ); + assert_eq!( + encode_key(Key::Delete, Modifiers::ALT, modes()), + Some(b"\x1b[3;3~".to_vec()) + ); + assert_eq!( + encode_key(Key::F(1), Modifiers::NONE, modes()), + Some(b"\x1bOP".to_vec()) + ); + assert_eq!( + encode_key(Key::F(12), Modifiers::CTRL, modes()), + Some(b"\x1b[24;5~".to_vec()) + ); + assert_eq!( + encode_key(Key::BackTab, Modifiers::SHIFT, modes()), + Some(b"\x1b[Z".to_vec()) + ); + } + + #[test] + fn ambiguous_digits_ignore_application_keypad() { + let mut app = modes(); + app.application_keypad = true; + assert_eq!( + encode_key(Key::Char('7'), Modifiers::NONE, app), + Some(b"7".to_vec()) + ); + } + + #[test] + fn paste_and_focus_are_exact() { + assert_eq!(encode_paste(b"a\0b", false), b"a\0b".to_vec()); + assert_eq!( + encode_paste(b"a\0b", true), + b"\x1b[200~a\0b\x1b[201~".to_vec() + ); + assert_eq!(encode_focus(true, true), Some(b"\x1b[I".to_vec())); + assert_eq!(encode_focus(false, true), Some(b"\x1b[O".to_vec())); + assert_eq!(encode_focus(true, false), None); + } + + #[test] + fn sgr_mouse_modes_modifiers_and_coordinates() { + let mut m = modes(); + m.mouse_sgr = true; + m.mouse_tracking = MouseTrackingMode::Any; + assert_eq!( + encode_mouse( + MouseKind::Down(MouseButton::Left), + CellCoord::new(0, 0), + Modifiers::NONE, + m + ), + Some(b"\x1b[<0;1;1M".to_vec()) + ); + assert_eq!( + encode_mouse( + MouseKind::Drag(MouseButton::Right), + CellCoord::new(511, 511), + Modifiers::CTRL | Modifiers::ALT, + m + ), + Some(b"\x1b[<58;512;512M".to_vec()) + ); + assert_eq!( + encode_mouse( + MouseKind::Up(MouseButton::Left), + CellCoord::new(4, 9), + Modifiers::NONE, + m + ), + Some(b"\x1b[<3;10;5m".to_vec()) + ); + assert_eq!( + encode_mouse( + MouseKind::ScrollDown, + CellCoord::new(1, 2), + Modifiers::SHIFT, + m + ), + Some(b"\x1b[<69;3;2M".to_vec()) + ); + } + + #[test] + fn unsupported_keys_are_invisible() { + assert_eq!(encode_key(Key::Unknown(7), Modifiers::NONE, modes()), None); + assert_eq!(encode_key(Key::F(13), Modifiers::NONE, modes()), None); + assert_eq!(encode_key(Key::Char('c'), Modifiers::META, modes()), None); + assert_eq!(encode_key(Key::Up, Modifiers::HYPER, modes()), None); + let mut mouse_modes = modes(); + mouse_modes.mouse_sgr = true; + mouse_modes.mouse_tracking = MouseTrackingMode::Any; + assert_eq!( + encode_mouse( + MouseKind::Down(MouseButton::Left), + CellCoord::new(0, 0), + Modifiers::META, + mouse_modes, + ), + None, + ); + assert_eq!( + encode_mouse( + MouseKind::Move, + CellCoord::new(4, 9), + Modifiers::HYPER, + mouse_modes, + ), + None, + ); + } +} diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs new file mode 100644 index 0000000..ca205c5 --- /dev/null +++ b/src/terminal/mod.rs @@ -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; diff --git a/src/terminal/screen.rs b/src/terminal/screen.rs new file mode 100644 index 0000000..f7aafd8 --- /dev/null +++ b/src/terminal/screen.rs @@ -0,0 +1,1945 @@ +use std::collections::{BTreeSet, VecDeque}; +use std::time::{Duration, Instant}; + +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + +use super::{ + MAX_TERMINAL_COLS, MAX_TERMINAL_GRAPHEME_BYTES, MAX_TERMINAL_HISTORY_CELLS, + MAX_TERMINAL_METADATA_BYTES, MAX_TERMINAL_ROWS, MAX_TERMINAL_VISIBLE_CELLS, +}; +use crate::ansi::{ + AlternateScreenMode, AnsiEvent, CharacterSet, CharacterSetSlot, DeviceRequest, EraseMode, + TerminalMode, +}; +use crate::cell::{Cell, CellCoord, CellSize, Glyph, Style}; + +/// Maximum time a child may hold synchronized-output publication. +pub const SYNCHRONIZED_OUTPUT_WATCHDOG: Duration = Duration::from_secs(1); + +#[allow(missing_docs)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ScreenError { + InvalidSize(CellSize), +} + +impl std::fmt::Display for ScreenError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidSize(size) => { + write!(f, "invalid terminal size {}x{}", size.rows, size.cols) + } + } + } +} + +impl std::error::Error for ScreenError {} + +#[allow(missing_docs)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MouseTrackingMode { + Off, + X10, + Button, + Any, +} + +#[allow(missing_docs, clippy::struct_excessive_bools)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TerminalModes { + pub insert: bool, + pub origin: bool, + pub autowrap: bool, + pub application_cursor: bool, + pub application_keypad: bool, + pub cursor_visible: bool, + pub bracketed_paste: bool, + pub focus_reporting: bool, + pub synchronized_output: bool, + pub mouse_tracking: MouseTrackingMode, + pub mouse_sgr: bool, +} + +impl Default for TerminalModes { + fn default() -> Self { + Self { + insert: false, + origin: false, + autowrap: true, + application_cursor: false, + application_keypad: false, + cursor_visible: true, + bracketed_paste: false, + focus_reporting: false, + synchronized_output: false, + mouse_tracking: MouseTrackingMode::Off, + mouse_sgr: false, + } + } +} + +#[allow(missing_docs)] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TerminalRow { + pub cells: Vec, + pub logical_line_id: u64, + pub cell_offset: u32, + pub soft_wrapped: bool, +} + +#[allow(missing_docs)] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ScreenSnapshot { + pub size: CellSize, + pub cells: Vec, + pub cursor: Option, + pub title: Option, + pub generation: u64, +} + +#[derive(Clone, Copy, Debug, Default)] +struct Cursor { + row: usize, + col: usize, + pending_wrap: bool, +} + +#[derive(Clone, Copy, Debug)] +struct SavedCursor { + cursor: Cursor, + style: Style, + g0: CharacterSet, + g1: CharacterSet, + use_g1: bool, +} + +#[derive(Clone, Debug)] +struct Grid { + rows: Vec, + history: VecDeque, +} + +#[allow(missing_docs, clippy::struct_excessive_bools)] +pub struct TerminalScreen { + size: CellSize, + main: Grid, + alt: Grid, + alt_active: bool, + cursor: Cursor, + saved_main_cursor: Option, + saved_alt_cursor: Option, + saved_main_1049: Option, + inactive_main_cursor: Cursor, + inactive_alt_cursor: Cursor, + style: Style, + modes: TerminalModes, + scroll_top: usize, + scroll_bottom: usize, + tab_stops: BTreeSet, + title: Option, + generation: u64, + published: ScreenSnapshot, + sync_started: Option, + next_line_id: u64, + scrollback_rows: usize, + g0: CharacterSet, + g1: CharacterSet, + use_g1: bool, + bell_count: u64, + mouse_x10: bool, + mouse_button: bool, + mouse_any: bool, + last_grapheme: Option<(usize, usize)>, +} + +#[allow(missing_docs)] +impl TerminalScreen { + pub fn new(size: CellSize, scrollback_rows: usize) -> Result { + validate_size(size)?; + let mut next_line_id = 1; + let main = Grid::new(size, &mut next_line_id); + let alt = Grid::new(size, &mut next_line_id); + let published = ScreenSnapshot { + size, + cells: flatten(&main.rows), + cursor: Some(CellCoord::new(0, 0)), + title: None, + generation: 0, + }; + Ok(Self { + size, + main, + alt, + alt_active: false, + cursor: Cursor::default(), + saved_main_cursor: None, + saved_alt_cursor: None, + saved_main_1049: None, + inactive_main_cursor: Cursor::default(), + inactive_alt_cursor: Cursor::default(), + style: Style::default(), + modes: TerminalModes::default(), + scroll_top: 0, + scroll_bottom: size.rows as usize - 1, + tab_stops: default_tab_stops(size.cols as usize), + title: None, + generation: 0, + published, + sync_started: None, + next_line_id, + scrollback_rows, + g0: CharacterSet::Ascii, + g1: CharacterSet::Ascii, + use_g1: false, + bell_count: 0, + mouse_x10: false, + mouse_button: false, + mouse_any: false, + last_grapheme: None, + }) + } + + #[allow(clippy::too_many_lines, clippy::let_and_return, clippy::cast_lossless)] + pub fn apply_event(&mut self, event: AnsiEvent) -> Option> { + if !matches!(&event, AnsiEvent::Text(_)) { + self.last_grapheme = None; + } + let reply = match event { + AnsiEvent::Text(text) => { + self.write_text(&text); + None + } + AnsiEvent::SetStyle(style) => { + self.style = style; + self.changed(); + None + } + AnsiEvent::CarriageReturn => { + self.cursor.col = 0; + self.cursor.pending_wrap = false; + self.changed(); + None + } + AnsiEvent::Backspace => { + self.cursor.col = self.cursor.col.saturating_sub(1); + self.cursor.pending_wrap = false; + self.changed(); + None + } + AnsiEvent::Bell => { + self.bell_count = self.bell_count.saturating_add(1); + self.changed(); + None + } + AnsiEvent::LineFeed => { + self.line_feed(false); + None + } + AnsiEvent::HorizontalTab => { + self.horizontal_tab(); + None + } + AnsiEvent::SetTabStop => { + self.tab_stops.insert(self.cursor.col); + self.changed(); + None + } + AnsiEvent::ClearTabStop => { + self.tab_stops.remove(&self.cursor.col); + self.changed(); + None + } + AnsiEvent::ClearAllTabStops => { + self.tab_stops.clear(); + self.changed(); + None + } + AnsiEvent::CursorUp(n) => { + self.move_vertical(-i64::from(n)); + None + } + AnsiEvent::CursorDown(n) => { + self.move_vertical(i64::from(n)); + None + } + AnsiEvent::CursorForward(n) => { + self.move_horizontal(i64::from(n)); + None + } + AnsiEvent::CursorBackward(n) => { + self.move_horizontal(-i64::from(n)); + None + } + AnsiEvent::CursorNextLine(n) => { + self.move_vertical(i64::from(n)); + self.cursor.col = 0; + None + } + AnsiEvent::CursorPreviousLine(n) => { + self.move_vertical(-i64::from(n)); + self.cursor.col = 0; + None + } + AnsiEvent::CursorHorizontalAbsolute(col) => { + self.set_col(col); + None + } + AnsiEvent::CursorVerticalAbsolute(row) => { + self.set_row(row); + None + } + AnsiEvent::CursorPosition { row, col } => { + self.set_position(row, col); + None + } + AnsiEvent::EraseDisplay(mode) => { + self.erase_display(mode); + None + } + AnsiEvent::EraseLineMode(mode) => { + self.erase_line(mode); + None + } + AnsiEvent::EraseCharacters(n) => { + self.erase_characters(n); + None + } + AnsiEvent::InsertCharacters(n) => { + self.insert_characters(n); + None + } + AnsiEvent::DeleteCharacters(n) => { + self.delete_characters(n); + None + } + AnsiEvent::InsertLines(n) => { + self.insert_lines(n); + None + } + AnsiEvent::DeleteLines(n) => { + self.delete_lines(n); + None + } + AnsiEvent::ScrollUp(n) => { + self.scroll_up(n as usize); + None + } + AnsiEvent::ScrollDown(n) => { + self.scroll_down(n as usize); + None + } + AnsiEvent::SetScrollingRegion { top, bottom } => { + self.set_scrolling_region(top, bottom); + None + } + AnsiEvent::SaveCursor => { + let saved = Some(self.capture_cursor()); + if self.alt_active { + self.saved_alt_cursor = saved; + } else { + self.saved_main_cursor = saved; + } + None + } + AnsiEvent::RestoreCursor => { + let saved = if self.alt_active { + self.saved_alt_cursor + } else { + self.saved_main_cursor + }; + if let Some(saved) = saved { + self.restore_cursor(saved); + } + None + } + AnsiEvent::AlternateScreen { mode, enabled } => { + self.set_alternate(mode, enabled); + None + } + AnsiEvent::SetMode { mode, enabled } => { + self.set_mode(mode, enabled); + None + } + AnsiEvent::DesignateCharacterSet { slot, charset } => { + match slot { + CharacterSetSlot::G0 => self.g0 = charset, + CharacterSetSlot::G1 => self.g1 = charset, + } + self.changed(); + None + } + AnsiEvent::ShiftOut => { + self.use_g1 = true; + self.changed(); + None + } + AnsiEvent::ShiftIn => { + self.use_g1 = false; + self.changed(); + None + } + AnsiEvent::DeviceRequest(request) => Some(self.device_reply(request)), + AnsiEvent::SetTitle(title) => { + self.title = Some(sanitize_title(&title)); + self.changed(); + None + } + AnsiEvent::EraseToEol => { + self.erase_line(EraseMode::ToEnd); + None + } + AnsiEvent::EraseLine => { + self.erase_line(EraseMode::All); + None + } + AnsiEvent::AlternateScreenEnter => { + self.set_alternate(AlternateScreenMode::Mode1049, true); + None + } + AnsiEvent::AlternateScreenExit => { + self.set_alternate(AlternateScreenMode::Mode1049, false); + None + } + AnsiEvent::BracketedPasteBegin + | AnsiEvent::BracketedPasteEnd + | AnsiEvent::PromptStart + | AnsiEvent::PromptEnd + | AnsiEvent::CommandStart + | AnsiEvent::OutputStart => None, + }; + reply + } + + pub fn finish_output(&mut self) { + if self.modes.synchronized_output { + self.modes.synchronized_output = false; + self.sync_started = None; + self.publish(); + } + } + + pub fn synchronized_watchdog_expired(&mut self, now: Instant) -> bool { + if self.sync_started.is_some_and(|start| { + now.saturating_duration_since(start) >= SYNCHRONIZED_OUTPUT_WATCHDOG + }) { + self.modes.synchronized_output = false; + self.sync_started = None; + self.publish(); + true + } else { + false + } + } + + pub fn append_process_annotation(&mut self, annotation: &str) { + self.last_grapheme = None; + let needs_newline = self.cursor.col != 0 + || self.cursor.pending_wrap + || self.active().rows[self.cursor.row] + .cells + .iter() + .any(|c| !is_blank(c)); + self.style = Style::default(); + if needs_newline { + self.cursor.pending_wrap = false; + self.cursor.col = 0; + self.line_feed(false); + } + self.write_text(annotation); + self.cursor.pending_wrap = false; + let row = self.cursor.row; + self.active_mut().rows[row].soft_wrapped = false; + self.break_chain_after(row); + self.finish_output(); + } + + pub fn resize(&mut self, size: CellSize) -> Result<(), ScreenError> { + validate_size(size)?; + if size == self.size { + return Ok(()); + } + self.last_grapheme = None; + let old_size = self.size; + self.reflow_main(size); + resize_grid_clip( + &mut self.alt, + old_size, + size, + &mut self.next_line_id, + self.style, + ); + self.size = size; + self.scroll_top = 0; + self.scroll_bottom = size.rows as usize - 1; + self.cursor.row = self.cursor.row.min(self.scroll_bottom); + self.cursor.col = self.cursor.col.min(size.cols as usize - 1); + self.cursor.pending_wrap = false; + self.tab_stops.retain(|&col| col < size.cols as usize); + for col in (8..size.cols as usize).step_by(8) { + self.tab_stops.insert(col); + } + self.enforce_history_budget(); + self.changed(); + Ok(()) + } + + pub fn snapshot(&self) -> ScreenSnapshot { + if self.modes.synchronized_output { + self.published.clone() + } else { + self.current_snapshot() + } + } + + pub fn modes(&self) -> TerminalModes { + self.modes + } + pub fn bell_count(&self) -> u64 { + self.bell_count + } + pub fn history(&self) -> &VecDeque { + &self.main.history + } + pub fn visible_rows(&self) -> &[TerminalRow] { + &self.active().rows + } + + fn active(&self) -> &Grid { + if self.alt_active { + &self.alt + } else { + &self.main + } + } + fn active_mut(&mut self) -> &mut Grid { + if self.alt_active { + &mut self.alt + } else { + &mut self.main + } + } + + fn capture_cursor(&self) -> SavedCursor { + SavedCursor { + cursor: self.cursor, + style: self.style, + g0: self.g0, + g1: self.g1, + use_g1: self.use_g1, + } + } + + fn restore_cursor(&mut self, saved: SavedCursor) { + self.cursor = saved.cursor; + self.cursor.row = self.cursor.row.min(self.size.rows as usize - 1); + self.cursor.col = self.cursor.col.min(self.size.cols as usize - 1); + self.cursor.pending_wrap = false; + self.style = saved.style; + self.g0 = saved.g0; + self.g1 = saved.g1; + self.use_g1 = saved.use_g1; + self.changed(); + } + + fn write_text(&mut self, text: &str) { + for ch in text.chars() { + let ch = self.map_character(ch); + if self.try_extend_previous_grapheme(ch) { + continue; + } + let width = UnicodeWidthChar::width(ch).unwrap_or(0).min(2); + if width == 0 { + self.write_leading_combining(ch); + } else { + self.write_character(ch, width); + } + } + } + + fn try_extend_previous_grapheme(&mut self, ch: char) -> bool { + let Some((row, lead)) = self.last_grapheme else { + return false; + }; + let previous = glyph_bytes(&self.active().rows[row].cells[lead].glyph); + let Ok(previous_text) = std::str::from_utf8(&previous) else { + return false; + }; + let mut encoded = [0; 4]; + let addition = ch.encode_utf8(&mut encoded); + let mut candidate = String::with_capacity(previous.len() + addition.len()); + candidate.push_str(previous_text); + candidate.push_str(addition); + let mut graphemes = candidate.graphemes(true); + let joins_previous = + graphemes.next() == Some(candidate.as_str()) && graphemes.next().is_none(); + if !joins_previous { + return false; + } + if candidate.len() > MAX_TERMINAL_GRAPHEME_BYTES { + return true; + } + self.replace_previous_grapheme(row, lead, candidate); + true + } + + fn replace_previous_grapheme(&mut self, row: usize, lead: usize, cluster: String) { + let cols = self.size.cols as usize; + let width = UnicodeWidthStr::width(cluster.as_str()).clamp(1, 2); + let old_width = glyph_width(&self.active().rows[row].cells[lead].glyph); + let style = self.active().rows[row].cells[lead].style; + if width == 2 && cols == 1 { + self.active_mut().rows[row].cells[lead] = cell(Glyph::Char('\u{fffd}'), style); + self.cursor.row = row; + self.cursor.col = 0; + self.cursor.pending_wrap = self.modes.autowrap; + self.last_grapheme = Some((row, lead)); + self.changed(); + return; + } + if width == 2 && lead + 1 >= cols && !self.modes.autowrap { + self.active_mut().rows[row].cells[lead] = cell(Glyph::Char('\u{fffd}'), style); + self.cursor.row = row; + self.cursor.col = lead; + self.cursor.pending_wrap = false; + self.last_grapheme = Some((row, lead)); + self.changed(); + return; + } + if width == 2 && lead + 1 >= cols { + self.active_mut().rows[row].cells[lead] = blank(self.style); + if old_width == 2 && lead + 1 < cols { + self.active_mut().rows[row].cells[lead + 1] = blank(self.style); + } + self.cursor.row = row; + self.cursor.col = cols - 1; + self.cursor.pending_wrap = true; + self.soft_wrap(); + let new_row = self.cursor.row; + self.active_mut().rows[new_row].cells[0] = cell( + Glyph::Cluster(cluster.into_bytes().into_boxed_slice()), + style, + ); + self.active_mut().rows[new_row].cells[1] = cell(Glyph::Continuation, style); + if cols == 2 { + self.cursor.col = 1; + self.cursor.pending_wrap = self.modes.autowrap; + } else { + self.cursor.col = 2; + self.cursor.pending_wrap = false; + } + self.last_grapheme = Some((new_row, 0)); + self.changed(); + return; + } + self.active_mut().rows[row].cells[lead] = cell( + Glyph::Cluster(cluster.into_bytes().into_boxed_slice()), + style, + ); + if width == 2 { + self.active_mut().rows[row].cells[lead + 1] = cell(Glyph::Continuation, style); + } else if old_width == 2 && lead + 1 < cols { + self.active_mut().rows[row].cells[lead + 1] = blank(self.style); + } + self.cursor.row = row; + if lead + width >= cols { + self.cursor.col = cols - 1; + self.cursor.pending_wrap = self.modes.autowrap; + } else { + self.cursor.col = lead + width; + self.cursor.pending_wrap = false; + } + self.last_grapheme = Some((row, lead)); + self.changed(); + } + + fn write_leading_combining(&mut self, ch: char) { + let row = self.cursor.row; + let col = self.cursor.col; + self.clear_wide_at(row, col); + let mut cluster = String::from(" "); + cluster.push(ch); + if cluster.len() <= MAX_TERMINAL_GRAPHEME_BYTES { + self.active_mut().rows[row].cells[col] = cell( + Glyph::Cluster(cluster.into_bytes().into_boxed_slice()), + self.style, + ); + self.last_grapheme = Some((row, col)); + self.changed(); + } + } + + fn map_character(&self, ch: char) -> char { + let charset = if self.use_g1 { self.g1 } else { self.g0 }; + if charset == CharacterSet::DecSpecialGraphics { + dec_special_graphics(ch) + } else { + ch + } + } + + fn write_character(&mut self, mut ch: char, mut width: usize) { + let cols = self.size.cols as usize; + if width == 2 && cols == 1 { + ch = '\u{fffd}'; + width = 1; + } + if width == 2 && self.cursor.col + 1 >= cols && !self.modes.autowrap { + ch = '\u{fffd}'; + width = 1; + } else if self.cursor.pending_wrap || (width == 2 && self.cursor.col + 1 >= cols) { + self.soft_wrap(); + } + if self.modes.insert { + self.insert_characters(width as u32); + } + self.clear_wide_at(self.cursor.row, self.cursor.col); + if width == 2 { + self.clear_wide_at(self.cursor.row, self.cursor.col + 1); + } + let style = self.style; + let row = self.cursor.row; + let col = self.cursor.col; + let grid = self.active_mut(); + grid.rows[row].cells[col] = cell(Glyph::Char(ch), style); + if width == 2 { + grid.rows[row].cells[col + 1] = cell(Glyph::Continuation, style); + } + if col + width >= cols { + self.cursor.col = cols - 1; + self.cursor.pending_wrap = self.modes.autowrap; + } else { + self.cursor.col += width; + self.cursor.pending_wrap = false; + } + self.last_grapheme = Some((row, col)); + self.changed(); + } + + fn soft_wrap(&mut self) { + let row = self.cursor.row; + let id = self.active().rows[row].logical_line_id; + let offset = self.active().rows[row].cell_offset + self.size.cols; + self.active_mut().rows[row].soft_wrapped = true; + self.cursor.col = 0; + self.cursor.pending_wrap = false; + if row == self.scroll_bottom { + self.scroll_up_internal(1, Some((id, offset))); + } else { + self.cursor.row += 1; + let next_row = self.cursor.row; + let next = &mut self.active_mut().rows[next_row]; + next.logical_line_id = id; + next.cell_offset = offset; + } + self.changed(); + } + + fn line_feed(&mut self, soft: bool) { + self.cursor.pending_wrap = false; + let row = self.cursor.row; + if !soft { + self.active_mut().rows[row].soft_wrapped = false; + self.break_chain_after(row); + } + if row == self.scroll_bottom { + self.scroll_up_internal(1, None); + } else if row + 1 < self.size.rows as usize { + self.cursor.row += 1; + } + self.changed(); + } + + fn horizontal_tab(&mut self) { + let cols = self.size.cols as usize; + self.cursor.col = self + .tab_stops + .range((self.cursor.col + 1)..) + .next() + .copied() + .unwrap_or(cols - 1); + self.cursor.pending_wrap = false; + self.changed(); + } + + fn move_vertical(&mut self, delta: i64) { + let (lo, hi) = if self.modes.origin { + (self.scroll_top, self.scroll_bottom) + } else { + (0, self.size.rows as usize - 1) + }; + let distance = usize::try_from(delta.unsigned_abs()).unwrap_or(usize::MAX); + let moved = if delta.is_negative() { + self.cursor.row.saturating_sub(distance) + } else { + self.cursor.row.saturating_add(distance) + }; + self.cursor.row = moved.clamp(lo, hi); + self.cursor.pending_wrap = false; + self.changed(); + } + + fn move_horizontal(&mut self, delta: i64) { + let distance = usize::try_from(delta.unsigned_abs()).unwrap_or(usize::MAX); + let moved = if delta.is_negative() { + self.cursor.col.saturating_sub(distance) + } else { + self.cursor.col.saturating_add(distance) + }; + self.cursor.col = moved.min(self.size.cols as usize - 1); + self.cursor.pending_wrap = false; + self.changed(); + } + + fn set_col(&mut self, col: u32) { + self.cursor.col = col.saturating_sub(1).min(self.size.cols - 1) as usize; + self.cursor.pending_wrap = false; + self.changed(); + } + fn set_row(&mut self, row: u32) { + let base = if self.modes.origin { + self.scroll_top + } else { + 0 + }; + let hi = if self.modes.origin { + self.scroll_bottom + } else { + self.size.rows as usize - 1 + }; + self.cursor.row = (base + row.saturating_sub(1) as usize).min(hi); + self.cursor.pending_wrap = false; + self.changed(); + } + fn set_position(&mut self, row: u32, col: u32) { + self.set_row(row); + self.set_col(col); + } + + fn erase_display(&mut self, mode: EraseMode) { + match mode { + EraseMode::ToEnd => { + self.erase_line(EraseMode::ToEnd); + for row in self.cursor.row + 1..self.size.rows as usize { + self.clear_row(row); + } + } + EraseMode::ToStart => { + for row in 0..self.cursor.row { + self.clear_row(row); + } + self.erase_line(EraseMode::ToStart); + } + EraseMode::All => { + for row in 0..self.size.rows as usize { + self.clear_row(row); + } + } + EraseMode::Saved => { + if !self.alt_active { + self.main.history.clear(); + } + } + } + self.changed(); + } + + fn erase_line(&mut self, mode: EraseMode) { + let cols = self.size.cols as usize; + match mode { + EraseMode::All | EraseMode::Saved => self.clear_row(self.cursor.row), + EraseMode::ToEnd => self.clear_range(self.cursor.row, self.cursor.col, cols), + EraseMode::ToStart => self.clear_range(self.cursor.row, 0, self.cursor.col + 1), + } + self.cursor.pending_wrap = false; + self.changed(); + } + + fn erase_characters(&mut self, n: u32) { + let end = (self.cursor.col + n.max(1) as usize).min(self.size.cols as usize); + self.clear_range(self.cursor.row, self.cursor.col, end); + self.changed(); + } + + fn insert_characters(&mut self, n: u32) { + let cols = self.size.cols as usize; + let cursor_col = self.cursor.col; + let cursor_row = self.cursor.row; + let style = self.style; + let n = (n.max(1) as usize).min(cols - cursor_col); + self.normalize_row(cursor_row); + let row = &mut self.active_mut().rows[cursor_row].cells; + row[cursor_col..].rotate_right(n); + row[cursor_col..cursor_col + n].fill(blank(style)); + sanitize_row(row, style); + self.cursor.pending_wrap = false; + self.changed(); + } + + fn delete_characters(&mut self, n: u32) { + let cols = self.size.cols as usize; + let cursor_col = self.cursor.col; + let cursor_row = self.cursor.row; + let style = self.style; + let n = (n.max(1) as usize).min(cols - cursor_col); + self.normalize_row(cursor_row); + let row = &mut self.active_mut().rows[cursor_row].cells; + row[cursor_col..].rotate_left(n); + row[cols - n..].fill(blank(style)); + sanitize_row(row, style); + self.cursor.pending_wrap = false; + self.changed(); + } + + fn insert_lines(&mut self, n: u32) { + if self.cursor.row < self.scroll_top || self.cursor.row > self.scroll_bottom { + return; + } + let bottom = self.scroll_bottom; + let cursor_row = self.cursor.row; + let count = (n.max(1) as usize).min(bottom - cursor_row + 1); + for _ in 0..count { + self.active_mut().rows.remove(bottom); + let row = self.new_row(); + self.active_mut().rows.insert(cursor_row, row); + } + self.changed(); + } + + fn delete_lines(&mut self, n: u32) { + if self.cursor.row < self.scroll_top || self.cursor.row > self.scroll_bottom { + return; + } + let bottom = self.scroll_bottom; + let cursor_row = self.cursor.row; + let count = (n.max(1) as usize).min(bottom - cursor_row + 1); + for _ in 0..count { + self.active_mut().rows.remove(cursor_row); + let row = self.new_row(); + self.active_mut().rows.insert(bottom, row); + } + self.changed(); + } + + fn scroll_up(&mut self, n: usize) { + let cursor = self.cursor; + self.scroll_up_internal(n.max(1), None); + self.cursor = cursor; + self.changed(); + } + fn scroll_down(&mut self, n: usize) { + let top = self.scroll_top; + let bottom = self.scroll_bottom; + let count = n.max(1).min(bottom - top + 1); + for _ in 0..count { + self.active_mut().rows.remove(bottom); + let row = self.new_row(); + self.active_mut().rows.insert(top, row); + } + self.changed(); + } + + fn scroll_up_internal(&mut self, n: usize, continuation: Option<(u64, u32)>) { + let top = self.scroll_top; + let bottom = self.scroll_bottom; + let count = n.min(bottom - top + 1); + for index in 0..count { + let removed = self.active_mut().rows.remove(top); + if !self.alt_active && top == 0 && bottom + 1 == self.size.rows as usize { + self.main.history.push_back(removed); + } + let mut row = self.new_row(); + if index + 1 == count + && let Some((id, offset)) = continuation + { + row.logical_line_id = id; + row.cell_offset = offset; + } + self.active_mut().rows.insert(bottom, row); + } + self.cursor.row = bottom; + self.enforce_history_budget(); + } + + fn set_scrolling_region(&mut self, top: u32, bottom: Option) { + let top = top.saturating_sub(1) as usize; + let bottom = bottom.unwrap_or(self.size.rows).saturating_sub(1) as usize; + if top < bottom && bottom < self.size.rows as usize { + self.scroll_top = top; + self.scroll_bottom = bottom; + self.cursor.row = if self.modes.origin { top } else { 0 }; + self.cursor.col = 0; + self.cursor.pending_wrap = false; + self.changed(); + } + } + + fn set_alternate(&mut self, mode: AlternateScreenMode, enabled: bool) { + if enabled == self.alt_active { + return; + } + if enabled { + self.inactive_main_cursor = self.cursor; + if mode == AlternateScreenMode::Mode1049 { + self.saved_main_1049 = Some(self.capture_cursor()); + self.alt = Grid::new(self.size, &mut self.next_line_id); + self.cursor = Cursor::default(); + } else if mode == AlternateScreenMode::Mode1047 { + self.alt = Grid::new(self.size, &mut self.next_line_id); + self.cursor = Cursor::default(); + } else { + self.cursor = self.inactive_alt_cursor; + } + self.alt_active = true; + } else { + self.inactive_alt_cursor = self.cursor; + self.alt_active = false; + if mode == AlternateScreenMode::Mode1049 { + if let Some(saved) = self.saved_main_1049.take() { + self.restore_cursor(saved); + } else { + self.cursor = self.inactive_main_cursor; + } + } else { + self.cursor = self.inactive_main_cursor; + } + } + self.scroll_top = 0; + self.scroll_bottom = self.size.rows as usize - 1; + self.cursor.row = self.cursor.row.min(self.scroll_bottom); + self.cursor.col = self.cursor.col.min(self.size.cols as usize - 1); + self.cursor.pending_wrap = false; + self.changed(); + } + + fn set_mode(&mut self, mode: TerminalMode, enabled: bool) { + match mode { + TerminalMode::Insert => self.modes.insert = enabled, + TerminalMode::Origin => { + self.modes.origin = enabled; + self.cursor.row = if enabled { self.scroll_top } else { 0 }; + self.cursor.col = 0; + } + TerminalMode::AutoWrap => self.modes.autowrap = enabled, + TerminalMode::ApplicationCursor => self.modes.application_cursor = enabled, + TerminalMode::ApplicationKeypad => self.modes.application_keypad = enabled, + TerminalMode::CursorVisible => self.modes.cursor_visible = enabled, + TerminalMode::BracketedPaste => self.modes.bracketed_paste = enabled, + TerminalMode::FocusReporting => self.modes.focus_reporting = enabled, + TerminalMode::SynchronizedOutput => { + if enabled && !self.modes.synchronized_output { + self.publish(); + self.sync_started = Some(Instant::now()); + } + self.modes.synchronized_output = enabled; + if !enabled { + self.sync_started = None; + self.publish(); + } + } + TerminalMode::MouseX10 => { + self.mouse_x10 = enabled; + self.resolve_mouse_tracking(); + } + TerminalMode::MouseButton => { + self.mouse_button = enabled; + self.resolve_mouse_tracking(); + } + TerminalMode::MouseAny => { + self.mouse_any = enabled; + self.resolve_mouse_tracking(); + } + TerminalMode::MouseSgr => self.modes.mouse_sgr = enabled, + } + self.cursor.pending_wrap = false; + self.changed(); + } + + fn resolve_mouse_tracking(&mut self) { + self.modes.mouse_tracking = if self.mouse_any { + MouseTrackingMode::Any + } else if self.mouse_button { + MouseTrackingMode::Button + } else if self.mouse_x10 { + MouseTrackingMode::X10 + } else { + MouseTrackingMode::Off + }; + } + + fn device_reply(&self, request: DeviceRequest) -> Vec { + match request { + DeviceRequest::PrimaryAttributes => b"\x1b[?1;2c".to_vec(), + DeviceRequest::SecondaryAttributes => b"\x1b[>0;0;0c".to_vec(), + DeviceRequest::OperatingStatus => b"\x1b[0n".to_vec(), + DeviceRequest::CursorPosition => { + let row = if self.modes.origin { + self.cursor.row.saturating_sub(self.scroll_top) + 1 + } else { + self.cursor.row + 1 + }; + format!("\x1b[{row};{}R", self.cursor.col + 1).into_bytes() + } + } + } + + fn clear_range(&mut self, row: usize, start: usize, end: usize) { + if start >= end { + return; + } + self.clear_wide_at(row, start); + self.clear_wide_at(row, end.saturating_sub(1)); + let fill = blank(self.style); + self.active_mut().rows[row].cells[start..end].fill(fill); + } + fn clear_row(&mut self, row: usize) { + if row > 0 + && self.active().rows[row - 1].soft_wrapped + && self.active().rows[row - 1].logical_line_id + == self.active().rows[row].logical_line_id + { + self.active_mut().rows[row - 1].soft_wrapped = false; + self.break_chain_after(row - 1); + } + let fill = blank(self.style); + self.active_mut().rows[row].cells.fill(fill); + self.active_mut().rows[row].soft_wrapped = false; + self.break_chain_after(row); + } + fn clear_wide_at(&mut self, row: usize, col: usize) { + let cols = self.size.cols as usize; + if col >= cols { + return; + } + let lead = self.wide_lead(row, col); + let width = matches!( + self.active().rows[row] + .cells + .get(lead + 1) + .map(|c| &c.glyph), + Some(Glyph::Continuation) + ); + let fill = blank(self.style); + self.active_mut().rows[row].cells[lead] = fill.clone(); + if width { + self.active_mut().rows[row].cells[lead + 1] = fill; + } + } + fn wide_lead(&self, row: usize, col: usize) -> usize { + if matches!( + self.active().rows[row].cells[col].glyph, + Glyph::Continuation + ) && col > 0 + { + col - 1 + } else { + col + } + } + fn normalize_row(&mut self, row: usize) { + let style = self.style; + sanitize_row(&mut self.active_mut().rows[row].cells, style); + } + fn break_chain_after(&mut self, row: usize) { + let old_id = self.active().rows[row].logical_line_id; + if row + 1 >= self.active().rows.len() + || self.active().rows[row + 1].logical_line_id != old_id + { + return; + } + let cols = self.size.cols; + let mut assignments = Vec::new(); + let mut id = self.next_line_id; + self.next_line_id = self.next_line_id.saturating_add(1); + let mut offset = 0u32; + for index in row + 1..self.active().rows.len() { + let next = &self.active().rows[index]; + if next.logical_line_id != old_id { + break; + } + assignments.push((index, id, offset)); + if next.soft_wrapped { + offset = offset.saturating_add(cols); + } else { + id = self.next_line_id; + self.next_line_id = self.next_line_id.saturating_add(1); + offset = 0; + } + } + let grid = self.active_mut(); + for (index, id, offset) in assignments { + grid.rows[index].logical_line_id = id; + grid.rows[index].cell_offset = offset; + } + } + fn new_row(&mut self) -> TerminalRow { + let id = self.next_line_id; + self.next_line_id = self.next_line_id.saturating_add(1); + TerminalRow::new(self.size.cols as usize, id, self.style) + } + + fn enforce_history_budget(&mut self) { + let cols = self.size.cols as usize; + while self.main.history.len() > self.scrollback_rows + || self.main.history.len().saturating_mul(cols) > MAX_TERMINAL_HISTORY_CELLS + { + self.main.history.pop_front(); + } + } + + #[allow(clippy::too_many_lines)] + fn reflow_main(&mut self, size: CellSize) { + let old_cols = self.size.cols as usize; + let new_cols = size.cols as usize; + let main_cursor = if self.alt_active { + self.inactive_main_cursor + } else { + self.cursor + }; + let cursor_row = main_cursor.row.min(self.main.rows.len() - 1); + let cursor_id = self.main.rows[cursor_row].logical_line_id; + let cursor_offset = self.main.rows[cursor_row].cell_offset as usize + main_cursor.col; + let mut all: Vec = self.main.history.drain(..).collect(); + all.append(&mut self.main.rows); + let mut logical: Vec<(u64, usize, Vec)> = Vec::new(); + let mut previous_soft_wrapped = false; + for row in all { + let cursor_used = if row.logical_line_id == cursor_id + && cursor_offset >= row.cell_offset as usize + && cursor_offset < row.cell_offset as usize + old_cols + { + cursor_offset - row.cell_offset as usize + 1 + } else { + 0 + }; + let used = if row.soft_wrapped { + old_cols + } else { + last_nonblank(&row.cells).max(cursor_used) + }; + if previous_soft_wrapped + && let Some((id, base, cells)) = logical.last_mut() + && *id == row.logical_line_id + { + let start = row.cell_offset as usize - *base; + cells.resize(start, blank(self.style)); + cells.extend_from_slice(&row.cells[..used]); + } else { + logical.push(( + row.logical_line_id, + row.cell_offset as usize, + row.cells[..used].to_vec(), + )); + } + previous_soft_wrapped = row.soft_wrapped; + } + + let mut rows = Vec::new(); + let mut mapped_cursor_offset = cursor_offset; + for (id, base, mut cells) in logical { + sanitize_row(&mut cells, self.style); + if cells.is_empty() { + rows.push(TerminalRow::new(new_cols, id, self.style)); + continue; + } + let cursor_source = (id == cursor_id).then(|| cursor_offset.saturating_sub(base)); + let mut source = 0usize; + let mut physical = 0usize; + let mut col = 0usize; + let mut part = vec![blank(self.style); new_cols]; + while source < cells.len() { + if matches!(cells[source].glyph, Glyph::Continuation) { + source += 1; + continue; + } + let source_width = glyph_width(&cells[source].glyph).max(1); + let mut display_width = source_width.min(2); + let mut glyph = cells[source].glyph.clone(); + if display_width == 2 && new_cols == 1 { + glyph = Glyph::Char('\u{fffd}'); + display_width = 1; + } + if display_width == 2 && col + 2 > new_cols { + rows.push(TerminalRow { + cells: part, + logical_line_id: id, + cell_offset: (base + physical) as u32, + soft_wrapped: true, + }); + physical += new_cols; + col = 0; + part = vec![blank(self.style); new_cols]; + } + if let Some(cursor_source) = cursor_source + && cursor_source >= source + && cursor_source < source + source_width + { + mapped_cursor_offset = + base + physical + col + (cursor_source - source).min(display_width - 1); + } + part[col] = cell(glyph, cells[source].style); + if display_width == 2 { + part[col + 1] = cell(Glyph::Continuation, cells[source].style); + } + col += display_width; + source += source_width; + if col == new_cols && source < cells.len() { + rows.push(TerminalRow { + cells: part, + logical_line_id: id, + cell_offset: (base + physical) as u32, + soft_wrapped: true, + }); + physical += new_cols; + col = 0; + part = vec![blank(self.style); new_cols]; + } + } + rows.push(TerminalRow { + cells: part, + logical_line_id: id, + cell_offset: (base + physical) as u32, + soft_wrapped: false, + }); + } + while rows.len() < size.rows as usize { + let id = self.next_line_id; + self.next_line_id += 1; + rows.push(TerminalRow::new(new_cols, id, self.style)); + } + let split = rows.len().saturating_sub(size.rows as usize); + self.main.history = rows.drain(..split).collect(); + self.main.rows = rows; + let mapped = self + .main + .rows + .iter() + .enumerate() + .find_map(|(idx, row)| { + if row.logical_line_id == cursor_id + && mapped_cursor_offset >= row.cell_offset as usize + && mapped_cursor_offset < row.cell_offset as usize + new_cols + { + Some(Cursor { + row: idx, + col: mapped_cursor_offset - row.cell_offset as usize, + pending_wrap: false, + }) + } else { + None + } + }) + .unwrap_or(Cursor { + row: size.rows as usize - 1, + col: 0, + pending_wrap: false, + }); + if self.alt_active { + self.inactive_main_cursor = mapped; + } else { + self.cursor = mapped; + } + } + + fn changed(&mut self) { + self.generation = self.generation.saturating_add(1); + } + fn current_snapshot(&self) -> ScreenSnapshot { + ScreenSnapshot { + size: self.size, + cells: flatten(&self.active().rows), + cursor: self + .modes + .cursor_visible + .then(|| CellCoord::new(self.cursor.row as u32, self.cursor.col as u32)), + title: self.title.clone(), + generation: self.generation, + } + } + fn publish(&mut self) { + self.published = self.current_snapshot(); + } +} + +impl Grid { + fn new(size: CellSize, next_id: &mut u64) -> Self { + let mut rows = Vec::with_capacity(size.rows as usize); + for _ in 0..size.rows { + rows.push(TerminalRow::new( + size.cols as usize, + *next_id, + Style::default(), + )); + *next_id += 1; + } + Self { + rows, + history: VecDeque::new(), + } + } +} + +impl TerminalRow { + fn new(cols: usize, logical_line_id: u64, style: Style) -> Self { + Self { + cells: vec![blank(style); cols], + logical_line_id, + cell_offset: 0, + soft_wrapped: false, + } + } +} + +fn validate_size(size: CellSize) -> Result<(), ScreenError> { + let area = size.rows as usize * size.cols as usize; + if size.rows == 0 + || size.cols == 0 + || size.rows > u32::from(MAX_TERMINAL_ROWS) + || size.cols > u32::from(MAX_TERMINAL_COLS) + || area > MAX_TERMINAL_VISIBLE_CELLS + { + Err(ScreenError::InvalidSize(size)) + } else { + Ok(()) + } +} +fn default_tab_stops(cols: usize) -> BTreeSet { + (8..cols).step_by(8).collect() +} +fn blank(style: Style) -> Cell { + cell(Glyph::Char(' '), style) +} +fn cell(glyph: Glyph, style: Style) -> Cell { + Cell { + glyph, + style, + attachment: None, + } +} +fn is_blank(cell: &Cell) -> bool { + matches!(cell.glyph, Glyph::Char(' ')) + && cell.style == Style::default() + && cell.attachment.is_none() +} +fn flatten(rows: &[TerminalRow]) -> Vec { + rows.iter() + .flat_map(|row| row.cells.iter().cloned()) + .collect() +} +fn last_nonblank(cells: &[Cell]) -> usize { + cells + .iter() + .rposition(|c| !is_blank(c)) + .map_or(0, |i| i + 1) +} +fn glyph_bytes(glyph: &Glyph) -> Vec { + match glyph { + Glyph::Char(ch) => { + let mut buf = [0; 4]; + ch.encode_utf8(&mut buf).as_bytes().to_vec() + } + Glyph::Cluster(bytes) => bytes.to_vec(), + Glyph::Continuation => Vec::new(), + } +} +fn sanitize_row(cells: &mut [Cell], style: Style) { + for i in 0..cells.len() { + if matches!(cells[i].glyph, Glyph::Continuation) + && (i == 0 || glyph_width(&cells[i - 1].glyph) != 2) + { + cells[i] = blank(style); + } + } + for i in 0..cells.len() { + if glyph_width(&cells[i].glyph) == 2 + && (i + 1 == cells.len() || !matches!(cells[i + 1].glyph, Glyph::Continuation)) + { + cells[i] = blank(style); + } + } +} +fn glyph_width(glyph: &Glyph) -> usize { + match glyph { + Glyph::Char(ch) => UnicodeWidthChar::width(*ch).unwrap_or(0).min(2), + Glyph::Cluster(bytes) => std::str::from_utf8(bytes) + .ok() + .map_or(1, |cluster| UnicodeWidthStr::width(cluster).min(2)), + Glyph::Continuation => 0, + } +} +fn resize_grid_clip( + grid: &mut Grid, + old: CellSize, + new: CellSize, + next_id: &mut u64, + style: Style, +) { + let new_cols = new.cols as usize; + for row in &mut grid.rows { + row.cells.truncate(new_cols); + row.cells.resize(new_cols, blank(style)); + sanitize_row(&mut row.cells, style); + } + grid.rows.truncate(new.rows as usize); + while grid.rows.len() < new.rows as usize { + grid.rows.push(TerminalRow::new(new_cols, *next_id, style)); + *next_id += 1; + } + grid.history.clear(); + let _ = old; +} +fn sanitize_title(value: &str) -> String { + let mut value: String = value + .chars() + .map(|ch| if ch.is_control() { ' ' } else { ch }) + .collect(); + if value.len() > MAX_TERMINAL_METADATA_BYTES { + let mut end = MAX_TERMINAL_METADATA_BYTES; + while !value.is_char_boundary(end) { + end -= 1; + } + value.truncate(end); + } + value +} +fn dec_special_graphics(ch: char) -> char { + match ch { + '`' => '◆', + 'a' => '▒', + 'f' => '°', + 'g' => '±', + 'j' => '┘', + 'k' => '┐', + 'l' => '┌', + 'm' => '└', + 'n' => '┼', + 'o' => '⎺', + 'p' => '⎻', + 'q' => '─', + 'r' => '⎼', + 's' => '⎽', + 't' => '├', + 'u' => '┤', + 'v' => '┴', + 'w' => '┬', + 'x' => '│', + 'y' => '≤', + 'z' => '≥', + '{' => 'π', + '|' => '≠', + '}' => '£', + '~' => '·', + _ => ch, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ansi::{AnsiParser, AnsiParserProfile}; + + fn screen(rows: u32, cols: u32) -> TerminalScreen { + TerminalScreen::new(CellSize::new(rows, cols), 10_000).unwrap() + } + fn text(snapshot: &ScreenSnapshot) -> String { + snapshot + .cells + .iter() + .map(|c| match &c.glyph { + Glyph::Char(ch) => *ch, + Glyph::Cluster(bytes) => { + std::str::from_utf8(bytes).unwrap().chars().next().unwrap() + } + Glyph::Continuation => '·', + }) + .collect() + } + + #[test] + fn parser_split_points_produce_identical_screen() { + let bytes = b"ab\x1b[2;3Hc\x1b[31mD\x1b[2J\x1b[Hdone"; + let mut whole_parser = AnsiParser::with_profile(AnsiParserProfile::FullScreen); + let mut whole = screen(3, 8); + for event in whole_parser.feed(bytes) { + whole.apply_event(event); + } + for split in 0..=bytes.len() { + let mut parser = AnsiParser::with_profile(AnsiParserProfile::FullScreen); + let mut split_screen = screen(3, 8); + for event in parser + .feed(&bytes[..split]) + .into_iter() + .chain(parser.feed(&bytes[split..])) + { + split_screen.apply_event(event); + } + assert_eq!(split_screen.snapshot(), whole.snapshot(), "split {split}"); + } + } + + #[test] + fn wide_combining_and_overwrite_keep_invariants() { + let mut s = screen(2, 4); + s.apply_event(AnsiEvent::Text("中e\u{301}".into())); + assert!(matches!(s.snapshot().cells[1].glyph, Glyph::Continuation)); + assert!(matches!(s.snapshot().cells[2].glyph, Glyph::Cluster(_))); + s.apply_event(AnsiEvent::CursorPosition { row: 1, col: 2 }); + s.apply_event(AnsiEvent::Text("x".into())); + assert!(matches!(s.snapshot().cells[0].glyph, Glyph::Char(' '))); + assert!(matches!(s.snapshot().cells[1].glyph, Glyph::Char('x'))); + } + + #[test] + fn alternate_screen_preserves_main_and_has_no_history() { + let mut s = screen(2, 4); + s.apply_event(AnsiEvent::Text("main".into())); + let main = s.snapshot(); + s.apply_event(AnsiEvent::AlternateScreen { + mode: AlternateScreenMode::Mode1049, + enabled: true, + }); + s.apply_event(AnsiEvent::Text("alt\nmore".into())); + assert!(s.history().is_empty()); + s.apply_event(AnsiEvent::AlternateScreen { + mode: AlternateScreenMode::Mode1049, + enabled: false, + }); + assert_eq!(&s.snapshot().cells[..4], &main.cells[..4]); + } + + #[test] + fn acs_and_device_replies_are_exact() { + let mut s = screen(4, 8); + s.apply_event(AnsiEvent::DesignateCharacterSet { + slot: CharacterSetSlot::G0, + charset: CharacterSet::DecSpecialGraphics, + }); + s.apply_event(AnsiEvent::Text("lqk".into())); + assert!(text(&s.snapshot()).starts_with("┌─┐")); + assert_eq!( + s.apply_event(AnsiEvent::DeviceRequest(DeviceRequest::PrimaryAttributes)), + Some(b"\x1b[?1;2c".to_vec()) + ); + assert_eq!( + s.apply_event(AnsiEvent::DeviceRequest(DeviceRequest::SecondaryAttributes)), + Some(b"\x1b[>0;0;0c".to_vec()) + ); + assert_eq!( + s.apply_event(AnsiEvent::DeviceRequest(DeviceRequest::OperatingStatus)), + Some(b"\x1b[0n".to_vec()) + ); + assert_eq!( + s.apply_event(AnsiEvent::DeviceRequest(DeviceRequest::CursorPosition)), + Some(b"\x1b[1;4R".to_vec()) + ); + } + + #[test] + fn synchronized_output_gates_snapshot_and_finish_releases() { + let mut s = screen(2, 4); + let before = s.snapshot(); + s.apply_event(AnsiEvent::SetMode { + mode: TerminalMode::SynchronizedOutput, + enabled: true, + }); + s.apply_event(AnsiEvent::Text("x".into())); + assert_eq!(s.snapshot(), before); + s.finish_output(); + assert_ne!(s.snapshot(), before); + } + + #[test] + fn main_reflows_soft_wrap_and_alt_clips() { + let mut s = screen(2, 4); + s.apply_event(AnsiEvent::Text("abcdef".into())); + let id = s.visible_rows()[0].logical_line_id; + s.resize(CellSize::new(3, 3)).unwrap(); + assert_eq!(s.visible_rows()[0].logical_line_id, id); + assert_eq!(s.visible_rows()[1].logical_line_id, id); + assert!(s.visible_rows()[0].soft_wrapped); + s.apply_event(AnsiEvent::AlternateScreen { + mode: AlternateScreenMode::Mode1049, + enabled: true, + }); + s.apply_event(AnsiEvent::Text("abcdef".into())); + s.resize(CellSize::new(3, 2)).unwrap(); + assert_eq!(text(&s.snapshot())[..4].to_string(), "abde"); + } + + #[test] + fn history_obeys_row_and_cell_caps() { + let mut s = TerminalScreen::new(CellSize::new(2, 512), 2).unwrap(); + for _ in 0..6 { + s.apply_event(AnsiEvent::LineFeed); + } + assert_eq!(s.history().len(), 2); + let mut budget = TerminalScreen::new(CellSize::new(2, 512), 20_000).unwrap(); + for _ in 0..8_000 { + budget.apply_event(AnsiEvent::LineFeed); + } + assert!(budget.history().len() * 512 <= MAX_TERMINAL_HISTORY_CELLS); + } + + #[test] + fn annotation_is_default_style_hard_line() { + let mut s = screen(3, 20); + s.apply_event(AnsiEvent::Text("partial".into())); + s.append_process_annotation("Process 1 exited normally with code 0"); + let snapshot = s.snapshot(); + assert!(text(&snapshot).contains("Process 1 exited")); + assert!(!s.visible_rows()[2].soft_wrapped); + assert_eq!(snapshot.cursor.unwrap().row, 2); + assert!(matches!( + snapshot.cells[2 * 20 + 16].glyph, + Glyph::Char('0') + )); + } + + #[test] + fn annotation_remains_visible_without_trailing_scroll_on_one_row() { + let mut s = screen(1, 64); + s.append_process_annotation("Process 7 exited normally with code 0"); + let snapshot = s.snapshot(); + assert!(text(&snapshot).starts_with("Process 7 exited normally with code 0")); + assert_eq!(snapshot.cursor, Some(CellCoord::new(0, 37))); + assert!(s.history().is_empty()); + assert!(!s.visible_rows()[0].soft_wrapped); + } + #[test] + fn invalid_resize_is_atomic() { + let mut s = screen(2, 2); + let before = s.snapshot(); + assert!(s.resize(CellSize::new(0, 2)).is_err()); + assert_eq!(s.snapshot(), before); + } + + #[test] + fn cursor_erase_insert_delete_scroll_and_margins_mutate_exact_regions() { + let mut s = screen(4, 6); + s.apply_event(AnsiEvent::Text("abcdef".into())); + s.apply_event(AnsiEvent::CursorPosition { row: 1, col: 3 }); + s.apply_event(AnsiEvent::DeleteCharacters(2)); + assert!(text(&s.snapshot()).starts_with("abef ")); + s.apply_event(AnsiEvent::InsertCharacters(2)); + assert!(text(&s.snapshot()).starts_with("ab ef")); + s.apply_event(AnsiEvent::EraseLineMode(EraseMode::ToStart)); + assert!(text(&s.snapshot()).starts_with(" ef")); + s.apply_event(AnsiEvent::SetScrollingRegion { + top: 2, + bottom: Some(3), + }); + s.apply_event(AnsiEvent::CursorPosition { row: 2, col: 1 }); + s.apply_event(AnsiEvent::InsertLines(1)); + assert_eq!(s.snapshot().cells.len(), 24); + s.apply_event(AnsiEvent::ScrollUp(1)); + assert_eq!(s.snapshot().cells.len(), 24); + assert_eq!( + s.history().len(), + 0, + "partial-region scroll never enters history" + ); + } + + #[test] + fn alternate_and_dec_saved_cursors_are_independent() { + let mut s = screen(4, 8); + s.apply_event(AnsiEvent::CursorPosition { row: 3, col: 4 }); + s.apply_event(AnsiEvent::SaveCursor); + s.apply_event(AnsiEvent::AlternateScreen { + mode: AlternateScreenMode::Mode47, + enabled: true, + }); + s.apply_event(AnsiEvent::CursorPosition { row: 2, col: 2 }); + s.apply_event(AnsiEvent::SaveCursor); + s.apply_event(AnsiEvent::CursorPosition { row: 4, col: 8 }); + s.apply_event(AnsiEvent::RestoreCursor); + assert_eq!(s.snapshot().cursor, Some(CellCoord::new(1, 1))); + s.apply_event(AnsiEvent::AlternateScreen { + mode: AlternateScreenMode::Mode47, + enabled: false, + }); + s.apply_event(AnsiEvent::RestoreCursor); + assert_eq!(s.snapshot().cursor, Some(CellCoord::new(2, 3))); + } + + #[test] + fn watchdog_releases_sync_and_mouse_mode_resets_do_not_clobber_stronger_mode() { + let mut s = screen(2, 4); + s.apply_event(AnsiEvent::SetMode { + mode: TerminalMode::MouseAny, + enabled: true, + }); + s.apply_event(AnsiEvent::SetMode { + mode: TerminalMode::MouseX10, + enabled: true, + }); + s.apply_event(AnsiEvent::SetMode { + mode: TerminalMode::MouseX10, + enabled: false, + }); + assert_eq!(s.modes().mouse_tracking, MouseTrackingMode::Any); + s.apply_event(AnsiEvent::SetMode { + mode: TerminalMode::SynchronizedOutput, + enabled: true, + }); + s.apply_event(AnsiEvent::Text("x".into())); + let start = s.sync_started.unwrap(); + assert!(s.synchronized_watchdog_expired(start + SYNCHRONIZED_OUTPUT_WATCHDOG)); + assert!(!s.modes().synchronized_output); + assert!(text(&s.snapshot()).starts_with('x')); + } + + #[test] + fn split_zwj_cluster_is_bounded_and_keeps_wide_continuation() { + let mut s = screen(2, 8); + s.apply_event(AnsiEvent::Text("👩\u{200d}".into())); + s.apply_event(AnsiEvent::Text("💻".into())); + let snapshot = s.snapshot(); + assert!(matches!(snapshot.cells[0].glyph, Glyph::Cluster(_))); + assert!(matches!(snapshot.cells[1].glyph, Glyph::Continuation)); + for _ in 0..MAX_TERMINAL_GRAPHEME_BYTES { + s.apply_event(AnsiEvent::Text("\u{301}".into())); + } + if let Glyph::Cluster(bytes) = &s.snapshot().cells[0].glyph { + assert!(bytes.len() <= MAX_TERMINAL_GRAPHEME_BYTES); + } else { + panic!("cluster expected"); + } + } + + #[test] + fn split_regional_indicator_modifier_zwj_and_variation_sequences_are_single_cells() { + let cases = ["🇺🇸", "👍🏽", "👩\u{200d}💻", "❤\u{fe0f}"]; + for sequence in cases { + let mut s = screen(2, 8); + for ch in sequence.chars() { + s.apply_event(AnsiEvent::Text(ch.to_string())); + } + let snapshot = s.snapshot(); + let Glyph::Cluster(bytes) = &snapshot.cells[0].glyph else { + panic!("cluster expected for {sequence}"); + }; + assert_eq!(std::str::from_utf8(bytes).unwrap(), sequence); + assert_eq!(glyph_width(&snapshot.cells[0].glyph), 2); + assert!(matches!(snapshot.cells[1].glyph, Glyph::Continuation)); + assert!( + !snapshot.cells[2..] + .iter() + .any(|cell| matches!(cell.glyph, Glyph::Continuation)) + ); + } + } + + #[test] + fn reflow_carries_wide_glyph_whole_across_new_boundary() { + let mut s = screen(2, 6); + s.apply_event(AnsiEvent::Text("ab中c".into())); + s.apply_event(AnsiEvent::CursorPosition { row: 1, col: 5 }); + s.resize(CellSize::new(2, 3)).unwrap(); + let snapshot = s.snapshot(); + assert!(matches!(snapshot.cells[0].glyph, Glyph::Char('中'))); + assert!(matches!(snapshot.cells[1].glyph, Glyph::Continuation)); + assert!(matches!(snapshot.cells[2].glyph, Glyph::Char('c'))); + assert_eq!(s.history().back().unwrap().cells[0].glyph, Glyph::Char('a')); + assert_eq!(s.history().back().unwrap().cells[1].glyph, Glyph::Char('b')); + } + + #[test] + fn reflow_preserves_cursor_in_trailing_blank_cells() { + let mut s = screen(2, 6); + let line_id = s.visible_rows()[0].logical_line_id; + s.apply_event(AnsiEvent::CursorPosition { row: 1, col: 5 }); + s.resize(CellSize::new(2, 3)).unwrap(); + assert_eq!(s.snapshot().cursor, Some(CellCoord::new(0, 1))); + assert_eq!(s.visible_rows()[0].logical_line_id, line_id); + s.resize(CellSize::new(2, 6)).unwrap(); + assert_eq!(s.snapshot().cursor, Some(CellCoord::new(0, 4))); + assert_eq!(s.visible_rows()[0].logical_line_id, line_id); + } + + #[test] + fn no_autowrap_wide_at_right_edge_never_scrolls_or_orphans() { + let mut s = screen(2, 4); + s.apply_event(AnsiEvent::SetMode { + mode: TerminalMode::AutoWrap, + enabled: false, + }); + s.apply_event(AnsiEvent::CursorPosition { row: 1, col: 4 }); + s.apply_event(AnsiEvent::Text("中".into())); + let snapshot = s.snapshot(); + assert_eq!(snapshot.cursor, Some(CellCoord::new(0, 3))); + assert!(matches!(snapshot.cells[3].glyph, Glyph::Char('\u{fffd}'))); + assert!(s.history().is_empty()); + assert!( + !snapshot + .cells + .iter() + .any(|cell| matches!(cell.glyph, Glyph::Continuation)) + ); + } + + #[test] + fn variation_selector_width_change_at_right_edge_wraps_atomically() { + let mut s = screen(2, 4); + s.apply_event(AnsiEvent::Text("abc❤".into())); + s.apply_event(AnsiEvent::Text("\u{fe0f}".into())); + let snapshot = s.snapshot(); + assert!(matches!(snapshot.cells[3].glyph, Glyph::Char(' '))); + assert!(matches!(snapshot.cells[4].glyph, Glyph::Cluster(_))); + assert!(matches!(snapshot.cells[5].glyph, Glyph::Continuation)); + assert_eq!(snapshot.cursor, Some(CellCoord::new(1, 2))); + } + #[test] + fn title_is_single_line_control_free_and_utf8_bounded() { + let mut s = screen(2, 4); + s.apply_event(AnsiEvent::SetTitle(format!( + "a\r\nb\x1bc{}", + "é".repeat(600) + ))); + let title = s.snapshot().title.unwrap(); + assert_eq!(&title[..6], "a b c"); + assert!(!title.chars().any(char::is_control)); + assert!(title.len() <= MAX_TERMINAL_METADATA_BYTES); + assert!(title.is_char_boundary(title.len())); + } + + #[test] + fn hard_break_splits_a_previous_soft_wrap_chain_before_reflow() { + let mut s = screen(3, 4); + s.apply_event(AnsiEvent::Text("abcdefgh".into())); + let wrapped_id = s.visible_rows()[0].logical_line_id; + assert_eq!(s.visible_rows()[1].logical_line_id, wrapped_id); + s.apply_event(AnsiEvent::CursorPosition { row: 1, col: 1 }); + s.apply_event(AnsiEvent::LineFeed); + assert!(!s.visible_rows()[0].soft_wrapped); + assert_ne!(s.visible_rows()[1].logical_line_id, wrapped_id); + let hard_id = s.visible_rows()[1].logical_line_id; + s.resize(CellSize::new(3, 8)).unwrap(); + assert_eq!(s.visible_rows()[0].logical_line_id, wrapped_id); + assert_eq!(s.visible_rows()[1].logical_line_id, hard_id); + assert_eq!(&text(&s.snapshot())[..12], "abcd efgh"); + } + + #[test] + fn styled_trailing_blanks_survive_reflow_and_count_as_content() { + let mut s = screen(2, 4); + let style = Style { + bg: crate::cell::Color::Indexed(4), + underline: crate::cell::UnderlineStyle::Single, + ..Style::default() + }; + s.apply_event(AnsiEvent::SetStyle(style)); + s.apply_event(AnsiEvent::Text(" ".into())); + s.apply_event(AnsiEvent::CursorPosition { row: 1, col: 2 }); + s.resize(CellSize::new(2, 2)).unwrap(); + assert_eq!(s.snapshot().cells[0].style, style); + assert_eq!(s.snapshot().cells[1].style, style); + s.resize(CellSize::new(2, 4)).unwrap(); + assert_eq!(s.snapshot().cells[0].style, style); + assert_eq!(s.snapshot().cells[1].style, style); + s.apply_event(AnsiEvent::CursorPosition { row: 1, col: 1 }); + s.apply_event(AnsiEvent::EraseLineMode(EraseMode::All)); + s.append_process_annotation("exit"); + assert!(text(&s.snapshot()).contains("exit")); + assert_eq!(s.snapshot().cursor.unwrap().row, 1); + } +} diff --git a/src/terminal/session.rs b/src/terminal/session.rs new file mode 100644 index 0000000..12bb1ed --- /dev/null +++ b/src/terminal/session.rs @@ -0,0 +1,599 @@ +//! Terminal process/session registry. + +use std::cell::RefCell; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::rc::Rc; +use std::time::Instant; + +use thiserror::Error; + +use crate::ansi::AnsiParserProfile; +use crate::buffer::{Buffer, BufferId}; +use crate::cell::{Cell, CellCoord, CellSize}; +use crate::editor_core::EditorCore; +use crate::process::{ + ProcessEventKind, ProcessId, ProcessMode, ProcessSpec, ProcessState, ProcessSupervisor, + RestartPolicy, StdinMode, TerminalMode, +}; +use crate::terminal::screen::TerminalScreen; +use crate::terminal::{ + MAX_TERMINAL_COLS, MAX_TERMINAL_HISTORY_CELLS, MAX_TERMINAL_METADATA_BYTES, MAX_TERMINAL_ROWS, + MAX_TERMINAL_VISIBLE_CELLS, +}; + +/// Shared single-owner terminal registry used by editor and future Lua bindings. +pub type SharedTerminalManager = Rc>; + +/// 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, + /// Working directory, or the editor process directory when absent. + pub cwd: Option, + /// Environment overrides inherited by the child. + pub env: Vec<(String, String)>, + /// Identity-buffer name. Defaults to `*terminal:*`. + pub name: Option, + /// 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) -> 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, + /// Visible child cursor, if enabled. + pub cursor: Option, + /// Sanitized child title. + pub title: Option, + /// Published screen generation. + pub screen_generation: u64, + /// Context selection. Empty in context-free Stage 1 snapshots. + pub selection: Vec, + /// 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, + process_to_buffer: HashMap, + /// Removed buffers whose children are still being reaped. Their events + /// remain manager-owned so Lua/LSP/MCP consumers cannot steal a batch. + closing: HashSet, +} + +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 { + spec.validate()?; + let size = CellSize::new(u32::from(spec.rows), u32::from(spec.cols)); + let screen = TerminalScreen::new(size, spec.scrollback_rows) + .map_err(|error| TerminalError::Screen(error.to_string()))?; + + let buffer_name = spec.buffer_name(); + let buffer_id = BufferId::next(); + let mut buffer = Buffer::new(buffer_id, buffer_name.clone()); + buffer.set_read_only(true); + core.registry.borrow_mut().insert(buffer); + + let mut process_spec = ProcessSpec::new(buffer_name, spec.command); + process_spec.args = spec.args; + process_spec.cwd = spec.cwd; + process_spec.env = spec.env; + process_spec.mode = ProcessMode::Pty { + rows: spec.rows, + cols: spec.cols, + mode: TerminalMode::Raw, + }; + process_spec.restart = RestartPolicy::Never; + process_spec.ansi_events = true; + process_spec.ansi_profile = AnsiParserProfile::FullScreen; + process_spec.stdin = StdinMode::Piped; + process_spec.group = false; + + let process_id = match supervisor.spawn_terminal(process_spec) { + Ok(id) => id, + Err(error) => { + core.registry + .borrow_mut() + .remove(buffer_id) + .map_err(|rollback| { + TerminalError::Buffer(format!( + "spawn failed ({error}); buffer rollback failed: {rollback}" + )) + })?; + return Err(TerminalError::Spawn(error)); + } + }; + let pid = + if let Some(ProcessState::Running { pid, .. } | ProcessState::Exiting { pid, .. }) = + supervisor.state(process_id) + { + *pid + } else { + let _ = supervisor.terminate(process_id); + let _ = core.registry.borrow_mut().remove(buffer_id); + return Err(TerminalError::Spawn( + "supervisor published a PTY without a running pid".into(), + )); + }; + + let previous = self.sessions.insert( + buffer_id, + TerminalSession { + process_id, + pid, + screen, + process: TerminalProcessState::Running, + annotated: false, + }, + ); + debug_assert!(previous.is_none(), "fresh BufferId collided"); + self.process_to_buffer.insert(process_id, buffer_id); + core.set_round_trip_input(buffer_id, true); + Ok(buffer_id) + } + + /// Whether `buffer_id` identifies a published terminal session. + #[must_use] + pub fn is_terminal(&self, buffer_id: BufferId) -> bool { + self.sessions.contains_key(&buffer_id) + } + + /// Owned process id for a terminal buffer. The OS pid stays in snapshots. + #[must_use] + pub fn process_id(&self, buffer_id: BufferId) -> Option { + 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 { + 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 = 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 = self.closing.iter().copied().collect(); + for process_id in closing { + // Continue owning and discarding every final batch until reaped. + let _ = supervisor.take_events(process_id); + if matches!( + supervisor.state(process_id), + Some(ProcessState::Terminated(_)) | None + ) { + let _ = supervisor.forget(process_id); + self.closing.remove(&process_id); + } + } + } + + /// Queue raw terminal input for a running child. + pub fn send( + &self, + buffer_id: BufferId, + bytes: &[u8], + supervisor: &mut ProcessSupervisor, + ) -> Result<(), TerminalError> { + let session = self + .sessions + .get(&buffer_id) + .ok_or(TerminalError::NotTerminal(buffer_id))?; + supervisor + .write_stdin(session.process_id, bytes) + .map_err(TerminalError::Process) + } + + /// Resize a terminal screen and its PTY after validating shared limits. + pub fn resize( + &mut self, + buffer_id: BufferId, + rows: u16, + cols: u16, + supervisor: &mut ProcessSupervisor, + ) -> Result<(), TerminalError> { + validate_size(rows, cols)?; + let session = self + .sessions + .get_mut(&buffer_id) + .ok_or(TerminalError::NotTerminal(buffer_id))?; + if matches!(session.process, TerminalProcessState::Running) { + supervisor + .resize_pty(session.process_id, rows, cols) + .map_err(TerminalError::Process)?; + } + session + .screen + .resize(CellSize::new(u32::from(rows), u32::from(cols))) + .map_err(|error| TerminalError::Screen(error.to_string())) + } + + /// Request SIGTERM. Snapshot state stays `Running` until the outcome event. + pub fn terminate( + &mut self, + buffer_id: BufferId, + supervisor: &mut ProcessSupervisor, + ) -> Result<(), TerminalError> { + let session = self + .sessions + .get(&buffer_id) + .ok_or(TerminalError::NotTerminal(buffer_id))?; + if matches!(session.process, TerminalProcessState::Running) { + supervisor + .terminate(session.process_id) + .map_err(TerminalError::Process)?; + } + Ok(()) + } + + /// Tear down sessions whose identity buffers were removed by any path. + pub fn prune(&mut self, core: &EditorCore, supervisor: &mut ProcessSupervisor) { + let removed: Vec = { + let registry = core.registry.borrow(); + self.sessions + .keys() + .copied() + .filter(|buffer_id| !registry.contains(*buffer_id)) + .collect() + }; + for buffer_id in removed { + let Some(session) = self.sessions.remove(&buffer_id) else { + continue; + }; + self.process_to_buffer.remove(&session.process_id); + match supervisor.state(session.process_id) { + Some( + ProcessState::Starting + | ProcessState::Running { .. } + | ProcessState::Exiting { .. }, + ) => { + let _ = supervisor.terminate(session.process_id); + self.closing.insert(session.process_id); + } + Some(ProcessState::Terminated(_)) => { + let _ = supervisor.take_events(session.process_id); + let _ = supervisor.forget(session.process_id); + } + None => {} + } + } + } + + /// Terminate every terminal child and unpublish all sessions. + /// + /// The editor follows this with the supervisor's bounded global shutdown, + /// which performs final TERM/KILL escalation for terminal and non-terminal + /// processes alike. + pub fn shutdown(&mut self, supervisor: &mut ProcessSupervisor) { + let process_ids: Vec = 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 +} diff --git a/tests/vterm_stage1_acceptance.rs b/tests/vterm_stage1_acceptance.rs new file mode 100644 index 0000000..f86d429 --- /dev/null +++ b/tests/vterm_stage1_acceptance.rs @@ -0,0 +1,422 @@ +//! Shared Stage 1 terminal registry, lifecycle, and read-only acceptance. + +use std::time::{Duration, Instant}; + +use pmacs::buffer::{Buffer, BufferError, BufferId, EditOp}; +use pmacs::cell::Glyph; +use pmacs::editor::EditorState; +use pmacs::process::ProcessState; +use pmacs::rope::Range; +use pmacs::terminal::{TerminalProcessState, TerminalSpec}; + +fn rope_bytes(buffer: &Buffer) -> Vec { + let mut bytes = vec![0; buffer.len() as usize]; + if !bytes.is_empty() { + buffer.snapshot_rope().slice(0, buffer.len(), &mut bytes); + } + bytes +} + +fn screen_text(snapshot: &pmacs::terminal::TerminalSnapshot) -> String { + let mut text = String::new(); + for (index, cell) in snapshot.cells.iter().enumerate() { + if index > 0 && index % snapshot.size.cols as usize == 0 { + text.push('\n'); + } + match &cell.glyph { + Glyph::Char(ch) => text.push(*ch), + Glyph::Cluster(bytes) => text.push_str(&String::from_utf8_lossy(bytes)), + Glyph::Continuation => {} + } + } + text +} + +fn tick_until( + state: &mut EditorState, + timeout: Duration, + mut done: impl FnMut(&EditorState) -> bool, +) { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + state.tick_processes(); + if done(state) { + return; + } + std::thread::sleep(Duration::from_millis(5)); + } + panic!("terminal condition did not settle before {timeout:?}"); +} + +#[test] +fn spawn_failure_is_transactional() { + let mut state = EditorState::new(); + let buffers_before = state.core.borrow().registry.borrow().len(); + let processes_before = state.process_supervisor.borrow().ids().count(); + state.process_supervisor.borrow_mut().shutdown(); + let result = state.open_terminal(TerminalSpec::new("/bin/sh")); + + assert!(result.is_err()); + assert_eq!(state.core.borrow().registry.borrow().len(), buffers_before); + assert_eq!(state.terminal_manager.borrow().len(), 0); + assert_eq!( + state.process_supervisor.borrow().ids().count(), + processes_before + ); +} + +#[test] +fn strict_owned_spec_rejects_before_spawn_and_is_mutation_independent() { + let mut state = EditorState::new(); + let buffers_before = state.core.borrow().registry.borrow().len(); + let mut invalid = TerminalSpec::new("/bin/sh"); + invalid.rows = 0; + assert!(state.open_terminal(invalid).is_err()); + assert_eq!(state.core.borrow().registry.borrow().len(), buffers_before); + assert!(state.terminal_manager.borrow().is_empty()); + assert_eq!(state.process_supervisor.borrow().ids().count(), 0); + + let mut spec = TerminalSpec::new("/bin/sh"); + spec.args = vec!["-c".into(), "sleep 30".into()]; + spec.env = vec![("PMACS_VTERM_OWNED".into(), "original".into())]; + let mut caller_copy = spec.clone(); + let buffer_id = state.open_terminal(spec).expect("valid owned spec"); + caller_copy.command.clear(); + caller_copy.args.clear(); + caller_copy.env[0].1 = "mutated".into(); + let lua_processes: usize = state + .lua_host + .lua() + .load("return #pmacs.process.list()") + .eval() + .expect("process list"); + assert_eq!( + lua_processes, 0, + "terminal-owned ProcessId must not be exposed through pmacs.process" + ); + let terminal_module_absent: bool = state + .lua_host + .lua() + .load("return pmacs.terminal == nil") + .eval() + .expect("terminal module absence"); + assert!( + terminal_module_absent, + "Stage 1 must not publish an unrenderable interactive Lua terminal API" + ); + + let process_id = state + .terminal_manager + .borrow() + .process_id(buffer_id) + .expect("terminal process"); + let supervisor = state.process_supervisor.borrow(); + let process_spec = supervisor.spec(process_id).expect("owned process spec"); + assert_eq!(process_spec.command, "/bin/sh"); + assert_eq!( + process_spec.args, + [String::from("-c"), String::from("sleep 30")] + ); + assert_eq!( + process_spec.env, + [(String::from("PMACS_VTERM_OWNED"), String::from("original"))] + ); +} + +#[test] +fn read_only_guard_covers_direct_skip_undo_and_redo_without_state_change() { + let mut buffer = Buffer::from_bytes(BufferId::next(), "*protected*", b"abc"); + buffer + .apply_edit(EditOp::Insert { + pos: 3, + bytes: b"d", + }) + .expect("seed undo"); + buffer.undo().expect("seed redo"); + buffer.set_read_only(true); + let before = (rope_bytes(&buffer), buffer.revision(), buffer.is_modified()); + assert!(matches!( + buffer.begin_edit(), + Err(BufferError::ReadOnly { .. }) + )); + assert!(!buffer.editing_in_progress()); + + let results = [ + buffer.apply_edit(EditOp::Insert { + pos: 0, + bytes: b"x", + }), + buffer.apply_edit_skip_intercepts(EditOp::Replace { + range: Range::new(0, 1), + bytes: b"y", + }), + buffer.undo(), + buffer.redo(), + ]; + + assert!( + results + .iter() + .all(|result| matches!(result, Err(BufferError::ReadOnly { .. }))) + ); + assert_eq!( + before, + (rope_bytes(&buffer), buffer.revision(), buffer.is_modified()) + ); +} + +#[cfg(feature = "crdt")] +#[test] +fn read_only_empty_crdt_bootstrap_is_immutable_against_remote_content() { + let mut buffer = Buffer::new(BufferId::next(), "*terminal*"); + buffer.set_read_only(true); + buffer + .upgrade_to_crdt(1) + .expect("empty immutable bootstrap is allowed"); + + let donor = pmacs::crdt::CrdtState::new(2).expect("donor"); + let version = donor.version(); + donor.insert(0, "forged").expect("donor edit"); + let update = donor.export_updates_since(&version).expect("update"); + + assert!(matches!( + buffer.apply_remote_crdt_op(&update), + Err(BufferError::ReadOnly { .. }) + )); + assert!(buffer.is_empty()); + assert_eq!(buffer.revision(), 0); + assert!(!buffer.is_modified()); +} + +#[test] +fn final_output_precedes_exact_nonzero_annotation_and_buffer_is_retained() { + let mut state = EditorState::new(); + let mut spec = TerminalSpec::new("/bin/sh"); + spec.args = vec![ + "-c".into(), + concat!( + "printf 'main-home'; ", + "printf '\\033'; sleep 0.03; printf '[?1049h'; ", + "printf '\\033[2;'; sleep 0.03; printf '4HALT'; ", + "IFS= read -r gate; ", + "printf '\\033[?1049l'; ", + "printf '\\033[2;'; sleep 0.03; printf '3Hfinal-'; ", + "sleep 0.03; printf 'output'; exit 7" + ) + .into(), + ]; + spec.rows = 8; + spec.cols = 80; + let buffer_id = state.open_terminal(spec).expect("open terminal"); + + tick_until(&mut state, Duration::from_secs(5), |state| { + state + .terminal_manager + .borrow() + .snapshot(buffer_id) + .is_some_and(|snapshot| { + matches!(snapshot.process, TerminalProcessState::Running) + && screen_text(&snapshot) + .lines() + .nth(1) + .is_some_and(|row| row.starts_with(" ALT")) + }) + }); + let alternate = state + .terminal_manager + .borrow() + .snapshot(buffer_id) + .expect("running alternate-screen snapshot"); + let alternate_text = screen_text(&alternate); + assert!(alternate_text.contains("ALT")); + assert!( + !alternate_text.contains("main-home"), + "alternate screen must not expose the preserved main grid" + ); + { + let manager = state.terminal_manager.borrow(); + let mut supervisor = state.process_supervisor.borrow_mut(); + manager + .send(buffer_id, b"\n", &mut supervisor) + .expect("raw stdin unblocks child"); + } + + tick_until(&mut state, Duration::from_secs(5), |state| { + state + .terminal_manager + .borrow() + .snapshot(buffer_id) + .is_some_and(|snapshot| matches!(snapshot.process, TerminalProcessState::Exited(7))) + }); + + let snapshot = state + .terminal_manager + .borrow() + .snapshot(buffer_id) + .expect("retained terminal snapshot"); + let text = screen_text(&snapshot); + assert!( + text.contains("main-home"), + "leaving alternate screen must restore the main grid" + ); + assert!( + !text.contains("ALT"), + "alternate-screen output must not enter the retained main grid" + ); + assert!( + text.lines() + .nth(1) + .is_some_and(|row| row.starts_with(" final-output")), + "FullScreen parser/profile must honor CSI cursor addressing" + ); + let output_at = text + .find("final-output") + .expect("final child output visible"); + let annotation = format!("Process {} exited abnormally with code 7", snapshot.pid); + let annotation_at = text + .find(&annotation) + .expect("exact exit annotation visible"); + assert!( + output_at < annotation_at, + "final output must precede annotation" + ); + assert!(state.core.borrow().registry.borrow().contains(buffer_id)); + let core = state.core.borrow(); + let registry = core.registry.borrow(); + let buffer = registry.get(buffer_id).expect("identity buffer retained"); + assert!(buffer.is_read_only()); + assert!(buffer.is_empty()); + assert!(!buffer.is_modified()); +} + +#[test] +fn normal_and_signal_annotations_use_exact_pid_and_outcome() { + for (script, expected, annotation_tail) in [ + ( + "printf normal-output; exit 0", + TerminalProcessState::Exited(0), + "exited normally with code 0", + ), + ( + "printf signal-output; kill -TERM $$", + TerminalProcessState::Signaled("SIGTERM".into()), + "exited abnormally with signal SIGTERM", + ), + ] { + let mut state = EditorState::new(); + let mut spec = TerminalSpec::new("/bin/sh"); + spec.args = vec!["-c".into(), script.into()]; + spec.rows = 6; + spec.cols = 80; + let buffer_id = state.open_terminal(spec).expect("open terminal"); + tick_until(&mut state, Duration::from_secs(5), |state| { + state + .terminal_manager + .borrow() + .snapshot(buffer_id) + .is_some_and(|snapshot| snapshot.process == expected) + }); + let snapshot = state + .terminal_manager + .borrow() + .snapshot(buffer_id) + .expect("snapshot retained"); + assert!( + screen_text(&snapshot).contains(&format!("Process {} {annotation_tail}", snapshot.pid)), + "missing exact annotation for {:?}", + snapshot.process + ); + } +} + +#[test] +fn killing_terminal_buffer_prunes_session_and_reaps_owned_process() { + let mut state = EditorState::new(); + let mut spec = TerminalSpec::new("/bin/sh"); + spec.args = vec!["-c".into(), "sleep 30".into()]; + let buffer_id = state.open_terminal(spec).expect("open terminal"); + let process_id = state + .terminal_manager + .borrow() + .process_id(buffer_id) + .expect("owned process"); + + state + .core + .borrow_mut() + .kill_buffer(buffer_id) + .expect("kill identity buffer"); + state.tick_processes(); + assert!(!state.terminal_manager.borrow().is_terminal(buffer_id)); + + tick_until(&mut state, Duration::from_secs(5), |state| { + state + .process_supervisor + .borrow() + .state(process_id) + .is_none() + }); +} + +#[test] +fn editor_shutdown_kills_term_ignoring_terminal_child() { + let pid = { + let mut state = EditorState::new(); + state + .process_supervisor + .borrow_mut() + .set_grace_period(Duration::from_millis(50)); + let mut spec = TerminalSpec::new("/bin/sh"); + spec.args = vec![ + "-c".into(), + "trap '' TERM; while :; do sleep 1; done".into(), + ]; + let buffer_id = state.open_terminal(spec).expect("open terminal"); + state + .terminal_manager + .borrow() + .snapshot(buffer_id) + .expect("snapshot") + .pid + }; + + let deadline = Instant::now() + Duration::from_secs(2); + let proc_path = format!("/proc/{pid}"); + while Instant::now() < deadline && std::path::Path::new(&proc_path).exists() { + std::thread::sleep(Duration::from_millis(10)); + } + assert!( + !std::path::Path::new(&proc_path).exists(), + "terminal child {pid} survived EditorState shutdown" + ); +} + +#[test] +fn terminal_tick_does_not_take_non_terminal_process_events() { + let mut state = EditorState::new(); + let mut process = pmacs::process::ProcessSpec::new("ordinary", "/bin/sh"); + process.args = vec!["-c".into(), "printf ordinary".into()]; + let ordinary_id = state + .process_supervisor + .borrow_mut() + .spawn(process) + .expect("ordinary process"); + + tick_until(&mut state, Duration::from_secs(5), |state| { + matches!( + state.process_supervisor.borrow().state(ordinary_id), + Some(ProcessState::Terminated(_)) + ) + }); + let events = state + .process_supervisor + .borrow_mut() + .take_events(ordinary_id); + assert!( + events.iter().any(|event| matches!( + &event.kind, + pmacs::process::ProcessEventKind::Stdout(bytes) if bytes == b"ordinary" + )), + "TerminalManager must not steal ordinary process output" + ); +}