diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index 8252577..d811f1d 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, after Vterm Stage 1 review round 2 was addressed +and fully gated on `vterm-core` (awaiting merge authorization; not merged). +Vterm Stages 2 and 3 are not implemented.** This file is the bridge between development machines. If you are an agent reading on a fresh clone: this document plus the `docs/*-framing.md` files ARE your memory. Read this fully before taking on work, seed persistent memory from it, and **update this @@ -11,10 +11,8 @@ next machine reads it the way you just did. ## 1. Where the project stands (2026-07-21) -- Canonical `main` @ `bb17ec9` (#123 merged atop #124), protocol - **v17** (`SUPPORTED=[6..17]`). The rebased `statusline-segments` - branch implements protocol v18, but v18 is **not on main** until - review and merge. +- Canonical `main` @ `7bc0c61` (#125 merged), protocol + **v18** (`SUPPORTED=[6..18]`). - **Syntax-highlight / language-detection side-quest (#114–#118) LANDED** — a one-shot arc built in sibling worktrees off main while the user's themes lane (`theme-faces`) ran concurrently in the shared @@ -100,8 +98,7 @@ next machine reads it the way you just did. `pmacs.editor.take_typed_edit()` (buffer-revision postcondition, Q#AP9). Substrate: `buf:path()`, `pmacs.lsp.buffer_language(buf)`, `PMACS_FAKE_LSP_CHANGE_SINK`, `TestDaemon::spawn_with_config`. -- **Themes (Arc 4) stages 1 and 2 LANDED; stage 3 IMPLEMENTED ON ITS - FEATURE BRANCH, AWAITING REVIEW.** +- **Themes (Arc 4) stages 1–3 LANDED; Arc 4 COMPLETE ON `main`.** - Stage 1 (#120, `docs/theme-faces-framing.md` rev 9): named UI faces as reserved `ui`/`ui.*` theme entries; transactional split syntax/face epochs; protocol-v16 `ThemeFacts`; snapshot/baseline @@ -110,7 +107,7 @@ next machine reads it the way you just did. `pmacs.gpu.set_font` and authoritative protocol-v17 `FontFacts`; frontend-local family resolution, live font reload/reflow, and visual-run caret geometry. - - Stage 3 (`statusline-segments`, + - Stage 3 (#125, `statusline-segments`, `docs/statusline-segments-framing.md` rev 3): composable strict `pmacs.statusline` providers; borrow-released per-window evaluation with failure latches; legacy-preserving TUI composition; a pure @@ -121,8 +118,62 @@ next machine reads it the way you just did. Clippy clean; 1,619 default + 1,793 CRDT library tests; 7 default + 8 CRDT feature acceptance; 114 M4; 109 required GPU; one-invocation workspace sweep 2,718 passed across 78 suites (19 ignored, - `basedpyright` filtered); `git diff --check` clean. This branch is - awaiting review and **must not be described as merged**. + `basedpyright` filtered); `git diff --check` clean. Stage 3 landed + as #125 and completed Arc 4 on `main`. +- **Vterm Stage 1 terminal core IMPLEMENTED ON `vterm-core`, FULLY GATED, + AWAITING MERGE AUTHORIZATION, NOT MERGED** (`docs/vterm-framing.md` rev 5). + - Implementation commits: `bbc1f33` (Stage 1), `962944b` (Darwin signal + normalization), first-review fixes `f0a235f`, `28f2e6c`, `bf972a7`, and + second-review hardening `9797ada`; pull request: #126, + (open, non-draft, + targeting `main`). + - `AnsiParserProfile::{LineOriented, FullScreen}` preserves compile/REPL + behavior while terminal PTYs emit the full cursor/mode/device operation + set. `src/terminal/{screen,input,session}.rs` owns the state machine, + encoders, and lifecycle registry. + - Public session seam: owned strict `TerminalSpec`; owned + `TerminalSnapshot`; `TerminalProcessState`; and + `SharedTerminalManager = Rc>` 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. + - Review round 1 added typed IND/NEL/RI with margin-correct screen behavior, + defaults absent `TERM` to `xterm-256color`, makes shutdown liveness + acceptance portable with `kill(pid, 0)`, and preserves custom tab stops on + resize. Review round 2 rejects C0/C1 controls before they enter screen + cells, preserves the released button code in SGR mouse reports, removes + dead screen paths, and clears stale round-trip state during prune. Stage 2 + must uniquify default terminal buffer names. + - Exact CUU/CUD and out-of-range DECSTBM clamping, combining across controls, + xterm alternate-screen details, legacy non-SGR mouse, printable ASCII and + CSI-dispatch allocation fast paths, and scrollback-cap naming are explicit + post-arc deferrals in the framing. + - Final from-start rerun after review round 2: Clippy clean; 1,661 default + + 1,837 CRDT library tests (3 ignored each); 9 default + 10 CRDT vterm + acceptance; M4 114 passed (3 ignored, 1 filtered); required GPU 109; + workspace 2,769 passed across 79 suites (19 ignored, 1 filtered); diff + check clean. `scripts/bite HEAD^ src/terminal/screen.rs --test + vterm_stage1_acceptance terminal_cells_reject_child_control_characters` + is a clean behavioral bite. The parser dispatch has its independent clean + behavioral bite; the original `main`/crate-root bite remains explicitly + weaker compile-time API evidence. + - Stage 2 reviews require a durable focus/input resize owner, owning + `FrontendId` for the global `C-c` continuation, and local clipboard/BEL + signal drainage. Stage 3 additionally owns `pmacs-gpu/src/attach.rs`, + authenticated source routing, protocol-owned wire types/limits, and a + deliberate complete-frame limit decision: 16 MiB is insufficient; use a + measured legal-worst cap or aggregate bound, never silent chunking. - Roadmap: `docs/roadmap-2026-07.md` (ranked arcs). Position: - **Arc 1 (LSP utility surface) COMPLETE** — completion popup (#92/#93), panels/references/outline/hover (#94–#96), plus diff --git a/docs/roadmap-2026-07.md b/docs/roadmap-2026-07.md index 412de58..72f5e12 100644 --- a/docs/roadmap-2026-07.md +++ b/docs/roadmap-2026-07.md @@ -80,26 +80,34 @@ saveplace, autosave + crash recovery, optional backups. Generalize the question: what is a "session" in a daemon world; do CRDT snapshots ride along. -### Arc 4 — Themes + extensibility surface — COMPLETE ON FEATURE BRANCH +### Arc 4 — Themes + extensibility surface — COMPLETE ON `main` -Stages 1 and 2 landed as #120 and #124: named `ui.*` faces with -daemon-resolved `ThemeFacts`, then the live global -`pmacs.gpu.set_font` preference at protocol v17. Stage 3 is implemented -and fully gated on `statusline-segments`, awaiting review and **not yet -merged**: composable `pmacs.statusline` providers, per-window TUI -composition, a pure built-in LSP segment, dynamic modeline faces, and -semantic/GPU transport through protocol v18. Merging stage 3 completes -Arc 4 on `main`. +All three stages landed: #120 added named `ui.*` faces and daemon-resolved +`ThemeFacts`; #124 added the live global `pmacs.gpu.set_font` preference at +protocol v17; and #125 added composable `pmacs.statusline` providers, +per-window TUI composition, a pure built-in LSP segment, dynamic modeline +faces, and semantic/GPU transport through protocol v18. -### Arc 5 — Terminal, staged +### Arc 5 — Terminal, staged — VTERM STAGE 1 ON FEATURE BRANCH -- **Stage 1**: compile-mode / grep-mode / shell-command on the existing - PTY + ANSI + REPL-package substrate (line-oriented output buffer, - error-regex jump-to-file, `M-x compile`). Cheap, transformative. -- **Stage 2 (vterm)**: extend `ansi.rs` into a 2D grid model - (alt-screen, cursor addressing, scrollback — parser already - recognizes and discards these), grid-backed buffer view, GPU - rendering question (grid cells vs text buffer). +- **Compile-mode landed** in #113: line-oriented PTY/ANSI output, + error-regex navigation, and `M-x compile`. +- **Vterm Stage 1 terminal core** is implemented, two review rounds are + addressed, and the branch is fully gated on `vterm-core`; PR #126 awaits + merge authorization and is **not merged**. It adds compatibility parser + profiles, bounded VT screen/scrollback/reflow state, IND/NEL/RI, input + encoders, internal `TerminalManager`, read-only identity buffers, process + lifecycle, renderer-safe control-free cells, and headless real-PTY + acceptance. It intentionally adds no interactive Lua command or frontend + rendering. +- **Vterm Stage 2 TUI** starts only after Stage 1 merges: terminal-window + composition, input/resize, per-context scroll/selection/copy, and the Lua + surface. +- **Vterm Stage 3 protocol/GPU** starts only after Stage 2 merges: additive + protocol v19 complete frames, authenticated daemon routing, and native GPU + cell rendering. Its framing must resolve the current 16 MiB transport cap's + incompatibility with the legal worst complete terminal frame; never silently + chunk. ### Arc 6 — Folding (keystone gutter rider) diff --git a/docs/vterm-framing.md b/docs/vterm-framing.md new file mode 100644 index 0000000..995a216 --- /dev/null +++ b/docs/vterm-framing.md @@ -0,0 +1,1009 @@ +# Vterm — framing (Arc 5 stage 2, three-PR delivery) + +**Revision 5 — 2026-07-21. Status: Stage 1 review round 2 fixes implemented +and fully gated on `vterm-core`; awaiting merge authorization, not merged. +Stages 2 and 3 are not implemented.** + +Revision 5 establishes the renderer-facing cell invariant before Stage 2: +terminal text discards C0/C1 controls rather than storing host-terminal control +bytes in grapheme cells. SGR mouse release preserves the released button code; +review cleanups remove dead screen paths and stale round-trip state; and the +remaining VT-fidelity and allocation nits are explicit deferrals. Architecture +is 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 +readers, stdin writes, resize, exit/restart state, and final drain. +`src/ansi.rs` already owns a streaming UTF-8/CSI/OSC parser, but deliberately +emits only the line-oriented subset compile-mode needs. Vterm does not replace +either subsystem. It extends their contracts and adds the missing terminal +screen state machine. + +Arc 5 stage 2 ships as three separately reviewed PRs: + +1. **terminal core** — full-screen VT events, `TerminalScreen`, internal + session ownership/contracts, and headless real-PTY acceptance; +2. **TUI integration** — terminal-window composition, input, resize, + scrollback, selection, and copy; +3. **GPU integration** — protocol v19 terminal messages, semantic-daemon + routing, and a native GPU terminal renderer. + +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 5 — Stage 1 implementation and review record + +The first of the three vterm PRs is implemented, reviewed, and fully gated on +`vterm-core`, awaiting merge authorization. Initial feature commit `bbc1f33`, +first-review fixes through `bf972a7`, and second-review hardening `9797ada` are +published as open, non-draft PR #126, +, targeting `main`. +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 + +The initial delivery gate run fixed missing acceptance-crate documentation; +PR CI then exposed Darwin's numeric `strsignal` suffix, fixed in `962944b`. +Review round 1's first Clippy pass found only identical LF/IND match arms, +consolidated in `bf972a7`. Review round 2 added one screen unit and one shared +acceptance case; the complete sequence restarted from gate 1: + +- `cargo fmt --check`: clean; +- `cargo clippy --workspace --all-targets -- -D warnings`: clean; +- default library: 1,661 passed, 3 ignored; +- CRDT library: 1,837 passed, 3 ignored; +- Stage 1 acceptance: 9 default + 10 CRDT passed; +- M4 acceptance: 114 passed, 3 ignored, 1 `basedpyright` filtered; +- required GPU: 109 passed; +- workspace: 2,769 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. + +`scripts/bite HEAD^ src/ansi.rs --lib +parser_split_points_produce_identical_screen` returned `bite: OK` with a clean +behavioral assertion failure: the pre-dispatch parser left the cursor at row +zero/column four instead of applying NEL/RI/IND and landing at row one/column +zero. + +`scripts/bite HEAD^ src/terminal/screen.rs --test vterm_stage1_acceptance +terminal_cells_reject_child_control_characters` returned `bite: OK` with a +clean behavioral failure: the pre-hardening screen stored control bytes in a +grapheme cluster rather than preserving the blank snapshot. + +### 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. + +### 0.5 Stage 1 review round 1 + +The first external review found no ownership, mutation-guard, parser-cap, or +security regressions. This round resolves its three merge-adjacent findings: + +- `ESC D` (IND), `ESC E` (NEL), and `ESC M` (RI) are typed full-screen + operations. RI scrolls down only at the top margin; IND/NEL scroll up at the + bottom margin, with NEL additionally returning to column zero. The parser's + every-byte-split matrix and focused screen-margin test pin the complete path. +- Terminal children no longer inherit an arbitrary host `TERM`; absent a + caller override, their process environment gets `TERM=xterm-256color`. +- The TERM-ignoring shutdown acceptance uses `kill(pid, 0)` through `nix` + instead of Linux-only `/proc`, so macOS now exercises the assertion. +- Resize retains every surviving application tab stop and installs default + stops only in newly added columns. + +`spawn_ansi_parser` intentionally calls `AnsiParser::finish()` on channel +disconnect for both profiles. For existing line-oriented compile/REPL +consumers, EOF therefore delivers trailing partial text and required synthetic +style/alternate-screen balancing that older code dropped. This is an +intentional latent-bug fix and an observable compatibility contract. + +The Stage 2 Lua `open` surface must uniquify colliding default buffer names +(`*terminal:sh*`, `*terminal:sh*<2>`, and so on) before terminal creation +becomes user-visible. + +### 0.6 Stage 1 review round 2 + +The second external review found no ownership, lifecycle, mutation-guard, +transactional-spawn, final-drain, parser-cap, or reflow defects and judged +Stage 1 merge-ready. Its renderer-boundary hardening and cheap cleanups are +resolved before Stage 2: + +- `TerminalScreen::write_text` drops every `char::is_control()` value before + grapheme segmentation, so parser-produced C1 and direct-event C0/C1 bytes + cannot enter copyable or renderable cells. Unit and shared acceptance tests + pin a byte-identical blank snapshot. +- SGR mouse release reports retain the released left/middle/right button code + and use the lowercase `m` final. +- The dead `line_feed` mode parameter and contradictory wide-grapheme branch + are removed; all logical-line ID allocation saturates consistently; and + terminal prune clears stale round-trip input membership. + +Out-of-range DECSTBM bottom clamping, CSI-intermediate clone removal, and a +separately named configuration-time scrollback-row cap remain explicit +deferrals in §11. + +## 1. Problem and ownership boundary + +Pmacs can supervise a PTY and can parse enough ANSI to turn command output into +a line-oriented compilation buffer. It cannot host `nvim`, `htop`, a shell +using cursor motion, or any other application whose output means “mutate a +terminal screen” rather than “append text.” The current parser intentionally +recognizes and discards cursor addressing, alternate-screen state, scrolling, +and most terminal modes. The editor renderer only knows an ordinary rope and +its views. + +The missing abstraction is a real terminal state machine: + +```text +PTY bytes -> AnsiParser -> terminal operations -> TerminalScreen + -> response bytes -> PTY stdin +Frontend input -> terminal input encoder ------------------------^ + +TerminalScreen -> TUI cell composition + -> protocol v19 TerminalFrame -> GPU cell layout +``` + +Ownership is explicit: + +- `ProcessSupervisor` owns the child, PTY file descriptors, bounded worker + pipeline, signal delivery, and final drain. +- `AnsiParser` owns byte-stream framing and escape-sequence decoding. It does + not own a screen. +- `TerminalScreen` owns main/alternate grids, cursor and modes, scroll regions, + tab stops, and scrollback. +- `TerminalManager` owns the mapping from a special editor `BufferId` to one + PTY process plus one `TerminalScreen`, and owns per-window/per-frontend + scroll/selection state. +- The ordinary buffer is an identity and lifecycle anchor only. Terminal + screen contents are never mirrored into its rope. +- TUI and GPU frontends own final glyph drawing. They consume cells; they do + not reinterpret ANSI or maintain a second VT state machine. + +This keeps the existing semantics-down boundary intact. Document text remains +a CRDT/rope semantic surface. A terminal is inherently a cell protocol, so its +new wire family carries terminal cells, not daemon-formatted document text and +not pixels. + +## 2. Ground truth in the current tree + +The implementation must preserve these existing contracts: + +- `ProcessMode::Pty`, `write_stdin`, `resize_pty`, group-directed signals, + bounded output channels, and the TERM/KILL final drain already exist in + `src/process.rs`. +- `ProcessSpec::ansi_events` moves parsing onto a bounded worker and emits + `ProcessEventKind::Ansi`; raw pipe/LSP consumers retain their byte contract. +- `AnsiParser` is stateful across arbitrary feed boundaries, has a per-state + escape-sequence cap, safely recovers malformed sequences, carries truecolor + and underline color, and resets after `finish()`. +- The parser currently emits `Text`, `SetStyle`, line-oriented controls, + `Erase`, `SetTitle`, and alternate-screen markers. Cursor motion and most + CSI/DEC operations are parsed but discarded by design. +- Alternate-screen suppression is currently parser-global and load-bearing for + compile/REPL consumers. Vterm therefore requires an explicit parser profile: + `LineOriented` preserves today's suppression and event contract; + `FullScreen` emits all screen/mode operations. `ProcessSpec` selects the + profile when `ansi_events` is enabled; existing Lua process specs default to + `LineOriented`, while terminal sessions construct `FullScreen` specs. +- `EditorState::tick_processes` is the main-thread process drain point. + LSP/MCP and Lua packages drain events only for process IDs they own. +- `EditorCore::round_trip_buffers` already disables optimistic frontend edits + for special buffer-local input surfaces. +- Grid frontends already receive generic `CellDelta`; a terminal window can be + composed into that grid without a new grid protocol. +- Semantic frontends hold document text through CRDT snapshots and receive + byte-anchored style/decorations. They require a new terminal-specific + message because an empty terminal identity buffer contains no screen text. +- Desktop persistence saves file-backed buffers only. A pathless terminal + buffer is already omitted; no special persistence exception is required. + +## 3. Terminal core model + +### 3.1 Types and files + +Stage 1 adds a `src/terminal/` module rather than growing `editor.rs` or +`ansi.rs` into a second monolith: + +- `screen.rs`: `TerminalScreen`, `TerminalGrid`, `TerminalRow`, cursor, modes, + scrollback, resize, snapshots; +- `input.rs`: normalized key/mouse/paste to VT byte encoding; +- `session.rs`: `TerminalManager`, `TerminalSession`, process ownership and + event application; +- `view.rs` (stage 2): per-context viewport/selection helpers and TUI + composition. + +`src/ansi.rs` remains the one escape parser. It gains terminal operations; it +does not depend on editor state or `TerminalScreen`. + +The public core shape is: + +```rust +pub struct TerminalManager { /* BufferId -> TerminalSession */ } + +pub struct TerminalSelectionSpan { + pub row: u32, + pub start_col: u32, // inclusive + pub end_col: u32, // exclusive +} + +pub enum TerminalProcessState { + Running, + Exited(i32), + Signaled(String), + Crashed(String), +} + +pub struct TerminalSnapshot { + pub buffer_id: BufferId, + pub size: CellSize, + pub cells: Vec, // row-major visible slice + pub cursor: Option, + pub title: Option, + pub screen_generation: u64, + pub selection: Vec, + pub scroll_offset: u32, + pub at_bottom: bool, + pub pid: u32, + pub process: TerminalProcessState, +} +``` + +A snapshot is owned data taken only after all parser events for the current +main-thread tick are applied. Renderers never borrow the mutable screen across +Lua, editor-core, or process-supervisor calls. + +### 3.2 Parser extension + +`AnsiEvent` gains enough operations to drive a VT-style screen: + +- printable text, BEL, CR, LF/VT/FF, BS, HT, and tab-stop set/clear; +- relative and absolute cursor movement (`CUU/CUD/CUF/CUB`, `CNL/CPL`, + `CHA/HPA`, `VPA`, `CUP/HVP`); +- erase display/line/characters; +- insert/delete characters and lines; +- scroll up/down and set/reset scrolling margins; +- save/restore cursor for both DEC and CSI spellings; +- main/alternate screen enter/exit (`47`, `1047`, `1049`); +- mode set/reset for insert, origin, autowrap, application cursor, + application keypad, cursor visibility, bracketed paste, focus reporting, + synchronized output (`?2026`), and supported mouse reporting modes; +- SGR and title changes; +- DEC G0/G1 character-set selection plus SI/SO, including the line-drawing + characters full-screen TUIs depend on; +- device-status/device-attribute queries represented as typed requests. The + session, not the parser, writes bounded response bytes to PTY stdin. + +Unknown, private, or malformed sequences stay non-fatal and bounded. Parser +state always makes forward progress. DCS/APC/PM payloads remain ignored under +the same per-sequence cap; they must not leak into visible text. + +The parser-profile split is compatibility-critical. Full-screen support must +not make compile-mode start appending alternate-screen text or receiving +cursor events it does not understand. `AnsiParser::new()` and the existing +Lua `ansi = true` process option retain the line-oriented profile; terminal +session construction is the only initial full-screen caller. On `finish()`, +`LineOriented` preserves today's synthetic alternate-screen/style balancing. +`FullScreen` flushes pending text but does not invent an alternate-screen exit +or clear cells. The session manager, after applying the actual final parser +events, adds the process-exit annotation described in §4.1. Parser internals +reset in both profiles. + +The shared `Cell::Style` remains unchanged in this arc. Existing support covers +indexed/truecolor foreground/background, bold, italic, underline variants, +underline color, and reverse. Faint, conceal, blink, and strikethrough remain +ignored rather than being mapped to an unrelated attribute. Extending the +shared style would change every cell-carrying postcard encoding and is a +separate protocol-wide decision, not hidden vterm scope. + +### 3.3 Screen invariants + +`TerminalScreen` maintains two grids: + +- **main screen** with bounded scrollback; +- **alternate screen** with no scrollback and an independently saved cursor. + +Every physical row records a monotonic logical-line ID, its cell offset within +that logical line, and whether it ended in a soft autowrap. This is necessary +for copy and resize: hard line breaks become `\n`; soft-wrapped physical rows +are joined into one logical line. Per-view top/selection anchors use +`(line_id, cell_offset)`, not a physical row index, so reflow can remap them. + +Core invariants: + +- `cells.len() == rows * cols`; every row has exactly `cols` cells. +- Cursor and scrolling margins are always in bounds after every operation. +- A wide grapheme occupies a leading glyph plus `Glyph::Continuation`; an + overwrite, erase, insert, delete, or resize never leaves an orphaned half. +- Combining codepoints extend the preceding grapheme when one exists; at the + left edge they combine with a space cell. Cluster byte length is bounded. +- Autowrap uses the pending-wrap rule: writing the last column arms a wrap; + the following printable grapheme performs it. Cursor motion/control clears + the pending wrap where VT behavior requires. +- Origin mode interprets absolute row addressing relative to the active + scrolling region. +- Insert/delete/scroll operations affect only their defined region and fill + exposed cells with the current erase style. +- Entering `1049` saves the main cursor and clears the alternate grid; leaving + it restores the main grid/cursor. Repeated set/reset is idempotent. +- Resizing the main screen reflows soft-wrapped logical lines, preserves hard + breaks and logical-line IDs, and maps the cursor to the corresponding + logical offset. The alternate screen is never reflowed: it is clipped/padded + in place, matching a full-screen application's expectation that it will + repaint after `SIGWINCH`. +- Scrollback eviction removes the oldest complete physical rows. It clamps a + per-view anchor whose logical line was evicted to the oldest retained row. + The default cap is 10,000 rows and an independent 4,000,000-cell budget; + either limit may trigger eviction. + +Synchronized output is a publication gate, not a second screen. Operations +continue mutating `TerminalScreen`, but snapshots retain the last published +generation until `?2026l`. Exit/truncation releases the final state. A bounded +one-second watchdog also releases and clears synchronization if a buggy child +never resets it, preventing a permanently frozen editor surface. + +Hard limits are shared constants, not frontend-local guesses: + +- maximum rows: 512; +- maximum columns: 512; +- maximum visible cells: 262,144; +- maximum UTF-8 bytes in one grapheme cluster: 256; +- maximum retained history cells: 4,000,000; +- maximum parser control-string payload: the existing 1 KiB cap. + +Invalid creation/resize arguments reject atomically. A rejected resize leaves +the process and prior screen unchanged. + +### 3.4 Device responses + +The core responds to the small query set required by normal shells and TUIs: + +- primary and secondary device attributes; +- operating-status report; +- cursor-position report, using current origin semantics. + +Responses are fixed templates plus checked decimal coordinates and pass +through the existing bounded `write_stdin` queue. OSC 52 clipboard writes, +window manipulation, palette mutation, hyperlinks, sixel, and arbitrary DCS +responses are not honored. Child output is untrusted; it cannot directly set +the host clipboard or execute an editor command. + +## 4. Session, buffer, and Lua contract + +### 4.1 Session lifecycle + +One terminal session owns exactly one process ID, one screen, and one identity +buffer. A buffer may appear in several windows/frontends, but there is never a +second parser or screen for it. + +Creation order is transactional: + +1. validate and copy the complete `TerminalSpec`; +2. create the read-only identity buffer; +3. spawn a raw PTY with `ansi_events = FullScreen`; +4. install the session in `TerminalManager`; +5. mark the buffer round-trip-only. + +Stage 1 exposes that operation as a Rust manager contract for headless tests +and future callers, but deliberately registers no interactive command: opening +an unrenderable blank terminal buffer would be a broken partial feature. +Stage 2's Lua binding calls the same operation and switches the caller's active +window only after it succeeds. +On synchronous spawn failure, the temporary buffer is removed and no session +entry remains. The terminal process ID is not exposed through +`pmacs.process`; only `TerminalManager` drains its events, so two consumers +cannot steal batches from each other. + +`EditorState::tick_processes` becomes: + +1. supervisor `tick()`; +2. terminal manager drains only its owned process IDs, applies all ANSI + batches, queues device responses, and records terminal exit state; +3. the existing `process.after-tick` hook runs; +4. LSP/MCP retain their existing later ticks. + +At process exit, final drained output is applied first. The manager then writes +one synthetic, default-style hard line into the active terminal screen: +`Process exited normally with code 0` for zero; `Process exited +abnormally with code ` for a non-zero code; or `Process exited +abnormally with signal ` for signal termination. It emits CRLF first +when needed so the annotation never overwrites child text. The annotation is +terminal-owned (not parser output and not rope text), visible and copyable like +the rest of the screen. +The buffer remains until the user kills it. Killing the buffer terminates a +still-live process/session. A periodic prune handles all buffer-removal paths, +including Lua callers that bypass the friendly terminal close API. Editor +shutdown uses the existing supervisor shutdown path and cannot restart a +terminal. + +The wire-visible process state has only the four variants above: synchronous +spawn failure never publishes a session, and `terminate` remains `Running` +until the supervisor reports its final outcome. Crash/signal strings are +sanitized to one line and bounded before snapshots or Lua metadata are built. + +### 4.2 Buffer semantics + +A terminal buffer is pathless, unmodified, and read-only. Read-only is a +buffer-owned invariant, not only an intercept view: `Buffer` gains a flag and +typed error checked by ordinary edits, intercept-skipping host edits, undo/ +redo, and local/remote CRDT content mutation. The terminal manager sets it +before publishing the session. The rope stays empty for the session lifetime. + +Consequences are deliberate: + +- buffer lists and window switching see a normal named buffer; +- normal save/autosave/LSP/syntax/CRDT editing does not apply; +- desktop save omits it because it has no file path; +- terminal scrollback does not participate in ordinary document search; +- copy reads from terminal rows, never from a hidden mirror rope; +- killing the buffer is the single editor-lifecycle teardown signal. + +Semantic bootstrap may attach an immutable empty CRDT state and send its +`BufferSnapshot` so v18/v19 mirrors can track the buffer identity and +`CursorByte` without decode/state failure. No terminal contents enter that +CRDT, and local/remote CRDT edit validation rejects the read-only buffer. The +stage-3 terminal frame is the authoritative visible semantic surface; a forged +remote operation cannot mutate the empty identity rope. + +No hidden “text projection” is maintained. Two representations would drift on +cursor rewrites, erase operations, alternate-screen swaps, and resize. + +### 4.3 Lua API + +Stage 2 installs `pmacs.terminal` before user config, loads +`builtin/runtime/terminal.lua`, and registers the interactive command: + +```lua +local buffer = pmacs.terminal.open { + command = os.getenv('SHELL') or '/bin/sh', + args = {}, + cwd = nil, -- inherits instance cwd + env = {}, + name = nil, -- default: *terminal:* + rows = 24, + cols = 80, + scrollback_rows = 10000, +} + +pmacs.terminal.is_terminal(buffer) -- boolean +pmacs.terminal.state(buffer) -- fresh plain metadata table +pmacs.terminal.send(buffer, bytes) -- explicit raw bytes +pmacs.terminal.resize(buffer, rows, cols) +pmacs.terminal.terminate(buffer) -- SIGTERM; buffer remains +pmacs.terminal.scroll(lines) -- active terminal window +pmacs.terminal.scroll_to_bottom() +pmacs.terminal.copy_selection() -- active terminal window +``` + +`open` validates exact raw table fields before side effects. Unknown fields, +metatable-provided fields, holes in `args`, non-string env keys/values, +embedded NUL, non-integer dimensions, and out-of-range scrollback reject with +the field named. The copied spec is immune to caller mutation. Returned and +accepted identity is `BufferIdLua`, following the rest of the editor API. + +The built-in chunk registers `terminal` as an interactive command. It opens +`$SHELL` without a shell-command interpolation layer. There is no command +string split and no implicit `sh -c`. +It also installs terminal-buffer-local commands used after the escape prefix: +`M-w` copies the terminal selection, `M-v`/`C-v` page scrollback up/down, and +`M-<`/`M->` move to the oldest retained row/bottom. These shadow ordinary +document commands only during the one-key editor escape; normal terminal input +still sends those keys to the child. + +## 5. Stage 2 — TUI integration + +### 5.1 Composition and cursor + +For every window whose `buffer_id` belongs to `TerminalManager`, the window +content rectangle is painted from a terminal snapshot instead of +`TextView::render`. Modeline/statusline composition remains unchanged. Normal +text overlays, line-number gutters, syntax, diagnostics, and wrapping are not +run over terminal cells. + +Each `(frontend_id, window_id, buffer_id)` owns a `TerminalViewState`. At +bottom, the last screen row aligns with the content rectangle's last row. +Scrolling records a stable logical-line top anchor rather than a numeric +distance from a moving tail. New child output therefore does not move a +scrolled-back viewport or selection. If retention evicts that anchor, it +clamps once to the oldest retained row. A “not at bottom” marker is available +to the built-in terminal statusline provider. + +The active terminal cursor is translated from terminal-local coordinates into +the window rectangle. It is hidden when the child hid it, the window is not +active, the viewport is scrolled away from bottom, or the coordinate is +clipped. Other terminal windows do not paint a cursor. + +A smaller window clips; a larger window pads with default cells. Merely +rendering a passive view never resizes the PTY. + +BEL is forwarded only from the active terminal through the existing frontend +signal path. OSC title is sanitized and exposed in terminal metadata/frame and +the terminal statusline; it does not rename the identity buffer or directly +set the host window title. + +### 5.2 Input precedence + +Modal editor surfaces remain authoritative. Input precedence is: + +1. minibuffer, incremental search, completion/menu, query-replace, and other + existing modal shadows; +2. terminal escape-prefix state; +3. terminal key/mouse/paste handling when the active buffer is terminal; +4. ordinary buffer-local/global keymaps and self-insert. + +All terminal buffers remain in `round_trip_buffers`, so GPU/TUI input reaches +this daemon-owned decision before any optimistic edit. +Escape-prefix state is per frontend, so one attached user's pending escape +never captures another user's next key. + +When terminal input owns a normalized key, `terminal/input.rs` encodes: + +- UTF-8 printable characters; +- Ctrl-character mappings, Alt ESC-prefixing, Enter/Tab/Backspace/Escape; +- arrows/Home/End according to application-cursor mode; +- Insert/Delete/Page and F1–F12 xterm sequences; +- Shift-Tab and supported modifier parameters. + +Unknown/lock/media keys are ignored, never converted into text. Press is the +only actionable event in the current normalized protocol; repeat arrives as +repeated press and release is not forwarded. +The normalized protocol does not distinguish number-row digits from numeric +keypad digits, so application-keypad mode is tracked but cannot transform +those ambiguous `Key::Char` events. + +`C-c` is the fixed stage-2 terminal escape prefix. It is consumed and makes the +next key run through the ordinary editor dispatcher, allowing `C-c C-x ...` +for editor commands. `C-c C-c` sends the literal Ctrl-C byte required to +interrupt the child. This is an intentional fixed stage-2 policy. + +Paste sends exact bytes, wrapped in `ESC[200~` / `ESC[201~` only while the +child enabled bracketed paste. It never passes through a command shell or Lua. +When the child enabled focus reporting, authenticated frontend focus gain/loss +sends `ESC[I` / `ESC[O` for the controlling terminal. With the mode off, +focus changes send no PTY bytes. + +### 5.3 Mouse, selection, copy, and scrollback + +If the child enabled a supported mouse mode, pointer events inside the terminal +content rectangle are encoded as SGR mouse reports, with coordinates translated +to terminal-local 1-based cells. The active mode determines whether press, +release, drag, move, and wheel are reported. + +Otherwise the editor owns the gesture: + +- wheel changes the per-window scrollback offset; +- primary drag creates a terminal-cell selection across history and screen; +- copy serializes selected rows as UTF-8, trims only trailing default blank + cells, joins soft-wrapped rows without `\n`, and separates hard rows with + `\n`; +- wide-cell continuations are never emitted twice; +- a new plain click clears the old selection; +- child output does not move a scrolled-back viewport or selection anchor. + +`pmacs.terminal.copy_selection()` publishes through the existing kill-ring / +clipboard path. Ordinary document selection fields remain untouched. + +### 5.4 Resize ownership + +One PTY has one kernel window size even when displayed in several views. The +controlling view is the active window of `core.active_frontend` — the frontend +that most recently supplied accepted input/focus. Only that view may resize the +PTY. Passive views clip/pad. + +For grid frontends, the daemon derives the terminal content `rows × cols` from +the computed split rectangle and modeline reservation. Focus/split/frontend +resize changes trigger one checked `resize_pty`; unchanged dimensions are +suppressed. The screen model resizes before the child receives `SIGWINCH`, so +its repaint lands into the new geometry. +If the computed content rectangle has zero rows or columns, rendering skips it +and the prior valid PTY size remains unchanged; zero is never sent to +`resize_pty`. + +## 6. Stage 3 — protocol v19 and GPU integration + +### 6.1 Wire additions + +Protocol v19 appends, never inserts, these final variants: + +```rust +InstanceMessage::TerminalFrame { + buffer_id: BufferId, + size: CellSize, + cells: Vec, + cursor: Option, + title: Option, + screen_generation: u64, + selection: Vec, + scroll_offset: u32, + at_bottom: bool, + pid: u32, + process: TerminalProcessState, +} + +FrontendEvent::TerminalResize { + frontend_id: FrontendId, + buffer_id: BufferId, + size: CellSize, +} + +FrontendEvent::TerminalPointer { + frontend_id: FrontendId, + buffer_id: BufferId, + coord: CellCoord, + kind: MouseKind, + mods: Modifiers, +} +``` + +`TerminalFrame` is a complete visible-grid replacement. Empty is not a clear +sentinel: valid terminal sizes are non-zero and `cells.len()` must equal area. +Complete replacement is chosen over a second diff/cache protocol for the first +GPU stage. `screen_generation` advances on screen/process/title mutation; +scroll/selection have their own per-context epochs. The producer caches and +compares the complete context payload, so a view-only change still sends even +when `screen_generation` is unchanged, while an identical payload is silent. + +All terminal frame fields are untrusted at the GPU boundary. Validation checks +shared row/column/area limits, exact area, cursor bounds, title length, +selection ordering/non-overlap/bounds, cluster UTF-8 and cluster-byte limits, +continuation structure, and attachment absence. +Invalid input is rejected atomically and the last valid terminal frame remains +painted. + +The daemon routes terminal resize/pointer events by authenticated session +source. Claimed frontend and buffer must match the source's active terminal +window. A mismatch is dropped without resizing, selecting, or writing PTY +input. + +The protocol remains compatible with v18 where structurally possible: + +- v18 grid peers need no new message and continue to receive composed + `CellDelta` terminal windows; +- v18 semantic peers receive the immutable empty identity snapshot but no + terminal variant. They cannot display the terminal screen; terminal use from + those peers is unsupported, while normal document editing remains supported; +- v19 frontends gate the new outbound event variants on negotiated version; +- postcard byte pins cover the old final variants plus the newly appended + discriminants. + +### 6.2 Semantic producer + +When a semantic frontend's active buffer is a terminal, its producer emits +`TerminalFrame` plus the existing global theme/font/statusline facts that still +apply. It suppresses document-only style spans, decorations, inlays, block +adornments, folds, file summaries, line numbers, and document cursor layout for +that buffer. On switching back to a document, existing caches are invalidated +so the first document frame is a full authoritative resync. + +The terminal frame is scoped to the authenticated frontend/window context, +because scrollback offset and selection are per view. A frame for one split or +frontend must never overwrite another context's baseline. + +A GPU frontend reports its terminal viewport in **cells**, computed from its +own font metrics and pixel allocation. No pixel dimensions, glyph advances, +or DPI cross the daemon boundary. The daemon accepts a resize only from the +controlling active frontend defined in §5.4. + +### 6.3 GPU renderer + +The GPU keeps a dedicated terminal render mode keyed by active `buffer_id`. +It does not synthesize rope text from cells. Layout rules: + +- one terminal column equals the active monospace cell advance; +- `Glyph::Continuation` consumes a column and draws nothing; +- clusters shape as one cell origin with the declared one/two-column footprint; +- terminal foreground/background/reverse/style resolve from the cell, not + syntax or UI faces; +- selection spans resolve through `ui.selection` over child cells; cursor + placement comes from terminal snapshot state; +- rows never wrap in the frontend; clipping is by terminal cell bounds; +- status band remains outside the terminal grid; +- theme/font changes invalidate terminal shaping and geometry caches; +- a font-size or window-size change recomputes the cell viewport and sends one + `TerminalResize` after suppression of identical sizes. + +Terminal mouse hit-testing is frontend-local pixel -> terminal cell. The GPU +sends `TerminalPointer`, never a fake source byte offset. + +## 7. Four-agent execution plan + +The vterm roster is fixed at four agents for all three stages. Do not add +review/scout agents; reuse these owners so state-machine decisions stay +coherent. + +| Owner | Stable scope | Primary files | +| --- | --- | --- | +| Lead/integrator | contracts first; `TerminalManager`, buffer/Lua/builtin wiring, cross-surface acceptance, gates, docs, branches/PRs | `src/terminal/session.rs`, `src/lua_bindings/mod.rs`, `builtin/runtime/terminal.lua`, `tests/vterm_*_acceptance.rs`, docs | +| VT core agent | streaming parser operations, screen state machine, input encoder, model units | `src/ansi.rs`, `src/terminal/screen.rs`, `src/terminal/input.rs` | +| TUI agent | terminal window composition, cursor, per-view scroll/selection/copy, grid input and resize | `src/terminal/view.rs`, owned sections of `src/editor.rs`, focused TUI tests | +| Protocol/GPU agent | v19 types/limits/gates, semantic terminal producer, authenticated daemon routing, GPU state/render/hit-test | `pmacs-protocol`, `src/protocol.rs`, `src/semantic_render.rs`, owned sections of `src/daemon.rs`, `pmacs-gpu/src/main.rs` | + +Coordination rules: + +- Lead establishes types and method signatures before another lane edits a + caller. +- Strict file ownership. `src/editor.rs` passes from lead to TUI only after + stage-1 construction wiring is settled; `src/daemon.rs` belongs only to the + protocol/GPU lane in stage 3. +- Workers do not update docs, ledgers, branches, or PRs and do not stash, + checkout, rebase, or merge. +- Workers add focused tests in their owned modules. Lead alone owns shared + acceptance files. +- Exact-path staging only; never `git add .`. +- Four agents are the total vterm team, not four implementation workers plus a + lead. + +Per-stage utilization: + +- Stage 1: lead + VT core implement; TUI and protocol/GPU owners review the + snapshot/input contracts against their future consumers. +- Stage 2: TUI implements; VT core owns encoder corrections; lead integrates + lifecycle/acceptance; protocol/GPU owner checks that no TUI-only assumption + enters the snapshot contract. +- Stage 3: protocol/GPU implements; TUI and VT core owners add parity cases in + their existing surfaces; lead integrates and gates. + +## 8. Branch and PR plan + +Stage 1 is implemented and fully gated on `vterm-core`; review fixes through +`bf972a7` are published in open, non-draft PR #126 +(), targeting `main` and not +merged. Continue the approved plan only after the preceding PR lands: + +1. review PR #126; 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 + new `main`; implement/gate/open the third PR. + +The framing branch is `vterm-framing` in worktree `pmacs-vterm-framing`. +Implementation branches are not stacked across an unmerged parent. This avoids +base-branch deletion/auto-close risk and makes each PR's gate evidence honest. + + +## 9. Acceptance + +### Stage 1 — terminal core + +1. Feed every supported CSI/OSC/DEC sequence, including IND/NEL/RI, at every + byte split; whole-feed and split-feed screens are identical. RI and + forward-index operations additionally pin exact scrolling-margin behavior. +2. Split UTF-8, malformed UTF-8, truncated escape, over-cap control string, + and unknown private sequences recover without panic, unbounded growth, or + visible escape leakage. +3. Cursor absolute/relative movement, save/restore, origin mode, margins, + insert/delete character/line, erase, and scroll mutate only the specified + cells. +4. Autowrap pending state, wide glyphs, combining clusters, overwrite, erase, + and clipping never create orphan continuations. +5. Main/alternate screen swaps preserve the main grid and cursor; alternate + output never enters scrollback. Synchronized output publishes no + intermediate frame and releases on reset, EOF, and watchdog expiry. +6. DEC line drawing through G0/G1 + SI/SO renders the expected Unicode box + glyphs across split feeds. +7. SGR indexed/truecolor/underline/reverse survives screen operations; ignored + attributes leave supported fields unchanged. +8. Main-screen resize reflows only soft wraps, preserves cursor/logical-line + identity and application tab stops, and adds defaults only in new columns; + alternate-screen resize clips/pads without reflow. +9. Scrollback obeys both row and cell budgets, evicts oldest rows, and keeps + the visible grid exact. +10. DA/DSR/CPR query events produce bounded exact response bytes; unsupported + OSC/DCS cannot write stdin or clipboard. +11. Strict owned `TerminalSpec` values reject before spawn and are + mutation-independent after spawn. +12. A real PTY child prints cursor-addressed/alternate-screen content in + adversarial chunks; final output lands first, then exact normal/non-zero/ + signal process annotations are visible and copyable before exit state. +13. Spawn failure leaves no buffer/session/process residue. Killing a live + terminal buffer terminates it; editor shutdown leaves no child/reader. +14. Every rope mutation path (ordinary, intercept-skipping, undo/redo, local + or remote CRDT edit/import) rejects the read-only terminal buffer and + leaves its rope/revision/modified state unchanged; immutable empty CRDT + bootstrap remains valid. + +### Stage 2 — TUI + +15. Lua `open` performs the same strict raw-field validation, publishes no + partial state on error, and switches the active window only after success. +16. A terminal window paints exact cells/styles inside its content rectangle; + statusline, sibling splits, and outside cells are untouched. +17. Active cursor translation, child-hidden cursor, passive window, clipping, + and scrolled-back hiding are exact. +18. Printable, Ctrl, Alt, arrows, Home/End, function keys, application cursor, + focus reporting, and unknown keys produce the specified PTY bytes. +19. `C-c` dispatches one editor key; `C-c C-c` sends Ctrl-C; modal minibuffer, + search, menu, and query-replace remain authoritative. +20. Paste is byte-exact with bracketed wrappers only when enabled. +21. Mouse-reporting modes receive translated SGR reports. With reporting off, + the same gestures scroll/select/copy and write no PTY bytes. +22. Copy handles soft/hard wraps, trailing blanks, wide/combining glyphs, + resize/reflow, eviction-clamped anchors, and selections crossing + history/screen exactly once. +23. The controlling active view alone resizes the PTY; passive split/frontend + renders never cause resize thrash. +24. A hermetic real TUI smoke opens `/bin/sh`, runs a cursor-addressed probe, + resizes, scrolls/copies, exits, and restores the host terminal cleanly. + +### Stage 3 — GPU/protocol + +25. Protocol v19 appends all new variants after v18 pins; v18 grid traffic + round-trips unchanged and new outbound variants are version-gated. +26. Terminal frame validation accepts exact shared boundaries and atomically + rejects over-area, bad area, out-of-bounds cursor, malformed cluster, + orphan continuation, invalid selection spans, attachment, overlong title, + and overlong process-state text while retaining the prior valid frame. +27. Semantic terminal activation suppresses document-only messages; switching + back forces a complete document resync. +28. Two frontends/splits on one terminal keep independent scroll/selection + snapshots; only the active controlling context resizes or writes input. +29. Forged frontend/buffer IDs in terminal resize/pointer events cannot affect + another terminal or process. +30. Headless GPU rendering pins background rectangles, indexed/truecolor, + reverse, wide/combining cells, clipping, cursor visibility, status-band + separation, and no frontend wrapping. +31. Font/window resize emits cell dimensions, never pixels, and identical + resize requests are suppressed. +32. Theme/font/terminal generation changes invalidate exactly the affected + caches; an unchanged terminal frame produces no redraw message. +33. A real daemon + required-GPU smoke runs a full-screen alternate-screen + probe, handles input and resize, exits, and returns to the preserved main + screen. + +## 10. Gates and bite verification + +Every PR runs the standing full gates from `AGENTS.md`, sequentially, plus its +stage acceptance suite. Stage 2 includes a real hermetic TUI PTY smoke; stage 3 +includes `PMACS_REQUIRE_GPU=1 cargo test -p pmacs-gpu` and the real-daemon GPU +probe. + +New behavioral acceptance must be bite-verified against the immediate +pre-stage tree with `scripts/bite` where the swapped files compile. Protocol +v19 tests additionally pin postcard bytes and verify the older-version daemon +filter; a test that merely fails to decode on old code is not a useful bite. + +## 11. Explicit deferrals + +Not part of these three PRs: + +- terminal image protocols (sixel, kitty graphics, iTerm images); +- OSC 52 host clipboard writes and OSC 8 hyperlink interaction; +- faint, blink, conceal, and strikethrough additions to shared `Style`; +- kitty keyboard protocol, key release events, media keys, and IME preedit; +- cursor-shape/blink rendering and numeric-keypad distinction absent from the + current normalized input/cursor protocol; +- RIS (`ESC c`), DECALN (`ESC # 8`), and DEC cursor save/restore mode `?1048`; +- CUU/CUD region clamping when the cursor starts inside scroll margins while + origin mode is disabled; +- combining a character into the preceding cell across intervening SGR or + cursor-control events; +- DECSTBM clamping when an explicit bottom margin exceeds the current screen + height; the current core leaves the existing scrolling region unchanged; +- exact xterm `?1047` clear-on-exit and scroll-margin preservation across + alternate-screen switches; +- legacy X10 mouse byte encoding when a child enables mouse tracking without + SGR mode; Stage 2 sends no report for that unsupported combination; +- nonstandard `CSI 3 K` ignore semantics (the current core clears the line); +- the ASCII fast path that avoids grapheme-candidate allocation and + segmentation for every printable character after another ASCII character, + and avoiding the per-sequence `intermediates` clone in CSI dispatch; +- cleanup of the defensive impossible-state path where a terminal spawn + returns a process without a running PID, and borrow-tolerant `EditorState` + drop; normal spawn/rollback/prune/shutdown paths remain covered; +- a separately named configuration-time scrollback-row cap; the current + validation conservatively reuses the history-cell cap before the runtime + row and cell budgets enforce the effective limit; +- shell integration, prompt marks, command semantic zones, and cwd reporting; +- ordinary document search over terminal history; +- terminal session persistence/reconnect across editor restart; +- reparenting a live terminal process into a second daemon instance; +- user-configurable escape key and scrollback policy. + +Deferral means graceful ignore or documented absence, never escape leakage, +panic, unbounded allocation, or child leak. + +## 12. Resolved decisions + +The 2026-07-21 architecture discussion resolved every Revision 1 question: + +1. Fixed terminal editor escape: `C-c`; `C-c C-c` sends literal Ctrl-C. +2. Resize: reflow main-screen soft wraps; clip/pad alternate screen. +3. Exit: retain the buffer and append the process PID/outcome line from §4.1. +4. Compatibility: additive v19; v18 grid remains supported, v18 semantic has + no terminal surface. +5. GPU wire: complete visible frames with complete-payload suppression. +6. Style: preserve the shared encoding and defer unsupported attributes. +7. Identity: one process/screen per terminal `BufferId`; the most recently + active frontend's active view controls PTY size. diff --git a/src/ansi.rs b/src/ansi.rs index 4455fe7..f57d58c 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,61 @@ pub enum AnsiEvent { /// `CSI ? 1049 l`: alternate-screen exited. `Text` and /// `SetStyle` resume. AlternateScreenExit, + /// Full-screen-only terminal operations. + Bell, + LineFeed, + /// `ESC D`: advance one row, scrolling at the bottom margin. + Index, + /// `ESC E`: return to column zero and advance one row. + NextLine, + /// `ESC M`: move up one row, scrolling down at the top margin. + ReverseIndex, + HorizontalTab, + SetTabStop, + ClearTabStop, + ClearAllTabStops, + CursorUp(u32), + CursorDown(u32), + CursorForward(u32), + CursorBackward(u32), + CursorNextLine(u32), + CursorPreviousLine(u32), + CursorHorizontalAbsolute(u32), + CursorVerticalAbsolute(u32), + CursorPosition { + row: u32, + col: u32, + }, + EraseDisplay(EraseMode), + EraseLineMode(EraseMode), + EraseCharacters(u32), + InsertCharacters(u32), + DeleteCharacters(u32), + InsertLines(u32), + DeleteLines(u32), + ScrollUp(u32), + ScrollDown(u32), + SetScrollingRegion { + top: u32, + bottom: Option, + }, + SaveCursor, + RestoreCursor, + AlternateScreen { + mode: AlternateScreenMode, + enabled: bool, + }, + SetMode { + mode: TerminalMode, + enabled: bool, + }, + DesignateCharacterSet { + slot: CharacterSetSlot, + charset: CharacterSet, + }, + ShiftOut, + ShiftIn, + DeviceRequest(DeviceRequest), } /// Tunable knobs for [`AnsiParser`]. @@ -155,6 +285,7 @@ enum State { Ground, Escape, EscapeIntermediate, + EscapeIgnore, CsiEntry, CsiParam, CsiIntermediate, @@ -326,6 +457,7 @@ pub struct AnsiParser { osc_body: Vec, /// Intermediate bytes for plain ESC sequences (`ESC` + 0x20..=0x2F). escape_intermediates: Vec, + profile: AnsiParserProfile, config: AnsiParserConfig, } @@ -339,12 +471,24 @@ impl AnsiParser { /// Construct a parser with default configuration. #[must_use] pub fn new() -> Self { - Self::with_config(AnsiParserConfig::default()) + Self::with_profile(AnsiParserProfile::LineOriented) } - /// Construct a parser with custom configuration. + /// Construct a parser using the selected compatibility profile. + #[must_use] + pub fn with_profile(profile: AnsiParserProfile) -> Self { + Self::with_profile_and_config(profile, AnsiParserConfig::default()) + } + + /// Construct a line-oriented parser with custom configuration. #[must_use] pub fn with_config(config: AnsiParserConfig) -> Self { + Self::with_profile_and_config(AnsiParserProfile::LineOriented, config) + } + + /// Construct a parser with both an explicit profile and configuration. + #[must_use] + pub fn with_profile_and_config(profile: AnsiParserProfile, config: AnsiParserConfig) -> Self { Self { state: State::Ground, current_style: Style::default(), @@ -356,6 +500,7 @@ impl AnsiParser { csi: CsiCollector::default(), osc_body: Vec::new(), escape_intermediates: Vec::new(), + profile, config, } } @@ -396,11 +541,11 @@ impl AnsiParser { // transition path (flush_text_run) does emit U+FFFD for // pending bytes because a non-text byte genuinely // interrupts the sequence; feed-boundary doesn't. - if !self.text_run.is_empty() && !self.alt_screen_active { + if !self.text_run.is_empty() { let run = std::mem::take(&mut self.text_run); - events.push(AnsiEvent::Text(run)); - } else { - self.text_run.clear(); + if !self.suppress_visible() { + events.push(AnsiEvent::Text(run)); + } } events } @@ -431,23 +576,22 @@ impl AnsiParser { pub fn finish(&mut self) -> Vec { let mut events = Vec::new(); self.flush_pending_utf8_as_replacement(); - if !self.text_run.is_empty() && !self.alt_screen_active { + if !self.text_run.is_empty() { let run = std::mem::take(&mut self.text_run); - events.push(AnsiEvent::Text(run)); - } else { - self.text_run.clear(); + if !self.suppress_visible() { + events.push(AnsiEvent::Text(run)); + } } - // Balancing state events, in unwind order. `reset` alone - // deliberately preserves alt-screen suppression (a - // mid-stream reset must not unhide alt-screen contents); a - // stream END does end it, observably. - if self.alt_screen_active { - self.alt_screen_active = false; - events.push(AnsiEvent::AlternateScreenExit); - } - if self.emitted_style != Style::default() { - events.push(AnsiEvent::SetStyle(Style::default())); + if self.profile == AnsiParserProfile::LineOriented { + if self.alt_screen_active { + self.alt_screen_active = false; + events.push(AnsiEvent::AlternateScreenExit); + } + if self.emitted_style != Style::default() { + events.push(AnsiEvent::SetStyle(Style::default())); + } } + self.alt_screen_active = false; self.current_style = Style::default(); self.emitted_style = Style::default(); self.reset(); @@ -472,28 +616,53 @@ impl AnsiParser { return; } - // Per-state byte cap. The counter increments for every byte - // consumed in any non-Ground state, and is reset to zero at - // every transition into a fresh sequence (ESC-anywhere) or - // back to Ground (normal dispatch / force-recover). At the - // limit, the parser drops the in-flight sequence and - // returns to Ground; the *current* byte is dropped on the - // floor, but subsequent bytes are processed normally as - // ordinary text. Spec §sec:ansi-scope: "drops back to ground - // state at the next ESC or after a bounded number of bytes - // (1 KiB), whichever comes first." - if self.state != State::Ground { + // Bound retained control-string payload without ever exposing its + // overflow as printable text. Once capped, remain in a zero-storage + // ignore state until BEL/ST or a fresh ESC sequence provides a safe + // recovery boundary. + if self.state != State::Ground + && !matches!( + self.state, + State::EscapeIgnore | State::CsiIgnore | State::OscIgnore | State::DcsIgnore + ) + { self.ignore_byte_count = self.ignore_byte_count.saturating_add(1); if self.ignore_byte_count > self.config.unknown_sequence_byte_limit { - self.recover_to_ground(); + match self.state { + State::OscString | State::OscEscPending => { + self.osc_body.clear(); + self.state = State::OscIgnore; + self.ignore_byte_count = 0; + } + State::DcsEntry + | State::DcsParam + | State::DcsIntermediate + | State::DcsPassthrough + | State::SosPmApcString => { + self.state = State::DcsIgnore; + self.ignore_byte_count = 0; + } + State::Escape | State::EscapeIntermediate => { + self.escape_intermediates.clear(); + self.state = State::EscapeIgnore; + self.ignore_byte_count = 0; + } + State::CsiEntry | State::CsiParam | State::CsiIntermediate => { + self.csi.reset(); + self.state = State::CsiIgnore; + self.ignore_byte_count = 0; + } + _ => self.recover_to_ground(), + } return; } } match self.state { State::Ground => self.feed_ground(b, events), - State::Escape => self.feed_escape(b), - State::EscapeIntermediate => self.feed_escape_intermediate(b), + State::Escape => self.feed_escape(b, events), + State::EscapeIntermediate => self.feed_escape_intermediate(b, events), + State::EscapeIgnore => self.feed_escape_ignore(b), State::CsiEntry => self.feed_csi_entry(b, events), State::CsiParam => self.feed_csi_param(b, events), State::CsiIntermediate => self.feed_csi_intermediate(b, events), @@ -530,13 +699,13 @@ impl AnsiParser { return; } let run = std::mem::take(&mut self.text_run); - if !self.alt_screen_active { + if !self.suppress_visible() { events.push(AnsiEvent::Text(run)); } } fn emit_set_style(&mut self, events: &mut Vec) { - if self.alt_screen_active { + if self.suppress_visible() { return; } self.emitted_style = self.current_style; @@ -548,11 +717,15 @@ impl AnsiParser { /// paste / `SetTitle`). The alt-screen markers themselves /// bypass this. fn push_visible(&self, ev: AnsiEvent, events: &mut Vec) { - if !self.alt_screen_active { + if !self.suppress_visible() { events.push(ev); } } + fn suppress_visible(&self) -> bool { + self.profile == AnsiParserProfile::LineOriented && self.alt_screen_active + } + /// Begin a fresh escape sequence (called from ESC-anywhere). /// Resets the byte budget and all in-flight sequence state. fn start_new_sequence(&mut self) { @@ -579,44 +752,53 @@ impl AnsiParser { // ----------------------------------------------------------------------- fn feed_ground(&mut self, b: u8, events: &mut Vec) { - match b { - // CR: flush text, emit CarriageReturn. - 0x0D => { - self.flush_text_run(events); - if !self.alt_screen_active { - events.push(AnsiEvent::CarriageReturn); + if self.profile == AnsiParserProfile::LineOriented { + match b { + 0x0D => { + self.flush_text_run(events); + self.push_visible(AnsiEvent::CarriageReturn, events); } + 0x08 => { + self.flush_text_run(events); + self.push_visible(AnsiEvent::Backspace, events); + } + 0x07 | 0x09 | 0x0A | 0x0B | 0x0C | 0x20..=0x7E | 0x80..=0xFF => { + self.push_text_byte(b); + } + 0x00..=0x1F | 0x7F => {} + } + return; + } + match b { + 0x07 => { + self.flush_text_run(events); + events.push(AnsiEvent::Bell); } - // BS: flush text, emit Backspace. 0x08 => { self.flush_text_run(events); - if !self.alt_screen_active { - events.push(AnsiEvent::Backspace); - } + events.push(AnsiEvent::Backspace); } - // BEL (0x07), VT (0x0B), FF (0x0C), HT (0x09), LF - // (0x0A): pass through to text alongside printable - // ASCII (0x20..=0x7E). The REPL view treats LF as a - // line break in the rope; HT as a literal tab. Other - // C0 controls (0x00..=0x06, 0x0E..=0x1F) and DEL - // (0x7F) are dropped silently. - // - // 0x80..=0xFF: UTF-8 lead or continuation byte. Goes - // through `push_text_byte`'s stateful decoder so - // multi-byte sequences across feeds are buffered until - // complete. - // - // All text bytes route through `push_text_byte` (not - // just non-ASCII): an ASCII byte arriving while a - // partial UTF-8 sequence is pending invalidates that - // sequence (the partial prefix's expected continuation - // didn't arrive), and `push_text_byte` is the only - // place that knows to flush the partial as `U+FFFD`. - // The fast path inside `push_text_byte` keeps the - // pure-ASCII case allocation-free. - 0x07 | 0x09 | 0x0A | 0x0B | 0x0C | 0x20..=0x7E | 0x80..=0xFF => { - self.push_text_byte(b); + 0x09 => { + self.flush_text_run(events); + events.push(AnsiEvent::HorizontalTab); } + 0x0A..=0x0C => { + self.flush_text_run(events); + events.push(AnsiEvent::LineFeed); + } + 0x0D => { + self.flush_text_run(events); + events.push(AnsiEvent::CarriageReturn); + } + 0x0E => { + self.flush_text_run(events); + events.push(AnsiEvent::ShiftOut); + } + 0x0F => { + self.flush_text_run(events); + events.push(AnsiEvent::ShiftIn); + } + 0x20..=0x7E | 0x80..=0xFF => self.push_text_byte(b), 0x00..=0x1F | 0x7F => {} } } @@ -726,7 +908,7 @@ impl AnsiParser { // Escape // ----------------------------------------------------------------------- - fn feed_escape(&mut self, b: u8) { + fn feed_escape(&mut self, b: u8, events: &mut Vec) { match b { 0x20..=0x2F => { self.escape_intermediates.push(b); @@ -740,42 +922,66 @@ impl AnsiParser { self.osc_body.clear(); self.state = State::OscString; } - // DCS / SOS / PM / APC introducers --- parse and discard. - b'P' => { - self.state = State::DcsEntry; - } - b'X' | b'^' | b'_' => { - self.state = State::SosPmApcString; - } - // ESC \ in Escape state is a stray ST; final byte for - // a bare ESC sequence (0x30..=0x7E) lands here too. We - // don't dispatch any single-byte ESC commands in v0.1 - // (cursor save/restore `ESC 7`/`ESC 8` are deliberately - // unsupported per spec); both cases consume and return - // to Ground. - b'\\' | 0x30..=0x7E => { + b'P' => self.state = State::DcsEntry, + b'X' | b'^' | b'_' => self.state = State::SosPmApcString, + b'7' | b'8' | b'D' | b'E' | b'H' | b'M' | b'=' | b'>' + if self.profile == AnsiParserProfile::FullScreen => + { + let event = match b { + b'7' => AnsiEvent::SaveCursor, + b'8' => AnsiEvent::RestoreCursor, + b'D' => AnsiEvent::Index, + b'E' => AnsiEvent::NextLine, + b'H' => AnsiEvent::SetTabStop, + b'M' => AnsiEvent::ReverseIndex, + b'=' => AnsiEvent::SetMode { + mode: TerminalMode::ApplicationKeypad, + enabled: true, + }, + _ => AnsiEvent::SetMode { + mode: TerminalMode::ApplicationKeypad, + enabled: false, + }, + }; + events.push(event); self.recover_to_ground(); } - // C0 controls inside Escape: drop, stay in Escape. + b'\\' | 0x30..=0x7E => self.recover_to_ground(), _ => {} } } - fn feed_escape_intermediate(&mut self, b: u8) { + fn feed_escape_intermediate(&mut self, b: u8, events: &mut Vec) { match b { - 0x20..=0x2F => { - self.escape_intermediates.push(b); - } - // Final byte: drop the sequence (no ESC + intermediate - // dispatches in v0.1 --- charsets are deliberately - // unsupported per spec) and return to Ground. + 0x20..=0x2F => self.escape_intermediates.push(b), 0x30..=0x7E => { + if self.profile == AnsiParserProfile::FullScreen { + let slot = match self.escape_intermediates.as_slice() { + [b'('] => Some(CharacterSetSlot::G0), + [b')'] => Some(CharacterSetSlot::G1), + _ => None, + }; + let charset = match b { + b'0' => Some(CharacterSet::DecSpecialGraphics), + b'B' => Some(CharacterSet::Ascii), + _ => None, + }; + if let (Some(slot), Some(charset)) = (slot, charset) { + events.push(AnsiEvent::DesignateCharacterSet { slot, charset }); + } + } self.recover_to_ground(); } _ => {} } } + fn feed_escape_ignore(&mut self, b: u8) { + if matches!(b, 0x30..=0x7E) { + self.recover_to_ground(); + } + } + // ----------------------------------------------------------------------- // CSI // ----------------------------------------------------------------------- @@ -850,70 +1056,129 @@ impl AnsiParser { /// Dispatch a fully-collected CSI sequence. `final_byte` is the /// terminating byte (`0x40..=0x7E`). The collected parameters /// are taken from `self.csi`. + #[allow(clippy::too_many_lines)] fn dispatch_csi(&mut self, final_byte: u8, events: &mut Vec) { - 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 +1224,13 @@ impl AnsiParser { _ => UnderlineStyle::Single, }; } - // 5/6 (blink, rapid blink): mapped to bold per spec - // §sec:ansi-scope ("blink-as-bold"). - 5 | 6 => self.current_style.bold = true, + // Line-oriented compile/REPL consumers historically render + // blink as bold. Full-screen preserves the shared Style + // contract: blink is unsupported and leaves it unchanged. + 5 | 6 if self.profile == AnsiParserProfile::LineOriented => { + self.current_style.bold = true; + } + 5 | 6 => {} 7 => self.current_style.reverse = true, // 8 (concealed/invisible): no-op. Out of scope. 8 => {} @@ -975,9 +1244,8 @@ impl AnsiParser { 22 => self.current_style.bold = false, 23 => self.current_style.italic = false, 24 => self.current_style.underline = UnderlineStyle::None, - // 25 (blink off): no-op. Symmetry with 5/6 → bold: - // we do not unset bold here, since that would also - // unset bold acquired via SGR 1. + // 25 (blink off): unsupported. It must not unset bold + // acquired through SGR 1. 25 => {} 27 => self.current_style.reverse = false, 28 => {} @@ -1039,11 +1307,12 @@ impl AnsiParser { } // ESC: begin ST-terminator check (ESC \). 0x1B => self.state = State::OscEscPending, - // 0x20..=0x7F: body bytes. The per-state byte cap - // (enforced at the top of `feed_byte`) bounds how many - // bytes we'll accept before force-recovering. - 0x20..=0x7F => self.osc_body.push(b), - // Other C0/C1 controls: drop silently, stay in OSC. + // OSC payload is UTF-8 bytes, not ASCII. Retain printable ASCII, + // DEL (compatibility), and all high bytes; lossy UTF-8 decoding at + // dispatch replaces malformed sequences. The per-state cap bounds + // retained storage. + 0x20..=0xFF => self.osc_body.push(b), + // Other C0 controls: drop silently, stay in OSC. _ => {} } } @@ -1083,7 +1352,7 @@ impl AnsiParser { let num: Option = std::str::from_utf8(num_part) .ok() .and_then(|s| s.parse().ok()); - if matches!(num, Some(133)) && !self.alt_screen_active { + if matches!(num, Some(133)) && !self.suppress_visible() { match text_part.first().copied() { Some(b'A') => events.push(AnsiEvent::PromptStart), Some(b'B') => events.push(AnsiEvent::PromptEnd), @@ -1099,13 +1368,66 @@ impl AnsiParser { // above produce events. Other OSC numbers are parsed and // discarded per spec §sec:ansi-scope, with the critical // guarantee that state alignment is preserved. - if matches!(num, Some(0 | 2)) && !self.alt_screen_active { + if matches!(num, Some(0 | 2)) && !self.suppress_visible() { let title = String::from_utf8_lossy(text_part).into_owned(); events.push(AnsiEvent::SetTitle(title)); } } } +fn param(params: &CsiParams, index: usize, default: u32) -> u32 { + params + .get(index) + .map_or(default, |p| if p.main == 0 { default } else { p.main }) +} + +fn erase_mode(value: u32) -> Option { + 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 +2317,177 @@ mod tests { even though the internal style is already default" ); } + + #[test] + fn full_screen_emits_typed_operation_set_across_every_split() { + let bytes = b"\x07\t\n\x1bD\x1bE\x1bM\x1bH\x1b[2A\x1b[3B\x1b[4C\x1b[5D\ + \x1b[2E\x1b[2F\x1b[7G\x1b[8d\x1b[2;3H\x1b[J\x1b[1K\x1b[2X\ + \x1b[3@\x1b[4P\x1b[2L\x1b[2M\x1b[3S\x1b[2T\x1b[2;20r\x1b[s\ + \x1b[u\x1b[3g\x1b[?1;6;7;25;1000;1002;1003;1004;1006;2004;2026h\ + \x1b[?47h\x1b[?1047h\x1b[?1049h\x1b[c\x1b[>c\x1b[5n\x1b[6n"; + let mut whole = AnsiParser::with_profile(AnsiParserProfile::FullScreen); + let expected = whole.feed(bytes); + assert!(expected.contains(&AnsiEvent::Bell)); + assert!(expected.contains(&AnsiEvent::Index)); + assert!(expected.contains(&AnsiEvent::NextLine)); + assert!(expected.contains(&AnsiEvent::ReverseIndex)); + assert!(expected.contains(&AnsiEvent::CursorPosition { row: 2, col: 3 })); + assert!(expected.contains(&AnsiEvent::SetScrollingRegion { + top: 2, + bottom: Some(20) + })); + assert!(expected.contains(&AnsiEvent::SetMode { + mode: TerminalMode::SynchronizedOutput, + enabled: true + })); + assert!(expected.contains(&AnsiEvent::AlternateScreen { + mode: AlternateScreenMode::Mode1049, + enabled: true + })); + assert!(expected.contains(&AnsiEvent::DeviceRequest(DeviceRequest::CursorPosition))); + for split in 0..=bytes.len() { + let mut parser = AnsiParser::with_profile(AnsiParserProfile::FullScreen); + let mut actual = parser.feed(&bytes[..split]); + actual.extend(parser.feed(&bytes[split..])); + assert_eq!(actual, expected, "split {split}"); + } + } + + #[test] + fn full_screen_finish_flushes_without_synthetic_balancing() { + let mut parser = AnsiParser::with_profile(AnsiParserProfile::FullScreen); + let events = parser.feed(b"\x1b[?1049h\x1b[31mred"); + assert!(events.contains(&AnsiEvent::AlternateScreen { + mode: AlternateScreenMode::Mode1049, + enabled: true, + })); + assert!( + events + .iter() + .any(|event| matches!(event, AnsiEvent::SetStyle(_))) + ); + assert!(parser.finish().is_empty()); + assert!(!parser.alt_screen_active); + assert!(parser.finish().is_empty()); + assert_eq!(parser.feed(b"x"), vec![AnsiEvent::Text("x".into())]); + } + + #[test] + fn full_screen_charset_designation_and_shift_are_typed() { + let mut parser = AnsiParser::with_profile(AnsiParserProfile::FullScreen); + assert_eq!( + parser.feed(b"\x1b(0\x1b)B\x0e\x0f"), + vec![ + AnsiEvent::DesignateCharacterSet { + slot: CharacterSetSlot::G0, + charset: CharacterSet::DecSpecialGraphics, + }, + AnsiEvent::DesignateCharacterSet { + slot: CharacterSetSlot::G1, + charset: CharacterSet::Ascii, + }, + AnsiEvent::ShiftOut, + AnsiEvent::ShiftIn, + ] + ); + } + + #[test] + fn full_screen_capped_control_strings_recover_invisibly() { + let config = AnsiParserConfig { + unknown_sequence_byte_limit: 8, + }; + let mut parser = AnsiParser::with_profile_and_config(AnsiParserProfile::FullScreen, config); + let events = parser.feed(b"\x1b]52;AAAAAAAABsecret\x1b\\ok\x1bPAAAAAAAAAAAA\x1b\\done"); + let visible: String = events + .iter() + .filter_map(|event| match event { + AnsiEvent::Text(text) => Some(text.as_str()), + _ => None, + }) + .collect(); + assert!(!visible.contains("secret")); + assert!(!visible.contains("AAAA")); + assert!(visible.ends_with("done")); + assert!(!visible.contains('\x1b')); + } + + #[test] + fn capped_csi_and_escape_intermediate_never_leak_payload() { + let config = AnsiParserConfig { + unknown_sequence_byte_limit: 8, + }; + for input in [ + b"\x1b[12345678901234567890mOK".as_slice(), + b"\x1b[?999999999999999999hOK".as_slice(), + b"\x1b 0OK".as_slice(), + ] { + let mut parser = + AnsiParser::with_profile_and_config(AnsiParserProfile::FullScreen, config); + let visible: String = parser + .feed(input) + .into_iter() + .filter_map(|event| { + if let AnsiEvent::Text(text) = event { + Some(text) + } else { + None + } + }) + .collect(); + assert_eq!(visible, "OK", "input {input:?}"); + } + } + + #[test] + fn full_screen_unsupported_sgr_attributes_leave_style_unchanged() { + let mut parser = AnsiParser::with_profile(AnsiParserProfile::FullScreen); + parser.feed(b"\x1b[1;3;31m"); + let before = parser.current_style; + parser.feed(b"\x1b[2;5;6;8;9;25;28;29m"); + assert_eq!(parser.current_style, before); + assert!(before.bold); + + let mut line = AnsiParser::new(); + line.feed(b"\x1b[5m"); + assert!( + line.current_style.bold, + "line-oriented blink-as-bold compatibility" + ); + } + + #[test] + fn unicode_osc_titles_survive_every_feed_split() { + let bytes = "\u{1b}]2;héllo 世界\u{7}".as_bytes(); + let mut whole = AnsiParser::with_profile(AnsiParserProfile::FullScreen); + let expected = whole.feed(bytes); + assert_eq!(expected, vec![AnsiEvent::SetTitle("héllo 世界".into())]); + for split in 0..=bytes.len() { + let mut parser = AnsiParser::with_profile(AnsiParserProfile::FullScreen); + let mut actual = parser.feed(&bytes[..split]); + actual.extend(parser.feed(&bytes[split..])); + assert_eq!(actual, expected, "split {split}"); + } + } + + #[test] + fn malformed_utf8_osc_title_is_replaced_and_bounded() { + let config = AnsiParserConfig { + unknown_sequence_byte_limit: 32, + }; + let mut parser = AnsiParser::with_profile_and_config(AnsiParserProfile::FullScreen, config); + let events = parser.feed(b"\x1b]0;bad\xfftitle\x07"); + let title = events + .into_iter() + .find_map(|event| { + if let AnsiEvent::SetTitle(title) = event { + Some(title) + } else { + None + } + }) + .expect("title event"); + assert_eq!(title, "bad\u{fffd}title"); + assert!(title.len() <= config.unknown_sequence_byte_limit); + } } 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..a8590ae 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 mut core = self.core.borrow_mut(); + manager.prune(&mut 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..9af5b5b 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,151 @@ 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::Index => t.set("kind", "index")?, + AnsiEvent::NextLine => t.set("kind", "next_line")?, + AnsiEvent::ReverseIndex => t.set("kind", "reverse_index")?, + AnsiEvent::HorizontalTab => t.set("kind", "horizontal_tab")?, + AnsiEvent::SetTabStop => t.set("kind", "set_tab_stop")?, + AnsiEvent::ClearTabStop => t.set("kind", "clear_tab_stop")?, + AnsiEvent::ClearAllTabStops => t.set("kind", "clear_all_tab_stops")?, + AnsiEvent::CursorUp(count) + | AnsiEvent::CursorDown(count) + | AnsiEvent::CursorForward(count) + | AnsiEvent::CursorBackward(count) + | AnsiEvent::CursorNextLine(count) + | AnsiEvent::CursorPreviousLine(count) + | AnsiEvent::EraseCharacters(count) + | AnsiEvent::InsertCharacters(count) + | AnsiEvent::DeleteCharacters(count) + | AnsiEvent::InsertLines(count) + | AnsiEvent::DeleteLines(count) + | AnsiEvent::ScrollUp(count) + | AnsiEvent::ScrollDown(count) => { + let kind = match ev { + AnsiEvent::CursorUp(_) => "cursor_up", + AnsiEvent::CursorDown(_) => "cursor_down", + AnsiEvent::CursorForward(_) => "cursor_forward", + AnsiEvent::CursorBackward(_) => "cursor_backward", + AnsiEvent::CursorNextLine(_) => "cursor_next_line", + AnsiEvent::CursorPreviousLine(_) => "cursor_previous_line", + AnsiEvent::EraseCharacters(_) => "erase_characters", + AnsiEvent::InsertCharacters(_) => "insert_characters", + AnsiEvent::DeleteCharacters(_) => "delete_characters", + AnsiEvent::InsertLines(_) => "insert_lines", + AnsiEvent::DeleteLines(_) => "delete_lines", + AnsiEvent::ScrollUp(_) => "scroll_up", + AnsiEvent::ScrollDown(_) => "scroll_down", + _ => unreachable!("outer match restricts the event"), + }; + t.set("kind", kind)?; + t.set("count", *count)?; + } + AnsiEvent::CursorHorizontalAbsolute(col) => { + t.set("kind", "cursor_horizontal_absolute")?; + t.set("col", *col)?; + } + AnsiEvent::CursorVerticalAbsolute(row) => { + t.set("kind", "cursor_vertical_absolute")?; + t.set("row", *row)?; + } + AnsiEvent::CursorPosition { row, col } => { + t.set("kind", "cursor_position")?; + t.set("row", *row)?; + t.set("col", *col)?; + } + AnsiEvent::EraseDisplay(mode) | AnsiEvent::EraseLineMode(mode) => { + t.set( + "kind", + if matches!(ev, AnsiEvent::EraseDisplay(_)) { + "erase_display" + } else { + "erase_line_mode" + }, + )?; + t.set( + "mode", + match mode { + crate::ansi::EraseMode::ToEnd => "to_end", + crate::ansi::EraseMode::ToStart => "to_start", + crate::ansi::EraseMode::All => "all", + crate::ansi::EraseMode::Saved => "saved", + }, + )?; + } + AnsiEvent::SetScrollingRegion { top, bottom } => { + t.set("kind", "set_scrolling_region")?; + t.set("top", *top)?; + t.set("bottom", *bottom)?; + } + AnsiEvent::SaveCursor => t.set("kind", "save_cursor")?, + AnsiEvent::RestoreCursor => t.set("kind", "restore_cursor")?, + AnsiEvent::AlternateScreen { mode, enabled } => { + t.set("kind", "alternate_screen")?; + t.set( + "mode", + match mode { + crate::ansi::AlternateScreenMode::Mode47 => 47, + crate::ansi::AlternateScreenMode::Mode1047 => 1047, + crate::ansi::AlternateScreenMode::Mode1049 => 1049, + }, + )?; + t.set("enabled", *enabled)?; + } + AnsiEvent::SetMode { mode, enabled } => { + t.set("kind", "set_mode")?; + t.set( + "mode", + match mode { + crate::ansi::TerminalMode::Insert => "insert", + crate::ansi::TerminalMode::Origin => "origin", + crate::ansi::TerminalMode::AutoWrap => "auto_wrap", + crate::ansi::TerminalMode::ApplicationCursor => "application_cursor", + crate::ansi::TerminalMode::ApplicationKeypad => "application_keypad", + crate::ansi::TerminalMode::CursorVisible => "cursor_visible", + crate::ansi::TerminalMode::BracketedPaste => "bracketed_paste", + crate::ansi::TerminalMode::FocusReporting => "focus_reporting", + crate::ansi::TerminalMode::SynchronizedOutput => "synchronized_output", + crate::ansi::TerminalMode::MouseX10 => "mouse_x10", + crate::ansi::TerminalMode::MouseButton => "mouse_button", + crate::ansi::TerminalMode::MouseAny => "mouse_any", + crate::ansi::TerminalMode::MouseSgr => "mouse_sgr", + }, + )?; + t.set("enabled", *enabled)?; + } + AnsiEvent::DesignateCharacterSet { slot, charset } => { + t.set("kind", "designate_character_set")?; + t.set( + "slot", + match slot { + crate::ansi::CharacterSetSlot::G0 => "g0", + crate::ansi::CharacterSetSlot::G1 => "g1", + }, + )?; + t.set( + "charset", + match charset { + crate::ansi::CharacterSet::Ascii => "ascii", + crate::ansi::CharacterSet::DecSpecialGraphics => "dec_special_graphics", + }, + )?; + } + AnsiEvent::ShiftOut => t.set("kind", "shift_out")?, + AnsiEvent::ShiftIn => t.set("kind", "shift_in")?, + AnsiEvent::DeviceRequest(request) => { + t.set("kind", "device_request")?; + t.set( + "request", + match request { + crate::ansi::DeviceRequest::PrimaryAttributes => "primary_attributes", + crate::ansi::DeviceRequest::SecondaryAttributes => "secondary_attributes", + crate::ansi::DeviceRequest::OperatingStatus => "operating_status", + crate::ansi::DeviceRequest::CursorPosition => "cursor_position", + }, + )?; + } } Ok(t) } @@ -7582,6 +7731,7 @@ fn lua_to_spec(table: &Table) -> mlua::Result { mode, restart, ansi_events, + ansi_profile: crate::ansi::AnsiParserProfile::LineOriented, stdin, group, }) @@ -7787,7 +7937,14 @@ pub fn install_process(lua: &Lua, supervisor: &SharedProcessSupervisor) -> mlua: "list", lua.create_function(move |lua, ()| { let sup = s.borrow(); - let ids: Vec = 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..2b53bf3 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, } @@ -749,29 +753,36 @@ impl TermStatus { } /// Map `libc::strsignal` description strings (as surfaced by -/// `portable-pty`) to symbolic SIGFOO names. Unknown descriptions pass -/// through unchanged — better to surface an unfamiliar string than to -/// fabricate a wrong name. Covers every signal in +/// `portable-pty`) to symbolic SIGFOO names. Darwin appends the signal +/// number (for example, `"Terminated: 15"`), while glibc returns only +/// the description. Unknown descriptions pass through unchanged — +/// better to surface an unfamiliar string than to fabricate a wrong +/// name. Covers every signal in /// [`super::lua_bindings::parse_signal`]'s accept-list plus the common /// fault signals that surface during process crashes. fn canonicalize_pty_signal_name(desc: &str) -> String { - match desc { - "Interrupt" => "SIGINT".to_owned(), - "Terminated" => "SIGTERM".to_owned(), - "Killed" => "SIGKILL".to_owned(), - "Hangup" => "SIGHUP".to_owned(), - "Quit" => "SIGQUIT".to_owned(), - "User defined signal 1" => "SIGUSR1".to_owned(), - "User defined signal 2" => "SIGUSR2".to_owned(), - "Aborted" => "SIGABRT".to_owned(), - "Segmentation fault" => "SIGSEGV".to_owned(), - "Floating point exception" => "SIGFPE".to_owned(), - "Illegal instruction" => "SIGILL".to_owned(), - "Broken pipe" => "SIGPIPE".to_owned(), - "Alarm clock" => "SIGALRM".to_owned(), - "Bus error" => "SIGBUS".to_owned(), - other => other.to_owned(), + let base = desc + .rsplit_once(": ") + .filter(|(_, number)| number.parse::().is_ok()) + .map_or(desc, |(description, _)| description); + match base { + "Interrupt" => "SIGINT", + "Terminated" => "SIGTERM", + "Killed" => "SIGKILL", + "Hangup" => "SIGHUP", + "Quit" => "SIGQUIT", + "User defined signal 1" => "SIGUSR1", + "User defined signal 2" => "SIGUSR2", + "Aborted" => "SIGABRT", + "Segmentation fault" => "SIGSEGV", + "Floating point exception" => "SIGFPE", + "Illegal instruction" => "SIGILL", + "Broken pipe" => "SIGPIPE", + "Alarm clock" => "SIGALRM", + "Bus error" => "SIGBUS", + _ => desc, } + .to_owned() } impl Default for ProcessSupervisor { @@ -823,6 +834,23 @@ impl ProcessSupervisor { /// crashes *after* spawn shows up as a [`Termination::Crashed`] /// in the event stream, not as a return error. pub fn spawn(&mut self, spec: ProcessSpec) -> Result { + 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 +862,20 @@ impl ProcessSupervisor { attempt_count: 0, next_restart_at: None, }; - self.start_generation(id, &mut managed)?; + self.start_generation(id, &mut managed, publish_synchronous_failure)?; self.processes.insert(id, managed); Ok(id) } - /// Start a fresh generation for `managed`. Mutates `managed` - /// in place; on failure the state is left as - /// `Terminated(Crashed{...})` and an event is emitted. - fn start_generation(&self, id: ProcessId, managed: &mut ManagedProcess) -> Result<(), String> { + /// Start a fresh generation for `managed`. Mutates `managed` in place; on + /// failure its state is `Terminated(Crashed{...})`, and the event is emitted + /// only when `publish_failure` is true. + fn start_generation( + &self, + id: ProcessId, + managed: &mut ManagedProcess, + publish_failure: bool, + ) -> Result<(), String> { managed.attempt_count += 1; managed.next_restart_at = None; match build_runtime(&managed.spec, id) { @@ -865,11 +898,13 @@ impl ProcessSupervisor { ended: now, }); managed.runtime = None; - let _ = self.events_tx.send(ProcessEvent { - id, - kind: ProcessEventKind::Crashed { error: e.clone() }, - at: now, - }); + if publish_failure { + let _ = self.events_tx.send(ProcessEvent { + id, + kind: ProcessEventKind::Crashed { error: e.clone() }, + at: now, + }); + } Err(e) } } @@ -1207,7 +1242,7 @@ impl ProcessSupervisor { kind: ProcessEventKind::Restarting { attempt }, at: now, }); - let _ = self.start_generation(id, &mut managed); + let _ = self.start_generation(id, &mut managed, true); self.processes.insert(id, managed); } else { // Schedule a restart attempt for `restart_backoff` from @@ -1547,7 +1582,12 @@ fn build_pty_runtime( )]; let output_rx = if spec.ansi_events { let (ansi_tx, ansi_rx) = channel::bounded::(ANSI_EVENT_CHANNEL_CAP); - readers.push(spawn_ansi_parser(byte_rx, ansi_tx, Arc::clone(&cancel))); + readers.push(spawn_ansi_parser( + byte_rx, + ansi_tx, + Arc::clone(&cancel), + spec.ansi_profile, + )); RuntimeOutputRx::Ansi(ansi_rx) } else { RuntimeOutputRx::Bytes(byte_rx) @@ -1954,9 +1994,10 @@ fn spawn_ansi_parser( byte_rx: Receiver, 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 +2005,44 @@ fn spawn_ansi_parser( let (kind, bytes) = match byte_rx.recv_timeout(READER_SEND_POLL_INTERVAL) { Ok(chunk) => chunk, Err(crossbeam::channel::RecvTimeoutError::Timeout) => continue, - Err(crossbeam::channel::RecvTimeoutError::Disconnected) => return, + Err(crossbeam::channel::RecvTimeoutError::Disconnected) => { + let events = parser.finish(); + if !events.is_empty() { + let _ = send_ansi_batch(&ansi_tx, &cancel, events); + } + return; + } }; if !matches!(kind, ReaderKind::Stdout) { continue; } - let mut events = parser.feed(&bytes); - if events.is_empty() { - continue; - } - loop { - match ansi_tx.send_timeout(events, READER_SEND_POLL_INTERVAL) { - Ok(()) => break, - Err(crossbeam::channel::SendTimeoutError::Timeout(rejected)) => { - if cancel.load(Ordering::Relaxed) { - return; - } - events = rejected; - } - Err(crossbeam::channel::SendTimeoutError::Disconnected(_)) => return, - } + let events = parser.feed(&bytes); + if !events.is_empty() && !send_ansi_batch(&ansi_tx, &cancel, events) { + return; } } }) } +fn send_ansi_batch( + ansi_tx: &Sender, + cancel: &AtomicBool, + mut events: AnsiBatch, +) -> bool { + loop { + match ansi_tx.send_timeout(events, READER_SEND_POLL_INTERVAL) { + Ok(()) => return true, + Err(crossbeam::channel::SendTimeoutError::Timeout(rejected)) => { + if cancel.load(Ordering::Relaxed) { + return false; + } + events = rejected; + } + Err(crossbeam::channel::SendTimeoutError::Disconnected(_)) => return false, + } + } +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -2026,6 +2080,30 @@ mod tests { }) } + #[test] + fn pty_signal_names_are_canonical_across_libc_variants() { + assert_eq!(canonicalize_pty_signal_name("Terminated"), "SIGTERM"); + assert_eq!(canonicalize_pty_signal_name("Terminated: 15"), "SIGTERM"); + assert_eq!(canonicalize_pty_signal_name("Killed: 9"), "SIGKILL"); + assert_eq!( + canonicalize_pty_signal_name("Unknown signal: 99"), + "Unknown signal: 99" + ); + } + + #[test] + fn terminal_transactional_spawn_failure_has_no_event_or_process_residue() { + let mut supervisor = ProcessSupervisor::new(); + let spec = ProcessSpec::new( + "unpublished-terminal", + "/definitely/not/a/real/pmacs-terminal-program", + ); + assert!(supervisor.spawn_terminal(spec).is_err()); + supervisor.tick(); + assert_eq!(supervisor.ids().count(), 0); + assert!(supervisor.take_all_events().is_empty()); + } + #[test] fn spawn_pipes_lifecycle_started_then_exited() { let mut sup = ProcessSupervisor::new(); @@ -2618,7 +2696,12 @@ mod tests { let (byte_tx, byte_rx) = channel::bounded::(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..35fada3 --- /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(button) => (button_code(button), true), + MouseKind::Drag(button) => (button_code(button) + 32, false), + MouseKind::Move => (35, false), + MouseKind::ScrollUp => (64, false), + MouseKind::ScrollDown => (65, false), + MouseKind::ScrollLeft => (66, false), + MouseKind::ScrollRight => (67, false), + }; + if mods.contains(Modifiers::SHIFT) { + code += 4; + } + if mods.contains(Modifiers::ALT) { + code += 8; + } + if mods.contains(Modifiers::CTRL) { + code += 16; + } + let final_byte = if release { 'm' } else { 'M' }; + Some( + format!( + "\x1b[<{code};{};{}{final_byte}", + coord.col.saturating_add(1), + coord.row.saturating_add(1) + ) + .into_bytes(), + ) +} + +fn control_byte(ch: char) -> Option { + 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::Right), + CellCoord::new(4, 9), + Modifiers::NONE, + m + ), + Some(b"\x1b[<2;10;5m".to_vec()) + ); + assert_eq!( + encode_mouse( + MouseKind::ScrollDown, + CellCoord::new(1, 2), + Modifiers::SHIFT, + m + ), + Some(b"\x1b[<69;3;2M".to_vec()) + ); + } + + #[test] + fn unsupported_keys_are_invisible() { + assert_eq!(encode_key(Key::Unknown(7), Modifiers::NONE, modes()), None); + assert_eq!(encode_key(Key::F(13), Modifiers::NONE, modes()), None); + assert_eq!(encode_key(Key::Char('c'), Modifiers::META, modes()), None); + assert_eq!(encode_key(Key::Up, Modifiers::HYPER, modes()), None); + let mut mouse_modes = modes(); + mouse_modes.mouse_sgr = true; + mouse_modes.mouse_tracking = MouseTrackingMode::Any; + assert_eq!( + encode_mouse( + MouseKind::Down(MouseButton::Left), + CellCoord::new(0, 0), + Modifiers::META, + mouse_modes, + ), + None, + ); + assert_eq!( + encode_mouse( + MouseKind::Move, + CellCoord::new(4, 9), + Modifiers::HYPER, + mouse_modes, + ), + None, + ); + } +} 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..fe4939f --- /dev/null +++ b/src/terminal/screen.rs @@ -0,0 +1,2019 @@ +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, + }) + } + + /// Apply one parsed terminal operation and return an optional fixed device + /// reply for the session manager to queue to the child. + #[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 | AnsiEvent::Index => { + self.line_feed(); + None + } + AnsiEvent::NextLine => { + self.cursor.col = 0; + self.line_feed(); + None + } + AnsiEvent::ReverseIndex => { + self.reverse_index(); + 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 + } + + /// Release a synchronized-output batch at EOF or session completion. + 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(); + } + 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(); + } + + /// Resize the main screen with soft-wrap reflow and clip/pad the alternate + /// screen, preserving cursor, history, and application tab-stop state. + 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); + if size.cols > old_size.cols { + let first_new_default = (old_size.cols as usize).div_ceil(8) * 8; + for col in (first_new_default..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 ch.is_control() { + continue; + } + 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); + 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) { + self.cursor.pending_wrap = false; + let row = self.cursor.row; + 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 reverse_index(&mut self) { + self.cursor.pending_wrap = false; + if self.cursor.row == self.scroll_top { + self.scroll_down(1); + } else if self.cursor.row > 0 { + 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 = self.next_line_id.saturating_add(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 = next_id.saturating_add(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\x1bE\x1bM\x1bD"; + 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); + } + assert_eq!(whole.snapshot().cursor, Some(CellCoord::new(1, 0))); + 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 control_characters_never_enter_cells() { + let mut s = screen(2, 4); + let before = s.snapshot(); + s.apply_event(AnsiEvent::Text("\u{9b}\n\0".into())); + assert_eq!(s.snapshot(), before); + } + + #[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 index_next_line_and_reverse_index_respect_scrolling_margins() { + let mut s = screen(4, 4); + for (row, value) in ["aaaa", "bbbb", "cccc", "dddd"].into_iter().enumerate() { + s.apply_event(AnsiEvent::CursorPosition { + row: row as u32 + 1, + col: 1, + }); + s.apply_event(AnsiEvent::Text(value.into())); + } + s.apply_event(AnsiEvent::SetScrollingRegion { + top: 2, + bottom: Some(3), + }); + s.apply_event(AnsiEvent::CursorPosition { row: 2, col: 3 }); + s.apply_event(AnsiEvent::ReverseIndex); + assert_eq!(&text(&s.snapshot()), "aaaa bbbbdddd"); + assert_eq!(s.snapshot().cursor, Some(CellCoord::new(1, 2))); + + s.apply_event(AnsiEvent::Index); + assert_eq!(s.snapshot().cursor, Some(CellCoord::new(2, 2))); + s.apply_event(AnsiEvent::NextLine); + assert_eq!(&text(&s.snapshot()), "aaaabbbb dddd"); + assert_eq!(s.snapshot().cursor, Some(CellCoord::new(2, 0))); + } + + #[test] + fn resize_only_adds_default_tab_stops_in_new_columns() { + let mut s = screen(2, 16); + s.apply_event(AnsiEvent::ClearAllTabStops); + s.apply_event(AnsiEvent::CursorHorizontalAbsolute(4)); + s.apply_event(AnsiEvent::SetTabStop); + s.resize(CellSize::new(2, 32)).unwrap(); + s.apply_event(AnsiEvent::CursorHorizontalAbsolute(1)); + s.apply_event(AnsiEvent::HorizontalTab); + assert_eq!(s.snapshot().cursor, Some(CellCoord::new(0, 3))); + s.apply_event(AnsiEvent::HorizontalTab); + assert_eq!(s.snapshot().cursor, Some(CellCoord::new(0, 16))); + } + + #[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..2c1876b --- /dev/null +++ b/src/terminal/session.rs @@ -0,0 +1,606 @@ +//! Terminal process/session registry. + +use std::cell::RefCell; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::rc::Rc; +use std::time::Instant; + +use thiserror::Error; + +use crate::ansi::AnsiParserProfile; +use crate::buffer::{Buffer, BufferId}; +use crate::cell::{Cell, CellCoord, CellSize}; +use crate::editor_core::EditorCore; +use crate::process::{ + ProcessEventKind, ProcessId, ProcessMode, ProcessSpec, ProcessState, ProcessSupervisor, + RestartPolicy, StdinMode, TerminalMode, +}; +use crate::terminal::screen::TerminalScreen; +use crate::terminal::{ + MAX_TERMINAL_COLS, MAX_TERMINAL_HISTORY_CELLS, MAX_TERMINAL_METADATA_BYTES, MAX_TERMINAL_ROWS, + MAX_TERMINAL_VISIBLE_CELLS, +}; + +/// Shared single-owner terminal registry used by editor and future Lua bindings. +pub type SharedTerminalManager = Rc>; + +/// 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. `TERM` defaults to + /// `xterm-256color` when the caller does not provide it. + 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; + if !process_spec.env.iter().any(|(name, _)| name == "TERM") { + process_spec + .env + .push(("TERM".into(), "xterm-256color".into())); + } + process_spec.mode = ProcessMode::Pty { + rows: spec.rows, + cols: spec.cols, + mode: TerminalMode::Raw, + }; + process_spec.restart = RestartPolicy::Never; + process_spec.ansi_events = true; + process_spec.ansi_profile = AnsiParserProfile::FullScreen; + process_spec.stdin = StdinMode::Piped; + process_spec.group = false; + + let process_id = match supervisor.spawn_terminal(process_spec) { + Ok(id) => id, + Err(error) => { + core.registry + .borrow_mut() + .remove(buffer_id) + .map_err(|rollback| { + TerminalError::Buffer(format!( + "spawn failed ({error}); buffer rollback failed: {rollback}" + )) + })?; + return Err(TerminalError::Spawn(error)); + } + }; + let pid = + if let Some(ProcessState::Running { pid, .. } | ProcessState::Exiting { pid, .. }) = + supervisor.state(process_id) + { + *pid + } else { + let _ = supervisor.terminate(process_id); + let _ = core.registry.borrow_mut().remove(buffer_id); + return Err(TerminalError::Spawn( + "supervisor published a PTY without a running pid".into(), + )); + }; + + let previous = self.sessions.insert( + buffer_id, + TerminalSession { + process_id, + pid, + screen, + process: TerminalProcessState::Running, + annotated: false, + }, + ); + debug_assert!(previous.is_none(), "fresh BufferId collided"); + self.process_to_buffer.insert(process_id, buffer_id); + core.set_round_trip_input(buffer_id, true); + Ok(buffer_id) + } + + /// Whether `buffer_id` identifies a published terminal session. + #[must_use] + pub fn is_terminal(&self, buffer_id: BufferId) -> bool { + self.sessions.contains_key(&buffer_id) + } + + /// Owned process id for a terminal buffer. The OS pid stays in snapshots. + #[must_use] + pub fn process_id(&self, buffer_id: BufferId) -> Option { + 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: &mut 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 { + core.set_round_trip_input(buffer_id, false); + let Some(session) = self.sessions.remove(&buffer_id) else { + continue; + }; + self.process_to_buffer.remove(&session.process_id); + match supervisor.state(session.process_id) { + Some( + ProcessState::Starting + | ProcessState::Running { .. } + | ProcessState::Exiting { .. }, + ) => { + let _ = supervisor.terminate(session.process_id); + self.closing.insert(session.process_id); + } + Some(ProcessState::Terminated(_)) => { + let _ = supervisor.take_events(session.process_id); + let _ = supervisor.forget(session.process_id); + } + None => {} + } + } + } + + /// Terminate every terminal child and unpublish all sessions. + /// + /// The editor follows this with the supervisor's bounded global shutdown, + /// which performs final TERM/KILL escalation for terminal and non-terminal + /// processes alike. + pub fn shutdown(&mut self, supervisor: &mut ProcessSupervisor) { + let process_ids: Vec = 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..b01d5b5 --- /dev/null +++ b/tests/vterm_stage1_acceptance.rs @@ -0,0 +1,436 @@ +//! Shared Stage 1 terminal registry, lifecycle, and read-only acceptance. + +use std::time::{Duration, Instant}; + +use pmacs::ansi::AnsiEvent; +use pmacs::buffer::{Buffer, BufferError, BufferId, EditOp}; +use pmacs::cell::{CellSize, Glyph}; +use pmacs::editor::EditorState; +use pmacs::process::ProcessState; +use pmacs::rope::Range; +use pmacs::terminal::screen::TerminalScreen; +use pmacs::terminal::{TerminalProcessState, TerminalSpec}; + +fn rope_bytes(buffer: &Buffer) -> Vec { + let mut bytes = vec![0; buffer.len() as usize]; + if !bytes.is_empty() { + buffer.snapshot_rope().slice(0, buffer.len(), &mut bytes); + } + bytes +} + +fn screen_text(snapshot: &pmacs::terminal::TerminalSnapshot) -> String { + let mut text = String::new(); + for (index, cell) in snapshot.cells.iter().enumerate() { + if index > 0 && index % snapshot.size.cols as usize == 0 { + text.push('\n'); + } + match &cell.glyph { + Glyph::Char(ch) => text.push(*ch), + Glyph::Cluster(bytes) => text.push_str(&String::from_utf8_lossy(bytes)), + Glyph::Continuation => {} + } + } + text +} + +fn tick_until( + state: &mut EditorState, + timeout: Duration, + mut done: impl FnMut(&EditorState) -> bool, +) { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + state.tick_processes(); + if done(state) { + return; + } + std::thread::sleep(Duration::from_millis(5)); + } + panic!("terminal condition did not settle before {timeout:?}"); +} + +#[test] +fn terminal_cells_reject_child_control_characters() { + let mut screen = TerminalScreen::new(CellSize::new(2, 4), 0).expect("valid screen"); + let before = screen.snapshot(); + screen.apply_event(AnsiEvent::Text("\u{9b}\n\0".into())); + assert_eq!(screen.snapshot(), before); +} + +#[test] +fn spawn_failure_is_transactional() { + let mut state = EditorState::new(); + let buffers_before = state.core.borrow().registry.borrow().len(); + let processes_before = state.process_supervisor.borrow().ids().count(); + state.process_supervisor.borrow_mut().shutdown(); + let result = state.open_terminal(TerminalSpec::new("/bin/sh")); + + assert!(result.is_err()); + assert_eq!(state.core.borrow().registry.borrow().len(), buffers_before); + assert_eq!(state.terminal_manager.borrow().len(), 0); + assert_eq!( + state.process_supervisor.borrow().ids().count(), + processes_before + ); +} + +#[test] +fn strict_owned_spec_rejects_before_spawn_and_is_mutation_independent() { + let mut state = EditorState::new(); + let buffers_before = state.core.borrow().registry.borrow().len(); + let mut invalid = TerminalSpec::new("/bin/sh"); + invalid.rows = 0; + assert!(state.open_terminal(invalid).is_err()); + assert_eq!(state.core.borrow().registry.borrow().len(), buffers_before); + assert!(state.terminal_manager.borrow().is_empty()); + assert_eq!(state.process_supervisor.borrow().ids().count(), 0); + + let mut spec = TerminalSpec::new("/bin/sh"); + spec.args = vec!["-c".into(), "sleep 30".into()]; + spec.env = vec![("PMACS_VTERM_OWNED".into(), "original".into())]; + let mut caller_copy = spec.clone(); + let buffer_id = state.open_terminal(spec).expect("valid owned spec"); + caller_copy.command.clear(); + caller_copy.args.clear(); + caller_copy.env[0].1 = "mutated".into(); + let lua_processes: usize = state + .lua_host + .lua() + .load("return #pmacs.process.list()") + .eval() + .expect("process list"); + assert_eq!( + lua_processes, 0, + "terminal-owned ProcessId must not be exposed through pmacs.process" + ); + let terminal_module_absent: bool = state + .lua_host + .lua() + .load("return pmacs.terminal == nil") + .eval() + .expect("terminal module absence"); + assert!( + terminal_module_absent, + "Stage 1 must not publish an unrenderable interactive Lua terminal API" + ); + + let process_id = state + .terminal_manager + .borrow() + .process_id(buffer_id) + .expect("terminal process"); + let supervisor = state.process_supervisor.borrow(); + let process_spec = supervisor.spec(process_id).expect("owned process spec"); + assert_eq!(process_spec.command, "/bin/sh"); + assert_eq!( + process_spec.args, + [String::from("-c"), String::from("sleep 30")] + ); + assert_eq!( + process_spec.env, + [ + (String::from("PMACS_VTERM_OWNED"), String::from("original")), + (String::from("TERM"), String::from("xterm-256color")), + ] + ); +} + +#[test] +fn read_only_guard_covers_direct_skip_undo_and_redo_without_state_change() { + let mut buffer = Buffer::from_bytes(BufferId::next(), "*protected*", b"abc"); + buffer + .apply_edit(EditOp::Insert { + pos: 3, + bytes: b"d", + }) + .expect("seed undo"); + buffer.undo().expect("seed redo"); + buffer.set_read_only(true); + let before = (rope_bytes(&buffer), buffer.revision(), buffer.is_modified()); + assert!(matches!( + buffer.begin_edit(), + Err(BufferError::ReadOnly { .. }) + )); + assert!(!buffer.editing_in_progress()); + + let results = [ + buffer.apply_edit(EditOp::Insert { + pos: 0, + bytes: b"x", + }), + buffer.apply_edit_skip_intercepts(EditOp::Replace { + range: Range::new(0, 1), + bytes: b"y", + }), + buffer.undo(), + buffer.redo(), + ]; + + assert!( + results + .iter() + .all(|result| matches!(result, Err(BufferError::ReadOnly { .. }))) + ); + assert_eq!( + before, + (rope_bytes(&buffer), buffer.revision(), buffer.is_modified()) + ); +} + +#[cfg(feature = "crdt")] +#[test] +fn read_only_empty_crdt_bootstrap_is_immutable_against_remote_content() { + let mut buffer = Buffer::new(BufferId::next(), "*terminal*"); + buffer.set_read_only(true); + buffer + .upgrade_to_crdt(1) + .expect("empty immutable bootstrap is allowed"); + + let donor = pmacs::crdt::CrdtState::new(2).expect("donor"); + let version = donor.version(); + donor.insert(0, "forged").expect("donor edit"); + let update = donor.export_updates_since(&version).expect("update"); + + assert!(matches!( + buffer.apply_remote_crdt_op(&update), + Err(BufferError::ReadOnly { .. }) + )); + assert!(buffer.is_empty()); + assert_eq!(buffer.revision(), 0); + assert!(!buffer.is_modified()); +} + +#[test] +fn final_output_precedes_exact_nonzero_annotation_and_buffer_is_retained() { + let mut state = EditorState::new(); + let mut spec = TerminalSpec::new("/bin/sh"); + spec.args = vec![ + "-c".into(), + concat!( + "printf 'main-home'; ", + "printf '\\033'; sleep 0.03; printf '[?1049h'; ", + "printf '\\033[2;'; sleep 0.03; printf '4HALT'; ", + "IFS= read -r gate; ", + "printf '\\033[?1049l'; ", + "printf '\\033[2;'; sleep 0.03; printf '3Hfinal-'; ", + "sleep 0.03; printf 'output'; exit 7" + ) + .into(), + ]; + spec.rows = 8; + spec.cols = 80; + let buffer_id = state.open_terminal(spec).expect("open terminal"); + + tick_until(&mut state, Duration::from_secs(5), |state| { + state + .terminal_manager + .borrow() + .snapshot(buffer_id) + .is_some_and(|snapshot| { + matches!(snapshot.process, TerminalProcessState::Running) + && screen_text(&snapshot) + .lines() + .nth(1) + .is_some_and(|row| row.starts_with(" ALT")) + }) + }); + let alternate = state + .terminal_manager + .borrow() + .snapshot(buffer_id) + .expect("running alternate-screen snapshot"); + let alternate_text = screen_text(&alternate); + assert!(alternate_text.contains("ALT")); + assert!( + !alternate_text.contains("main-home"), + "alternate screen must not expose the preserved main grid" + ); + { + let manager = state.terminal_manager.borrow(); + let mut supervisor = state.process_supervisor.borrow_mut(); + manager + .send(buffer_id, b"\n", &mut supervisor) + .expect("raw stdin unblocks child"); + } + + tick_until(&mut state, Duration::from_secs(5), |state| { + state + .terminal_manager + .borrow() + .snapshot(buffer_id) + .is_some_and(|snapshot| matches!(snapshot.process, TerminalProcessState::Exited(7))) + }); + + let snapshot = state + .terminal_manager + .borrow() + .snapshot(buffer_id) + .expect("retained terminal snapshot"); + let text = screen_text(&snapshot); + assert!( + text.contains("main-home"), + "leaving alternate screen must restore the main grid" + ); + assert!( + !text.contains("ALT"), + "alternate-screen output must not enter the retained main grid" + ); + assert!( + text.lines() + .nth(1) + .is_some_and(|row| row.starts_with(" final-output")), + "FullScreen parser/profile must honor CSI cursor addressing" + ); + let output_at = text + .find("final-output") + .expect("final child output visible"); + let annotation = format!("Process {} exited abnormally with code 7", snapshot.pid); + let annotation_at = text + .find(&annotation) + .expect("exact exit annotation visible"); + assert!( + output_at < annotation_at, + "final output must precede annotation" + ); + assert!(state.core.borrow().registry.borrow().contains(buffer_id)); + let core = state.core.borrow(); + let registry = core.registry.borrow(); + let buffer = registry.get(buffer_id).expect("identity buffer retained"); + assert!(buffer.is_read_only()); + assert!(buffer.is_empty()); + assert!(!buffer.is_modified()); +} + +#[test] +fn normal_and_signal_annotations_use_exact_pid_and_outcome() { + for (script, expected, annotation_tail) in [ + ( + "printf normal-output; exit 0", + TerminalProcessState::Exited(0), + "exited normally with code 0", + ), + ( + "printf signal-output; kill -TERM $$", + TerminalProcessState::Signaled("SIGTERM".into()), + "exited abnormally with signal SIGTERM", + ), + ] { + let mut state = EditorState::new(); + let mut spec = TerminalSpec::new("/bin/sh"); + spec.args = vec!["-c".into(), script.into()]; + spec.rows = 6; + spec.cols = 80; + let buffer_id = state.open_terminal(spec).expect("open terminal"); + tick_until(&mut state, Duration::from_secs(5), |state| { + state + .terminal_manager + .borrow() + .snapshot(buffer_id) + .is_some_and(|snapshot| snapshot.process == expected) + }); + let snapshot = state + .terminal_manager + .borrow() + .snapshot(buffer_id) + .expect("snapshot retained"); + assert!( + screen_text(&snapshot).contains(&format!("Process {} {annotation_tail}", snapshot.pid)), + "missing exact annotation for {:?}", + snapshot.process + ); + } +} + +#[test] +fn killing_terminal_buffer_prunes_session_and_reaps_owned_process() { + let mut state = EditorState::new(); + let mut spec = TerminalSpec::new("/bin/sh"); + spec.args = vec!["-c".into(), "sleep 30".into()]; + let buffer_id = state.open_terminal(spec).expect("open terminal"); + let process_id = state + .terminal_manager + .borrow() + .process_id(buffer_id) + .expect("owned process"); + + state + .core + .borrow_mut() + .kill_buffer(buffer_id) + .expect("kill identity buffer"); + state.tick_processes(); + assert!(!state.terminal_manager.borrow().is_terminal(buffer_id)); + + tick_until(&mut state, Duration::from_secs(5), |state| { + state + .process_supervisor + .borrow() + .state(process_id) + .is_none() + }); +} + +#[test] +fn editor_shutdown_kills_term_ignoring_terminal_child() { + let pid = { + let mut state = EditorState::new(); + state + .process_supervisor + .borrow_mut() + .set_grace_period(Duration::from_millis(50)); + let mut spec = TerminalSpec::new("/bin/sh"); + spec.args = vec![ + "-c".into(), + "trap '' TERM; while :; do sleep 1; done".into(), + ]; + let buffer_id = state.open_terminal(spec).expect("open terminal"); + state + .terminal_manager + .borrow() + .snapshot(buffer_id) + .expect("snapshot") + .pid + }; + + let pid = nix::unistd::Pid::from_raw(i32::try_from(pid).expect("pid fits i32")); + let deadline = Instant::now() + Duration::from_secs(2); + while Instant::now() < deadline && nix::sys::signal::kill(pid, None).is_ok() { + std::thread::sleep(Duration::from_millis(10)); + } + assert_eq!( + nix::sys::signal::kill(pid, None), + Err(nix::errno::Errno::ESRCH), + "terminal child {pid} survived EditorState shutdown" + ); +} + +#[test] +fn terminal_tick_does_not_take_non_terminal_process_events() { + let mut state = EditorState::new(); + let mut process = pmacs::process::ProcessSpec::new("ordinary", "/bin/sh"); + process.args = vec!["-c".into(), "printf ordinary".into()]; + let ordinary_id = state + .process_supervisor + .borrow_mut() + .spawn(process) + .expect("ordinary process"); + + tick_until(&mut state, Duration::from_secs(5), |state| { + matches!( + state.process_supervisor.borrow().state(ordinary_id), + Some(ProcessState::Terminated(_)) + ) + }); + let events = state + .process_supervisor + .borrow_mut() + .take_events(ordinary_id); + assert!( + events.iter().any(|event| matches!( + &event.kind, + pmacs::process::ProcessEventKind::Stdout(bytes) if bytes == b"ordinary" + )), + "TerminalManager must not steal ordinary process output" + ); +}