Merge pull request #126 from levineuwirth/vterm-core
feat(vterm): add Stage 1 terminal core
This commit is contained in:
commit
643d1e1fcc
|
|
@ -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,
|
||||
<https://github.com/levineuwirth/pmacs/pull/126> (open, non-draft,
|
||||
targeting `main`).
|
||||
- `AnsiParserProfile::{LineOriented, FullScreen}` preserves compile/REPL
|
||||
behavior while terminal PTYs emit the full cursor/mode/device operation
|
||||
set. `src/terminal/{screen,input,session}.rs` owns the state machine,
|
||||
encoders, and lifecycle registry.
|
||||
- Public session seam: owned strict `TerminalSpec`; owned
|
||||
`TerminalSnapshot`; `TerminalProcessState`; and
|
||||
`SharedTerminalManager = Rc<RefCell<TerminalManager>>` with
|
||||
`open/is_terminal/process_id/snapshot/tick/send/resize/terminate/prune/
|
||||
shutdown`. Stage 1 snapshots are context-free; Stage 2 adds per-view
|
||||
state without a second screen.
|
||||
- `EditorState` tick order is supervisor → terminal-owned PID drain/prune →
|
||||
`process.after-tick`. Terminal IDs are not exposed through
|
||||
`pmacs.process`; ordinary Lua/LSP/MCP ownership is unchanged. Terminal
|
||||
identity buffers are pathless, clean, empty, round-trip, and guarded
|
||||
read-only at every rope/CRDT/history mutation boundary.
|
||||
- Acceptance 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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
803
src/ansi.rs
803
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<u32>,
|
||||
},
|
||||
SaveCursor,
|
||||
RestoreCursor,
|
||||
AlternateScreen {
|
||||
mode: AlternateScreenMode,
|
||||
enabled: bool,
|
||||
},
|
||||
SetMode {
|
||||
mode: TerminalMode,
|
||||
enabled: bool,
|
||||
},
|
||||
DesignateCharacterSet {
|
||||
slot: CharacterSetSlot,
|
||||
charset: CharacterSet,
|
||||
},
|
||||
ShiftOut,
|
||||
ShiftIn,
|
||||
DeviceRequest(DeviceRequest),
|
||||
}
|
||||
|
||||
/// Tunable knobs for [`AnsiParser`].
|
||||
|
|
@ -155,6 +285,7 @@ enum State {
|
|||
Ground,
|
||||
Escape,
|
||||
EscapeIntermediate,
|
||||
EscapeIgnore,
|
||||
CsiEntry,
|
||||
CsiParam,
|
||||
CsiIntermediate,
|
||||
|
|
@ -326,6 +457,7 @@ pub struct AnsiParser {
|
|||
osc_body: Vec<u8>,
|
||||
/// Intermediate bytes for plain ESC sequences (`ESC` + 0x20..=0x2F).
|
||||
escape_intermediates: Vec<u8>,
|
||||
profile: AnsiParserProfile,
|
||||
config: AnsiParserConfig,
|
||||
}
|
||||
|
||||
|
|
@ -339,12 +471,24 @@ impl AnsiParser {
|
|||
/// Construct a parser with default configuration.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::with_config(AnsiParserConfig::default())
|
||||
Self::with_profile(AnsiParserProfile::LineOriented)
|
||||
}
|
||||
|
||||
/// Construct a parser with custom configuration.
|
||||
/// Construct a parser using the selected compatibility profile.
|
||||
#[must_use]
|
||||
pub fn with_profile(profile: AnsiParserProfile) -> Self {
|
||||
Self::with_profile_and_config(profile, AnsiParserConfig::default())
|
||||
}
|
||||
|
||||
/// Construct a line-oriented parser with custom configuration.
|
||||
#[must_use]
|
||||
pub fn with_config(config: AnsiParserConfig) -> Self {
|
||||
Self::with_profile_and_config(AnsiParserProfile::LineOriented, config)
|
||||
}
|
||||
|
||||
/// Construct a parser with both an explicit profile and configuration.
|
||||
#[must_use]
|
||||
pub fn with_profile_and_config(profile: AnsiParserProfile, config: AnsiParserConfig) -> Self {
|
||||
Self {
|
||||
state: State::Ground,
|
||||
current_style: Style::default(),
|
||||
|
|
@ -356,6 +500,7 @@ impl AnsiParser {
|
|||
csi: CsiCollector::default(),
|
||||
osc_body: Vec::new(),
|
||||
escape_intermediates: Vec::new(),
|
||||
profile,
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
|
@ -396,11 +541,11 @@ impl AnsiParser {
|
|||
// transition path (flush_text_run) does emit U+FFFD for
|
||||
// pending bytes because a non-text byte genuinely
|
||||
// interrupts the sequence; feed-boundary doesn't.
|
||||
if !self.text_run.is_empty() && !self.alt_screen_active {
|
||||
if !self.text_run.is_empty() {
|
||||
let run = std::mem::take(&mut self.text_run);
|
||||
events.push(AnsiEvent::Text(run));
|
||||
} else {
|
||||
self.text_run.clear();
|
||||
if !self.suppress_visible() {
|
||||
events.push(AnsiEvent::Text(run));
|
||||
}
|
||||
}
|
||||
events
|
||||
}
|
||||
|
|
@ -431,23 +576,22 @@ impl AnsiParser {
|
|||
pub fn finish(&mut self) -> Vec<AnsiEvent> {
|
||||
let mut events = Vec::new();
|
||||
self.flush_pending_utf8_as_replacement();
|
||||
if !self.text_run.is_empty() && !self.alt_screen_active {
|
||||
if !self.text_run.is_empty() {
|
||||
let run = std::mem::take(&mut self.text_run);
|
||||
events.push(AnsiEvent::Text(run));
|
||||
} else {
|
||||
self.text_run.clear();
|
||||
if !self.suppress_visible() {
|
||||
events.push(AnsiEvent::Text(run));
|
||||
}
|
||||
}
|
||||
// Balancing state events, in unwind order. `reset` alone
|
||||
// deliberately preserves alt-screen suppression (a
|
||||
// mid-stream reset must not unhide alt-screen contents); a
|
||||
// stream END does end it, observably.
|
||||
if self.alt_screen_active {
|
||||
self.alt_screen_active = false;
|
||||
events.push(AnsiEvent::AlternateScreenExit);
|
||||
}
|
||||
if self.emitted_style != Style::default() {
|
||||
events.push(AnsiEvent::SetStyle(Style::default()));
|
||||
if self.profile == AnsiParserProfile::LineOriented {
|
||||
if self.alt_screen_active {
|
||||
self.alt_screen_active = false;
|
||||
events.push(AnsiEvent::AlternateScreenExit);
|
||||
}
|
||||
if self.emitted_style != Style::default() {
|
||||
events.push(AnsiEvent::SetStyle(Style::default()));
|
||||
}
|
||||
}
|
||||
self.alt_screen_active = false;
|
||||
self.current_style = Style::default();
|
||||
self.emitted_style = Style::default();
|
||||
self.reset();
|
||||
|
|
@ -472,28 +616,53 @@ impl AnsiParser {
|
|||
return;
|
||||
}
|
||||
|
||||
// Per-state byte cap. The counter increments for every byte
|
||||
// consumed in any non-Ground state, and is reset to zero at
|
||||
// every transition into a fresh sequence (ESC-anywhere) or
|
||||
// back to Ground (normal dispatch / force-recover). At the
|
||||
// limit, the parser drops the in-flight sequence and
|
||||
// returns to Ground; the *current* byte is dropped on the
|
||||
// floor, but subsequent bytes are processed normally as
|
||||
// ordinary text. Spec §sec:ansi-scope: "drops back to ground
|
||||
// state at the next ESC or after a bounded number of bytes
|
||||
// (1 KiB), whichever comes first."
|
||||
if self.state != State::Ground {
|
||||
// Bound retained control-string payload without ever exposing its
|
||||
// overflow as printable text. Once capped, remain in a zero-storage
|
||||
// ignore state until BEL/ST or a fresh ESC sequence provides a safe
|
||||
// recovery boundary.
|
||||
if self.state != State::Ground
|
||||
&& !matches!(
|
||||
self.state,
|
||||
State::EscapeIgnore | State::CsiIgnore | State::OscIgnore | State::DcsIgnore
|
||||
)
|
||||
{
|
||||
self.ignore_byte_count = self.ignore_byte_count.saturating_add(1);
|
||||
if self.ignore_byte_count > self.config.unknown_sequence_byte_limit {
|
||||
self.recover_to_ground();
|
||||
match self.state {
|
||||
State::OscString | State::OscEscPending => {
|
||||
self.osc_body.clear();
|
||||
self.state = State::OscIgnore;
|
||||
self.ignore_byte_count = 0;
|
||||
}
|
||||
State::DcsEntry
|
||||
| State::DcsParam
|
||||
| State::DcsIntermediate
|
||||
| State::DcsPassthrough
|
||||
| State::SosPmApcString => {
|
||||
self.state = State::DcsIgnore;
|
||||
self.ignore_byte_count = 0;
|
||||
}
|
||||
State::Escape | State::EscapeIntermediate => {
|
||||
self.escape_intermediates.clear();
|
||||
self.state = State::EscapeIgnore;
|
||||
self.ignore_byte_count = 0;
|
||||
}
|
||||
State::CsiEntry | State::CsiParam | State::CsiIntermediate => {
|
||||
self.csi.reset();
|
||||
self.state = State::CsiIgnore;
|
||||
self.ignore_byte_count = 0;
|
||||
}
|
||||
_ => self.recover_to_ground(),
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
match self.state {
|
||||
State::Ground => self.feed_ground(b, events),
|
||||
State::Escape => self.feed_escape(b),
|
||||
State::EscapeIntermediate => self.feed_escape_intermediate(b),
|
||||
State::Escape => self.feed_escape(b, events),
|
||||
State::EscapeIntermediate => self.feed_escape_intermediate(b, events),
|
||||
State::EscapeIgnore => self.feed_escape_ignore(b),
|
||||
State::CsiEntry => self.feed_csi_entry(b, events),
|
||||
State::CsiParam => self.feed_csi_param(b, events),
|
||||
State::CsiIntermediate => self.feed_csi_intermediate(b, events),
|
||||
|
|
@ -530,13 +699,13 @@ impl AnsiParser {
|
|||
return;
|
||||
}
|
||||
let run = std::mem::take(&mut self.text_run);
|
||||
if !self.alt_screen_active {
|
||||
if !self.suppress_visible() {
|
||||
events.push(AnsiEvent::Text(run));
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_set_style(&mut self, events: &mut Vec<AnsiEvent>) {
|
||||
if self.alt_screen_active {
|
||||
if self.suppress_visible() {
|
||||
return;
|
||||
}
|
||||
self.emitted_style = self.current_style;
|
||||
|
|
@ -548,11 +717,15 @@ impl AnsiParser {
|
|||
/// paste / `SetTitle`). The alt-screen markers themselves
|
||||
/// bypass this.
|
||||
fn push_visible(&self, ev: AnsiEvent, events: &mut Vec<AnsiEvent>) {
|
||||
if !self.alt_screen_active {
|
||||
if !self.suppress_visible() {
|
||||
events.push(ev);
|
||||
}
|
||||
}
|
||||
|
||||
fn suppress_visible(&self) -> bool {
|
||||
self.profile == AnsiParserProfile::LineOriented && self.alt_screen_active
|
||||
}
|
||||
|
||||
/// Begin a fresh escape sequence (called from ESC-anywhere).
|
||||
/// Resets the byte budget and all in-flight sequence state.
|
||||
fn start_new_sequence(&mut self) {
|
||||
|
|
@ -579,44 +752,53 @@ impl AnsiParser {
|
|||
// -----------------------------------------------------------------------
|
||||
|
||||
fn feed_ground(&mut self, b: u8, events: &mut Vec<AnsiEvent>) {
|
||||
match b {
|
||||
// CR: flush text, emit CarriageReturn.
|
||||
0x0D => {
|
||||
self.flush_text_run(events);
|
||||
if !self.alt_screen_active {
|
||||
events.push(AnsiEvent::CarriageReturn);
|
||||
if self.profile == AnsiParserProfile::LineOriented {
|
||||
match b {
|
||||
0x0D => {
|
||||
self.flush_text_run(events);
|
||||
self.push_visible(AnsiEvent::CarriageReturn, events);
|
||||
}
|
||||
0x08 => {
|
||||
self.flush_text_run(events);
|
||||
self.push_visible(AnsiEvent::Backspace, events);
|
||||
}
|
||||
0x07 | 0x09 | 0x0A | 0x0B | 0x0C | 0x20..=0x7E | 0x80..=0xFF => {
|
||||
self.push_text_byte(b);
|
||||
}
|
||||
0x00..=0x1F | 0x7F => {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
match b {
|
||||
0x07 => {
|
||||
self.flush_text_run(events);
|
||||
events.push(AnsiEvent::Bell);
|
||||
}
|
||||
// BS: flush text, emit Backspace.
|
||||
0x08 => {
|
||||
self.flush_text_run(events);
|
||||
if !self.alt_screen_active {
|
||||
events.push(AnsiEvent::Backspace);
|
||||
}
|
||||
events.push(AnsiEvent::Backspace);
|
||||
}
|
||||
// BEL (0x07), VT (0x0B), FF (0x0C), HT (0x09), LF
|
||||
// (0x0A): pass through to text alongside printable
|
||||
// ASCII (0x20..=0x7E). The REPL view treats LF as a
|
||||
// line break in the rope; HT as a literal tab. Other
|
||||
// C0 controls (0x00..=0x06, 0x0E..=0x1F) and DEL
|
||||
// (0x7F) are dropped silently.
|
||||
//
|
||||
// 0x80..=0xFF: UTF-8 lead or continuation byte. Goes
|
||||
// through `push_text_byte`'s stateful decoder so
|
||||
// multi-byte sequences across feeds are buffered until
|
||||
// complete.
|
||||
//
|
||||
// All text bytes route through `push_text_byte` (not
|
||||
// just non-ASCII): an ASCII byte arriving while a
|
||||
// partial UTF-8 sequence is pending invalidates that
|
||||
// sequence (the partial prefix's expected continuation
|
||||
// didn't arrive), and `push_text_byte` is the only
|
||||
// place that knows to flush the partial as `U+FFFD`.
|
||||
// The fast path inside `push_text_byte` keeps the
|
||||
// pure-ASCII case allocation-free.
|
||||
0x07 | 0x09 | 0x0A | 0x0B | 0x0C | 0x20..=0x7E | 0x80..=0xFF => {
|
||||
self.push_text_byte(b);
|
||||
0x09 => {
|
||||
self.flush_text_run(events);
|
||||
events.push(AnsiEvent::HorizontalTab);
|
||||
}
|
||||
0x0A..=0x0C => {
|
||||
self.flush_text_run(events);
|
||||
events.push(AnsiEvent::LineFeed);
|
||||
}
|
||||
0x0D => {
|
||||
self.flush_text_run(events);
|
||||
events.push(AnsiEvent::CarriageReturn);
|
||||
}
|
||||
0x0E => {
|
||||
self.flush_text_run(events);
|
||||
events.push(AnsiEvent::ShiftOut);
|
||||
}
|
||||
0x0F => {
|
||||
self.flush_text_run(events);
|
||||
events.push(AnsiEvent::ShiftIn);
|
||||
}
|
||||
0x20..=0x7E | 0x80..=0xFF => self.push_text_byte(b),
|
||||
0x00..=0x1F | 0x7F => {}
|
||||
}
|
||||
}
|
||||
|
|
@ -726,7 +908,7 @@ impl AnsiParser {
|
|||
// Escape
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
fn feed_escape(&mut self, b: u8) {
|
||||
fn feed_escape(&mut self, b: u8, events: &mut Vec<AnsiEvent>) {
|
||||
match b {
|
||||
0x20..=0x2F => {
|
||||
self.escape_intermediates.push(b);
|
||||
|
|
@ -740,42 +922,66 @@ impl AnsiParser {
|
|||
self.osc_body.clear();
|
||||
self.state = State::OscString;
|
||||
}
|
||||
// DCS / SOS / PM / APC introducers --- parse and discard.
|
||||
b'P' => {
|
||||
self.state = State::DcsEntry;
|
||||
}
|
||||
b'X' | b'^' | b'_' => {
|
||||
self.state = State::SosPmApcString;
|
||||
}
|
||||
// ESC \ in Escape state is a stray ST; final byte for
|
||||
// a bare ESC sequence (0x30..=0x7E) lands here too. We
|
||||
// don't dispatch any single-byte ESC commands in v0.1
|
||||
// (cursor save/restore `ESC 7`/`ESC 8` are deliberately
|
||||
// unsupported per spec); both cases consume and return
|
||||
// to Ground.
|
||||
b'\\' | 0x30..=0x7E => {
|
||||
b'P' => self.state = State::DcsEntry,
|
||||
b'X' | b'^' | b'_' => self.state = State::SosPmApcString,
|
||||
b'7' | b'8' | b'D' | b'E' | b'H' | b'M' | b'=' | b'>'
|
||||
if self.profile == AnsiParserProfile::FullScreen =>
|
||||
{
|
||||
let event = match b {
|
||||
b'7' => AnsiEvent::SaveCursor,
|
||||
b'8' => AnsiEvent::RestoreCursor,
|
||||
b'D' => AnsiEvent::Index,
|
||||
b'E' => AnsiEvent::NextLine,
|
||||
b'H' => AnsiEvent::SetTabStop,
|
||||
b'M' => AnsiEvent::ReverseIndex,
|
||||
b'=' => AnsiEvent::SetMode {
|
||||
mode: TerminalMode::ApplicationKeypad,
|
||||
enabled: true,
|
||||
},
|
||||
_ => AnsiEvent::SetMode {
|
||||
mode: TerminalMode::ApplicationKeypad,
|
||||
enabled: false,
|
||||
},
|
||||
};
|
||||
events.push(event);
|
||||
self.recover_to_ground();
|
||||
}
|
||||
// C0 controls inside Escape: drop, stay in Escape.
|
||||
b'\\' | 0x30..=0x7E => self.recover_to_ground(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn feed_escape_intermediate(&mut self, b: u8) {
|
||||
fn feed_escape_intermediate(&mut self, b: u8, events: &mut Vec<AnsiEvent>) {
|
||||
match b {
|
||||
0x20..=0x2F => {
|
||||
self.escape_intermediates.push(b);
|
||||
}
|
||||
// Final byte: drop the sequence (no ESC + intermediate
|
||||
// dispatches in v0.1 --- charsets are deliberately
|
||||
// unsupported per spec) and return to Ground.
|
||||
0x20..=0x2F => self.escape_intermediates.push(b),
|
||||
0x30..=0x7E => {
|
||||
if self.profile == AnsiParserProfile::FullScreen {
|
||||
let slot = match self.escape_intermediates.as_slice() {
|
||||
[b'('] => Some(CharacterSetSlot::G0),
|
||||
[b')'] => Some(CharacterSetSlot::G1),
|
||||
_ => None,
|
||||
};
|
||||
let charset = match b {
|
||||
b'0' => Some(CharacterSet::DecSpecialGraphics),
|
||||
b'B' => Some(CharacterSet::Ascii),
|
||||
_ => None,
|
||||
};
|
||||
if let (Some(slot), Some(charset)) = (slot, charset) {
|
||||
events.push(AnsiEvent::DesignateCharacterSet { slot, charset });
|
||||
}
|
||||
}
|
||||
self.recover_to_ground();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn feed_escape_ignore(&mut self, b: u8) {
|
||||
if matches!(b, 0x30..=0x7E) {
|
||||
self.recover_to_ground();
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// CSI
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
@ -850,70 +1056,129 @@ impl AnsiParser {
|
|||
/// Dispatch a fully-collected CSI sequence. `final_byte` is the
|
||||
/// terminating byte (`0x40..=0x7E`). The collected parameters
|
||||
/// are taken from `self.csi`.
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn dispatch_csi(&mut self, final_byte: u8, events: &mut Vec<AnsiEvent>) {
|
||||
let private_marker = self.csi.private_marker;
|
||||
let private = self.csi.private_marker;
|
||||
let intermediates = self.csi.intermediates.clone();
|
||||
let params = self.csi.finalize();
|
||||
|
||||
match (private_marker, final_byte) {
|
||||
// SGR.
|
||||
(None, b'm') => self.dispatch_sgr(¶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 ? <num> h` / `l`.
|
||||
// Of these, only ?1049 (alternate screen) produces an
|
||||
// event; mouse modes (?1000, ?1006), bracketed-paste
|
||||
// mode (?2004), and the long tail are parsed and
|
||||
// discarded per spec §sec:ansi-scope.
|
||||
(Some(b'?'), b'h' | b'l') => {
|
||||
let set = final_byte == b'h';
|
||||
for p in ¶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<u32> = std::str::from_utf8(num_part)
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok());
|
||||
if matches!(num, Some(133)) && !self.alt_screen_active {
|
||||
if matches!(num, Some(133)) && !self.suppress_visible() {
|
||||
match text_part.first().copied() {
|
||||
Some(b'A') => events.push(AnsiEvent::PromptStart),
|
||||
Some(b'B') => events.push(AnsiEvent::PromptEnd),
|
||||
|
|
@ -1099,13 +1368,66 @@ impl AnsiParser {
|
|||
// above produce events. Other OSC numbers are parsed and
|
||||
// discarded per spec §sec:ansi-scope, with the critical
|
||||
// guarantee that state alignment is preserved.
|
||||
if matches!(num, Some(0 | 2)) && !self.alt_screen_active {
|
||||
if matches!(num, Some(0 | 2)) && !self.suppress_visible() {
|
||||
let title = String::from_utf8_lossy(text_part).into_owned();
|
||||
events.push(AnsiEvent::SetTitle(title));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn param(params: &CsiParams, index: usize, default: u32) -> u32 {
|
||||
params
|
||||
.get(index)
|
||||
.map_or(default, |p| if p.main == 0 { default } else { p.main })
|
||||
}
|
||||
|
||||
fn erase_mode(value: u32) -> Option<EraseMode> {
|
||||
match value {
|
||||
0 => Some(EraseMode::ToEnd),
|
||||
1 => Some(EraseMode::ToStart),
|
||||
2 => Some(EraseMode::All),
|
||||
3 => Some(EraseMode::Saved),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn private_mode_event(value: u32, enabled: bool) -> Option<AnsiEvent> {
|
||||
let mode = match value {
|
||||
47 => {
|
||||
return Some(AnsiEvent::AlternateScreen {
|
||||
mode: AlternateScreenMode::Mode47,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
1047 => {
|
||||
return Some(AnsiEvent::AlternateScreen {
|
||||
mode: AlternateScreenMode::Mode1047,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
1049 => {
|
||||
return Some(AnsiEvent::AlternateScreen {
|
||||
mode: AlternateScreenMode::Mode1049,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
1 => TerminalMode::ApplicationCursor,
|
||||
6 => TerminalMode::Origin,
|
||||
7 => TerminalMode::AutoWrap,
|
||||
25 => TerminalMode::CursorVisible,
|
||||
66 => TerminalMode::ApplicationKeypad,
|
||||
1000 => TerminalMode::MouseX10,
|
||||
1002 => TerminalMode::MouseButton,
|
||||
1003 => TerminalMode::MouseAny,
|
||||
1004 => TerminalMode::FocusReporting,
|
||||
1006 => TerminalMode::MouseSgr,
|
||||
2004 => TerminalMode::BracketedPaste,
|
||||
2026 => TerminalMode::SynchronizedOutput,
|
||||
_ => return None,
|
||||
};
|
||||
Some(AnsiEvent::SetMode { mode, enabled })
|
||||
}
|
||||
|
||||
/// Parse a CSI 38/48 extended-color suffix into a `Color` plus
|
||||
/// the number of *additional* params consumed (legacy form only;
|
||||
/// the modern subparam form keeps everything inside `p.sub` so
|
||||
|
|
@ -1995,4 +2317,177 @@ mod tests {
|
|||
even though the internal style is already default"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_screen_emits_typed_operation_set_across_every_split() {
|
||||
let bytes = b"\x07\t\n\x1bD\x1bE\x1bM\x1bH\x1b[2A\x1b[3B\x1b[4C\x1b[5D\
|
||||
\x1b[2E\x1b[2F\x1b[7G\x1b[8d\x1b[2;3H\x1b[J\x1b[1K\x1b[2X\
|
||||
\x1b[3@\x1b[4P\x1b[2L\x1b[2M\x1b[3S\x1b[2T\x1b[2;20r\x1b[s\
|
||||
\x1b[u\x1b[3g\x1b[?1;6;7;25;1000;1002;1003;1004;1006;2004;2026h\
|
||||
\x1b[?47h\x1b[?1047h\x1b[?1049h\x1b[c\x1b[>c\x1b[5n\x1b[6n";
|
||||
let mut whole = AnsiParser::with_profile(AnsiParserProfile::FullScreen);
|
||||
let expected = whole.feed(bytes);
|
||||
assert!(expected.contains(&AnsiEvent::Bell));
|
||||
assert!(expected.contains(&AnsiEvent::Index));
|
||||
assert!(expected.contains(&AnsiEvent::NextLine));
|
||||
assert!(expected.contains(&AnsiEvent::ReverseIndex));
|
||||
assert!(expected.contains(&AnsiEvent::CursorPosition { row: 2, col: 3 }));
|
||||
assert!(expected.contains(&AnsiEvent::SetScrollingRegion {
|
||||
top: 2,
|
||||
bottom: Some(20)
|
||||
}));
|
||||
assert!(expected.contains(&AnsiEvent::SetMode {
|
||||
mode: TerminalMode::SynchronizedOutput,
|
||||
enabled: true
|
||||
}));
|
||||
assert!(expected.contains(&AnsiEvent::AlternateScreen {
|
||||
mode: AlternateScreenMode::Mode1049,
|
||||
enabled: true
|
||||
}));
|
||||
assert!(expected.contains(&AnsiEvent::DeviceRequest(DeviceRequest::CursorPosition)));
|
||||
for split in 0..=bytes.len() {
|
||||
let mut parser = AnsiParser::with_profile(AnsiParserProfile::FullScreen);
|
||||
let mut actual = parser.feed(&bytes[..split]);
|
||||
actual.extend(parser.feed(&bytes[split..]));
|
||||
assert_eq!(actual, expected, "split {split}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_screen_finish_flushes_without_synthetic_balancing() {
|
||||
let mut parser = AnsiParser::with_profile(AnsiParserProfile::FullScreen);
|
||||
let events = parser.feed(b"\x1b[?1049h\x1b[31mred");
|
||||
assert!(events.contains(&AnsiEvent::AlternateScreen {
|
||||
mode: AlternateScreenMode::Mode1049,
|
||||
enabled: true,
|
||||
}));
|
||||
assert!(
|
||||
events
|
||||
.iter()
|
||||
.any(|event| matches!(event, AnsiEvent::SetStyle(_)))
|
||||
);
|
||||
assert!(parser.finish().is_empty());
|
||||
assert!(!parser.alt_screen_active);
|
||||
assert!(parser.finish().is_empty());
|
||||
assert_eq!(parser.feed(b"x"), vec![AnsiEvent::Text("x".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_screen_charset_designation_and_shift_are_typed() {
|
||||
let mut parser = AnsiParser::with_profile(AnsiParserProfile::FullScreen);
|
||||
assert_eq!(
|
||||
parser.feed(b"\x1b(0\x1b)B\x0e\x0f"),
|
||||
vec![
|
||||
AnsiEvent::DesignateCharacterSet {
|
||||
slot: CharacterSetSlot::G0,
|
||||
charset: CharacterSet::DecSpecialGraphics,
|
||||
},
|
||||
AnsiEvent::DesignateCharacterSet {
|
||||
slot: CharacterSetSlot::G1,
|
||||
charset: CharacterSet::Ascii,
|
||||
},
|
||||
AnsiEvent::ShiftOut,
|
||||
AnsiEvent::ShiftIn,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_screen_capped_control_strings_recover_invisibly() {
|
||||
let config = AnsiParserConfig {
|
||||
unknown_sequence_byte_limit: 8,
|
||||
};
|
||||
let mut parser = AnsiParser::with_profile_and_config(AnsiParserProfile::FullScreen, config);
|
||||
let events = parser.feed(b"\x1b]52;AAAAAAAABsecret\x1b\\ok\x1bPAAAAAAAAAAAA\x1b\\done");
|
||||
let visible: String = events
|
||||
.iter()
|
||||
.filter_map(|event| match event {
|
||||
AnsiEvent::Text(text) => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(!visible.contains("secret"));
|
||||
assert!(!visible.contains("AAAA"));
|
||||
assert!(visible.ends_with("done"));
|
||||
assert!(!visible.contains('\x1b'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capped_csi_and_escape_intermediate_never_leak_payload() {
|
||||
let config = AnsiParserConfig {
|
||||
unknown_sequence_byte_limit: 8,
|
||||
};
|
||||
for input in [
|
||||
b"\x1b[12345678901234567890mOK".as_slice(),
|
||||
b"\x1b[?999999999999999999hOK".as_slice(),
|
||||
b"\x1b 0OK".as_slice(),
|
||||
] {
|
||||
let mut parser =
|
||||
AnsiParser::with_profile_and_config(AnsiParserProfile::FullScreen, config);
|
||||
let visible: String = parser
|
||||
.feed(input)
|
||||
.into_iter()
|
||||
.filter_map(|event| {
|
||||
if let AnsiEvent::Text(text) = event {
|
||||
Some(text)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(visible, "OK", "input {input:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_screen_unsupported_sgr_attributes_leave_style_unchanged() {
|
||||
let mut parser = AnsiParser::with_profile(AnsiParserProfile::FullScreen);
|
||||
parser.feed(b"\x1b[1;3;31m");
|
||||
let before = parser.current_style;
|
||||
parser.feed(b"\x1b[2;5;6;8;9;25;28;29m");
|
||||
assert_eq!(parser.current_style, before);
|
||||
assert!(before.bold);
|
||||
|
||||
let mut line = AnsiParser::new();
|
||||
line.feed(b"\x1b[5m");
|
||||
assert!(
|
||||
line.current_style.bold,
|
||||
"line-oriented blink-as-bold compatibility"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_osc_titles_survive_every_feed_split() {
|
||||
let bytes = "\u{1b}]2;héllo 世界\u{7}".as_bytes();
|
||||
let mut whole = AnsiParser::with_profile(AnsiParserProfile::FullScreen);
|
||||
let expected = whole.feed(bytes);
|
||||
assert_eq!(expected, vec![AnsiEvent::SetTitle("héllo 世界".into())]);
|
||||
for split in 0..=bytes.len() {
|
||||
let mut parser = AnsiParser::with_profile(AnsiParserProfile::FullScreen);
|
||||
let mut actual = parser.feed(&bytes[..split]);
|
||||
actual.extend(parser.feed(&bytes[split..]));
|
||||
assert_eq!(actual, expected, "split {split}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_utf8_osc_title_is_replaced_and_bounded() {
|
||||
let config = AnsiParserConfig {
|
||||
unknown_sequence_byte_limit: 32,
|
||||
};
|
||||
let mut parser = AnsiParser::with_profile_and_config(AnsiParserProfile::FullScreen, config);
|
||||
let events = parser.feed(b"\x1b]0;bad\xfftitle\x07");
|
||||
let title = events
|
||||
.into_iter()
|
||||
.find_map(|event| {
|
||||
if let AnsiEvent::SetTitle(title) = event {
|
||||
Some(title)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.expect("title event");
|
||||
assert_eq!(title, "bad\u{fffd}title");
|
||||
assert!(title.len() <= config.unknown_sequence_byte_limit);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
138
src/buffer.rs
138
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<Edit, BufferError> {
|
||||
self.ensure_writable()?;
|
||||
if self.editing_in_progress {
|
||||
return Err(BufferError::ConcurrentEdit {
|
||||
id: self.id,
|
||||
|
|
@ -732,6 +764,7 @@ impl Buffer {
|
|||
/// ops in a CRDT-redundant edge case).
|
||||
#[cfg(feature = "crdt")]
|
||||
pub fn apply_remote_crdt_op(&mut self, op_bytes: &[u8]) -> Result<Option<Edit>, BufferError> {
|
||||
self.ensure_writable()?;
|
||||
if self.editing_in_progress {
|
||||
return Err(BufferError::ConcurrentEdit {
|
||||
id: self.id,
|
||||
|
|
@ -942,6 +975,7 @@ impl Buffer {
|
|||
reason = "by-value mirrors apply_edit's signature; the Lua bindings build a fresh EditOp per call"
|
||||
)]
|
||||
pub fn apply_edit_skip_intercepts(&mut self, op: EditOp<'_>) -> Result<Edit, BufferError> {
|
||||
self.ensure_writable()?;
|
||||
let mut views = std::mem::take(&mut self.views);
|
||||
let result = self.run_rope_edit_and_broadcast(&mut views, &op);
|
||||
self.views = views;
|
||||
|
|
@ -1187,6 +1221,7 @@ impl Buffer {
|
|||
///
|
||||
/// Threading: main thread only.
|
||||
pub fn undo(&mut self) -> Result<Edit, BufferError> {
|
||||
self.ensure_writable()?;
|
||||
// T M10.4: in CRDT mode, route through loro's UndoManager via
|
||||
// the materialize-and-replace path (Day 1 morning audit
|
||||
// decision — path (a)). Inverse ops are produced as proper
|
||||
|
|
@ -1294,6 +1329,7 @@ impl Buffer {
|
|||
///
|
||||
/// Threading: main thread only.
|
||||
pub fn redo(&mut self) -> Result<Edit, BufferError> {
|
||||
self.ensure_writable()?;
|
||||
// T M10.4: in CRDT mode, route through loro's UndoManager.
|
||||
#[cfg(feature = "crdt")]
|
||||
if self.crdt.is_some() {
|
||||
|
|
@ -1675,6 +1711,15 @@ pub enum BufferError {
|
|||
/// The underlying rope rejected the operation.
|
||||
#[error("rope error: {0}")]
|
||||
Rope(#[from] RopeError),
|
||||
/// A content mutation was attempted on a buffer whose owner marked it
|
||||
/// read-only. The check runs before all rope, CRDT, and history changes.
|
||||
#[error("buffer `{name}` (id {id:?}) is read-only")]
|
||||
ReadOnly {
|
||||
/// The protected buffer.
|
||||
id: BufferId,
|
||||
/// Buffer name for user-facing diagnostics.
|
||||
name: String,
|
||||
},
|
||||
/// `undo` was called with an empty undo stack.
|
||||
#[error("nothing to undo")]
|
||||
NothingToUndo,
|
||||
|
|
@ -1824,6 +1869,99 @@ mod tests {
|
|||
out
|
||||
}
|
||||
|
||||
dual_mode_test!(
|
||||
read_only_rejects_direct_skip_history_mutations,
|
||||
|make, make_bytes| {
|
||||
let mut buf = make_bytes("*read-only*", b"abc");
|
||||
buf.apply_edit(EditOp::Insert {
|
||||
pos: 3,
|
||||
bytes: b"d",
|
||||
})
|
||||
.expect("seed undo history");
|
||||
buf.undo().expect("seed redo history");
|
||||
buf.set_read_only(true);
|
||||
|
||||
let before = (
|
||||
collect(&buf),
|
||||
buf.revision(),
|
||||
buf.is_modified(),
|
||||
buf.undo.len(),
|
||||
buf.redo.len(),
|
||||
);
|
||||
assert!(matches!(
|
||||
buf.begin_edit(),
|
||||
Err(BufferError::ReadOnly { .. })
|
||||
));
|
||||
assert!(!buf.editing_in_progress());
|
||||
let attempts = [
|
||||
buf.apply_edit(EditOp::Insert {
|
||||
pos: 0,
|
||||
bytes: b"x",
|
||||
}),
|
||||
buf.apply_edit_skip_intercepts(EditOp::Replace {
|
||||
range: Range::new(0, 1),
|
||||
bytes: b"y",
|
||||
}),
|
||||
buf.undo(),
|
||||
buf.redo(),
|
||||
];
|
||||
assert!(
|
||||
attempts
|
||||
.iter()
|
||||
.all(|result| matches!(result, Err(BufferError::ReadOnly { .. })))
|
||||
);
|
||||
assert_eq!(
|
||||
before,
|
||||
(
|
||||
collect(&buf),
|
||||
buf.revision(),
|
||||
buf.is_modified(),
|
||||
buf.undo.len(),
|
||||
buf.redo.len(),
|
||||
)
|
||||
);
|
||||
|
||||
// Keep the generated dual-mode factory used in both configurations.
|
||||
drop(make("*unused*"));
|
||||
}
|
||||
);
|
||||
|
||||
#[cfg(feature = "crdt")]
|
||||
#[test]
|
||||
fn read_only_rejects_remote_crdt_before_import_and_allows_empty_bootstrap() {
|
||||
let mut protected = Buffer::new(BufferId::next(), "*terminal*");
|
||||
protected.set_read_only(true);
|
||||
protected
|
||||
.upgrade_to_crdt(1)
|
||||
.expect("immutable empty CRDT bootstrap remains valid");
|
||||
let before_snapshot = protected
|
||||
.crdt_state()
|
||||
.expect("CRDT attached")
|
||||
.export_snapshot()
|
||||
.expect("snapshot");
|
||||
|
||||
let donor = crate::crdt::CrdtState::new(2).expect("donor");
|
||||
let version = donor.version();
|
||||
donor.insert(0, "forged").expect("donor edit");
|
||||
let op = donor.export_updates_since(&version).expect("remote op");
|
||||
|
||||
assert!(matches!(
|
||||
protected.apply_remote_crdt_op(&op),
|
||||
Err(BufferError::ReadOnly { .. })
|
||||
));
|
||||
assert!(protected.is_empty());
|
||||
assert_eq!(protected.revision(), 0);
|
||||
assert!(!protected.is_modified());
|
||||
assert_eq!(
|
||||
protected
|
||||
.crdt_state()
|
||||
.expect("CRDT attached")
|
||||
.export_snapshot()
|
||||
.expect("snapshot"),
|
||||
before_snapshot
|
||||
);
|
||||
}
|
||||
|
||||
// A view that records every callback for assertions.
|
||||
#[derive(Default)]
|
||||
struct RecorderView {
|
||||
|
|
|
|||
|
|
@ -64,6 +64,9 @@ pub struct EditorState {
|
|||
/// Drop-time `shutdown` enforces SIGTERM-then-SIGKILL so editor
|
||||
/// exit cannot leave zombies.
|
||||
pub process_supervisor: crate::lua_bindings::SharedProcessSupervisor,
|
||||
/// Terminal session registry. Shared with future terminal Lua bindings;
|
||||
/// snapshots are owned so no screen borrow crosses editor/Lua/render work.
|
||||
pub terminal_manager: crate::terminal::session::SharedTerminalManager,
|
||||
/// LSP manager (T M4.5). Holds one [`crate::lsp::LspClient`] per
|
||||
/// language server; rides on top of [`Self::process_supervisor`]
|
||||
/// for spawn / I/O / restart. Constructed empty; user code
|
||||
|
|
@ -133,6 +136,11 @@ impl Drop for EditorState {
|
|||
/// stuck mid-handoff stays alive (bounded by its job), which is
|
||||
/// still a ~15x improvement over leaking every pool whole.
|
||||
fn drop(&mut self) {
|
||||
{
|
||||
let mut supervisor = self.process_supervisor.borrow_mut();
|
||||
self.terminal_manager.borrow_mut().shutdown(&mut supervisor);
|
||||
supervisor.shutdown();
|
||||
}
|
||||
self.async_runtime.shutdown_workers();
|
||||
}
|
||||
}
|
||||
|
|
@ -239,6 +247,7 @@ impl EditorState {
|
|||
// shutdown enforces no-zombie cleanup at editor exit.
|
||||
let process_supervisor = crate::lua_bindings::make_process_supervisor(lua_host.lua())
|
||||
.expect("install pmacs.process");
|
||||
let terminal_manager = Rc::new(RefCell::new(crate::terminal::TerminalManager::new()));
|
||||
// T M4.5 LSP manager. Wires onto the same supervisor so its
|
||||
// spawn/restart/I/O machinery is shared with `pmacs.process.*`.
|
||||
// The manager itself is reachable from Lua as `pmacs.lsp.*`.
|
||||
|
|
@ -481,6 +490,7 @@ impl EditorState {
|
|||
async_runtime,
|
||||
syntax_registry,
|
||||
process_supervisor,
|
||||
terminal_manager,
|
||||
lsp_manager,
|
||||
font_pref,
|
||||
mcp_manager,
|
||||
|
|
@ -493,17 +503,35 @@ impl EditorState {
|
|||
}
|
||||
}
|
||||
|
||||
/// One pass of the process supervisor: drain pending I/O / exit
|
||||
/// events and apply restart policies. Mirrors
|
||||
/// [`Self::tick_async`]; the run loop calls both per iteration.
|
||||
/// Transactionally open an internal Stage-1 terminal session.
|
||||
///
|
||||
/// Fires the `process.after-tick` hook (T M6.5) after the supervisor
|
||||
/// tick releases its borrow. Lua subscribers typically own a
|
||||
/// `{[process_id] = handle}` registry and drain events via
|
||||
/// `pmacs.process.events_take(id)`; the REPL package
|
||||
/// (`builtin/packages/repl/init.lua`) is the first such consumer.
|
||||
/// No interactive Lua command is registered until a frontend can render
|
||||
/// terminal snapshots. This Rust seam is used by headless acceptance and
|
||||
/// future bindings.
|
||||
pub fn open_terminal(
|
||||
&mut self,
|
||||
spec: crate::terminal::TerminalSpec,
|
||||
) -> Result<crate::buffer::BufferId, crate::terminal::TerminalError> {
|
||||
let mut manager = self.terminal_manager.borrow_mut();
|
||||
let mut core = self.core.borrow_mut();
|
||||
let mut supervisor = self.process_supervisor.borrow_mut();
|
||||
manager.open(spec, &mut core, &mut supervisor)
|
||||
}
|
||||
|
||||
/// One pass of the process supervisor and terminal-owned event drain.
|
||||
///
|
||||
/// Ordering is supervisor tick → terminal drain/prune →
|
||||
/// `process.after-tick`. `TerminalManager` calls `take_events` only for its
|
||||
/// own `ProcessId`s; existing Lua/LSP/MCP ownership remains unchanged.
|
||||
pub fn tick_processes(&mut self) {
|
||||
self.process_supervisor.borrow_mut().tick();
|
||||
{
|
||||
let mut supervisor = self.process_supervisor.borrow_mut();
|
||||
supervisor.tick();
|
||||
let mut manager = self.terminal_manager.borrow_mut();
|
||||
manager.tick(&mut supervisor);
|
||||
let mut core = self.core.borrow_mut();
|
||||
manager.prune(&mut core, &mut supervisor);
|
||||
}
|
||||
self.lua_host
|
||||
.run_hook("process.after-tick", mlua::MultiValue::new());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -4912,6 +4912,10 @@ fn installed_package_to_lua(lua: &Lua, pkg: &InstalledPackage) -> mlua::Result<T
|
|||
/// `bracketed_paste_begin` / `bracketed_paste_end` /
|
||||
/// `alt_screen_enter` / `alt_screen_exit`: `{ kind=<name> }` only
|
||||
/// - `set_title`: `{ kind="set_title", title=<string> }`
|
||||
#[allow(
|
||||
clippy::too_many_lines,
|
||||
reason = "exhaustive wire-to-Lua conversion keeps every ANSI variant and field visible in one audited match"
|
||||
)]
|
||||
fn event_to_lua_table(lua: &Lua, ev: &crate::ansi::AnsiEvent) -> mlua::Result<Table> {
|
||||
use crate::ansi::AnsiEvent;
|
||||
let t = lua.create_table()?;
|
||||
|
|
@ -4964,6 +4968,151 @@ fn event_to_lua_table(lua: &Lua, ev: &crate::ansi::AnsiEvent) -> mlua::Result<Ta
|
|||
AnsiEvent::AlternateScreenExit => {
|
||||
t.set("kind", "alt_screen_exit")?;
|
||||
}
|
||||
AnsiEvent::Bell => t.set("kind", "bell")?,
|
||||
AnsiEvent::LineFeed => t.set("kind", "line_feed")?,
|
||||
AnsiEvent::Index => t.set("kind", "index")?,
|
||||
AnsiEvent::NextLine => t.set("kind", "next_line")?,
|
||||
AnsiEvent::ReverseIndex => t.set("kind", "reverse_index")?,
|
||||
AnsiEvent::HorizontalTab => t.set("kind", "horizontal_tab")?,
|
||||
AnsiEvent::SetTabStop => t.set("kind", "set_tab_stop")?,
|
||||
AnsiEvent::ClearTabStop => t.set("kind", "clear_tab_stop")?,
|
||||
AnsiEvent::ClearAllTabStops => t.set("kind", "clear_all_tab_stops")?,
|
||||
AnsiEvent::CursorUp(count)
|
||||
| AnsiEvent::CursorDown(count)
|
||||
| AnsiEvent::CursorForward(count)
|
||||
| AnsiEvent::CursorBackward(count)
|
||||
| AnsiEvent::CursorNextLine(count)
|
||||
| AnsiEvent::CursorPreviousLine(count)
|
||||
| AnsiEvent::EraseCharacters(count)
|
||||
| AnsiEvent::InsertCharacters(count)
|
||||
| AnsiEvent::DeleteCharacters(count)
|
||||
| AnsiEvent::InsertLines(count)
|
||||
| AnsiEvent::DeleteLines(count)
|
||||
| AnsiEvent::ScrollUp(count)
|
||||
| AnsiEvent::ScrollDown(count) => {
|
||||
let kind = match ev {
|
||||
AnsiEvent::CursorUp(_) => "cursor_up",
|
||||
AnsiEvent::CursorDown(_) => "cursor_down",
|
||||
AnsiEvent::CursorForward(_) => "cursor_forward",
|
||||
AnsiEvent::CursorBackward(_) => "cursor_backward",
|
||||
AnsiEvent::CursorNextLine(_) => "cursor_next_line",
|
||||
AnsiEvent::CursorPreviousLine(_) => "cursor_previous_line",
|
||||
AnsiEvent::EraseCharacters(_) => "erase_characters",
|
||||
AnsiEvent::InsertCharacters(_) => "insert_characters",
|
||||
AnsiEvent::DeleteCharacters(_) => "delete_characters",
|
||||
AnsiEvent::InsertLines(_) => "insert_lines",
|
||||
AnsiEvent::DeleteLines(_) => "delete_lines",
|
||||
AnsiEvent::ScrollUp(_) => "scroll_up",
|
||||
AnsiEvent::ScrollDown(_) => "scroll_down",
|
||||
_ => unreachable!("outer match restricts the event"),
|
||||
};
|
||||
t.set("kind", kind)?;
|
||||
t.set("count", *count)?;
|
||||
}
|
||||
AnsiEvent::CursorHorizontalAbsolute(col) => {
|
||||
t.set("kind", "cursor_horizontal_absolute")?;
|
||||
t.set("col", *col)?;
|
||||
}
|
||||
AnsiEvent::CursorVerticalAbsolute(row) => {
|
||||
t.set("kind", "cursor_vertical_absolute")?;
|
||||
t.set("row", *row)?;
|
||||
}
|
||||
AnsiEvent::CursorPosition { row, col } => {
|
||||
t.set("kind", "cursor_position")?;
|
||||
t.set("row", *row)?;
|
||||
t.set("col", *col)?;
|
||||
}
|
||||
AnsiEvent::EraseDisplay(mode) | AnsiEvent::EraseLineMode(mode) => {
|
||||
t.set(
|
||||
"kind",
|
||||
if matches!(ev, AnsiEvent::EraseDisplay(_)) {
|
||||
"erase_display"
|
||||
} else {
|
||||
"erase_line_mode"
|
||||
},
|
||||
)?;
|
||||
t.set(
|
||||
"mode",
|
||||
match mode {
|
||||
crate::ansi::EraseMode::ToEnd => "to_end",
|
||||
crate::ansi::EraseMode::ToStart => "to_start",
|
||||
crate::ansi::EraseMode::All => "all",
|
||||
crate::ansi::EraseMode::Saved => "saved",
|
||||
},
|
||||
)?;
|
||||
}
|
||||
AnsiEvent::SetScrollingRegion { top, bottom } => {
|
||||
t.set("kind", "set_scrolling_region")?;
|
||||
t.set("top", *top)?;
|
||||
t.set("bottom", *bottom)?;
|
||||
}
|
||||
AnsiEvent::SaveCursor => t.set("kind", "save_cursor")?,
|
||||
AnsiEvent::RestoreCursor => t.set("kind", "restore_cursor")?,
|
||||
AnsiEvent::AlternateScreen { mode, enabled } => {
|
||||
t.set("kind", "alternate_screen")?;
|
||||
t.set(
|
||||
"mode",
|
||||
match mode {
|
||||
crate::ansi::AlternateScreenMode::Mode47 => 47,
|
||||
crate::ansi::AlternateScreenMode::Mode1047 => 1047,
|
||||
crate::ansi::AlternateScreenMode::Mode1049 => 1049,
|
||||
},
|
||||
)?;
|
||||
t.set("enabled", *enabled)?;
|
||||
}
|
||||
AnsiEvent::SetMode { mode, enabled } => {
|
||||
t.set("kind", "set_mode")?;
|
||||
t.set(
|
||||
"mode",
|
||||
match mode {
|
||||
crate::ansi::TerminalMode::Insert => "insert",
|
||||
crate::ansi::TerminalMode::Origin => "origin",
|
||||
crate::ansi::TerminalMode::AutoWrap => "auto_wrap",
|
||||
crate::ansi::TerminalMode::ApplicationCursor => "application_cursor",
|
||||
crate::ansi::TerminalMode::ApplicationKeypad => "application_keypad",
|
||||
crate::ansi::TerminalMode::CursorVisible => "cursor_visible",
|
||||
crate::ansi::TerminalMode::BracketedPaste => "bracketed_paste",
|
||||
crate::ansi::TerminalMode::FocusReporting => "focus_reporting",
|
||||
crate::ansi::TerminalMode::SynchronizedOutput => "synchronized_output",
|
||||
crate::ansi::TerminalMode::MouseX10 => "mouse_x10",
|
||||
crate::ansi::TerminalMode::MouseButton => "mouse_button",
|
||||
crate::ansi::TerminalMode::MouseAny => "mouse_any",
|
||||
crate::ansi::TerminalMode::MouseSgr => "mouse_sgr",
|
||||
},
|
||||
)?;
|
||||
t.set("enabled", *enabled)?;
|
||||
}
|
||||
AnsiEvent::DesignateCharacterSet { slot, charset } => {
|
||||
t.set("kind", "designate_character_set")?;
|
||||
t.set(
|
||||
"slot",
|
||||
match slot {
|
||||
crate::ansi::CharacterSetSlot::G0 => "g0",
|
||||
crate::ansi::CharacterSetSlot::G1 => "g1",
|
||||
},
|
||||
)?;
|
||||
t.set(
|
||||
"charset",
|
||||
match charset {
|
||||
crate::ansi::CharacterSet::Ascii => "ascii",
|
||||
crate::ansi::CharacterSet::DecSpecialGraphics => "dec_special_graphics",
|
||||
},
|
||||
)?;
|
||||
}
|
||||
AnsiEvent::ShiftOut => t.set("kind", "shift_out")?,
|
||||
AnsiEvent::ShiftIn => t.set("kind", "shift_in")?,
|
||||
AnsiEvent::DeviceRequest(request) => {
|
||||
t.set("kind", "device_request")?;
|
||||
t.set(
|
||||
"request",
|
||||
match request {
|
||||
crate::ansi::DeviceRequest::PrimaryAttributes => "primary_attributes",
|
||||
crate::ansi::DeviceRequest::SecondaryAttributes => "secondary_attributes",
|
||||
crate::ansi::DeviceRequest::OperatingStatus => "operating_status",
|
||||
crate::ansi::DeviceRequest::CursorPosition => "cursor_position",
|
||||
},
|
||||
)?;
|
||||
}
|
||||
}
|
||||
Ok(t)
|
||||
}
|
||||
|
|
@ -7582,6 +7731,7 @@ fn lua_to_spec(table: &Table) -> mlua::Result<ProcessSpec> {
|
|||
mode,
|
||||
restart,
|
||||
ansi_events,
|
||||
ansi_profile: crate::ansi::AnsiParserProfile::LineOriented,
|
||||
stdin,
|
||||
group,
|
||||
})
|
||||
|
|
@ -7787,7 +7937,14 @@ pub fn install_process(lua: &Lua, supervisor: &SharedProcessSupervisor) -> mlua:
|
|||
"list",
|
||||
lua.create_function(move |lua, ()| {
|
||||
let sup = s.borrow();
|
||||
let ids: Vec<ProcessId> = sup.ids().collect();
|
||||
let ids: Vec<ProcessId> = sup
|
||||
.ids()
|
||||
.filter(|id| {
|
||||
sup.spec(*id).is_none_or(|spec| {
|
||||
spec.ansi_profile == crate::ansi::AnsiParserProfile::LineOriented
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let out = lua.create_table_with_capacity(ids.len(), 0)?;
|
||||
for (i, id) in ids.iter().enumerate() {
|
||||
let row = lua.create_table_with_capacity(0, 3)?;
|
||||
|
|
|
|||
183
src/process.rs
183
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::<u32>().is_ok())
|
||||
.map_or(desc, |(description, _)| description);
|
||||
match base {
|
||||
"Interrupt" => "SIGINT",
|
||||
"Terminated" => "SIGTERM",
|
||||
"Killed" => "SIGKILL",
|
||||
"Hangup" => "SIGHUP",
|
||||
"Quit" => "SIGQUIT",
|
||||
"User defined signal 1" => "SIGUSR1",
|
||||
"User defined signal 2" => "SIGUSR2",
|
||||
"Aborted" => "SIGABRT",
|
||||
"Segmentation fault" => "SIGSEGV",
|
||||
"Floating point exception" => "SIGFPE",
|
||||
"Illegal instruction" => "SIGILL",
|
||||
"Broken pipe" => "SIGPIPE",
|
||||
"Alarm clock" => "SIGALRM",
|
||||
"Bus error" => "SIGBUS",
|
||||
_ => desc,
|
||||
}
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
impl Default for ProcessSupervisor {
|
||||
|
|
@ -823,6 +834,23 @@ impl ProcessSupervisor {
|
|||
/// crashes *after* spawn shows up as a [`Termination::Crashed`]
|
||||
/// in the event stream, not as a return error.
|
||||
pub fn spawn(&mut self, spec: ProcessSpec) -> Result<ProcessId, String> {
|
||||
self.spawn_inner(spec, true)
|
||||
}
|
||||
|
||||
/// Spawn an unpublished terminal-owned process.
|
||||
///
|
||||
/// Unlike the public Lua/process path, synchronous failure does not emit an
|
||||
/// event for an ID no caller can own. `TerminalManager` rolls back its
|
||||
/// temporary identity buffer and returns the error directly.
|
||||
pub(crate) fn spawn_terminal(&mut self, spec: ProcessSpec) -> Result<ProcessId, String> {
|
||||
self.spawn_inner(spec, false)
|
||||
}
|
||||
|
||||
fn spawn_inner(
|
||||
&mut self,
|
||||
spec: ProcessSpec,
|
||||
publish_synchronous_failure: bool,
|
||||
) -> Result<ProcessId, String> {
|
||||
if self.shut_down {
|
||||
return Err("supervisor is shut down".to_owned());
|
||||
}
|
||||
|
|
@ -834,15 +862,20 @@ impl ProcessSupervisor {
|
|||
attempt_count: 0,
|
||||
next_restart_at: None,
|
||||
};
|
||||
self.start_generation(id, &mut managed)?;
|
||||
self.start_generation(id, &mut managed, publish_synchronous_failure)?;
|
||||
self.processes.insert(id, managed);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Start a fresh generation for `managed`. Mutates `managed`
|
||||
/// in place; on failure the state is left as
|
||||
/// `Terminated(Crashed{...})` and an event is emitted.
|
||||
fn start_generation(&self, id: ProcessId, managed: &mut ManagedProcess) -> Result<(), String> {
|
||||
/// Start a fresh generation for `managed`. Mutates `managed` in place; on
|
||||
/// failure its state is `Terminated(Crashed{...})`, and the event is emitted
|
||||
/// only when `publish_failure` is true.
|
||||
fn start_generation(
|
||||
&self,
|
||||
id: ProcessId,
|
||||
managed: &mut ManagedProcess,
|
||||
publish_failure: bool,
|
||||
) -> Result<(), String> {
|
||||
managed.attempt_count += 1;
|
||||
managed.next_restart_at = None;
|
||||
match build_runtime(&managed.spec, id) {
|
||||
|
|
@ -865,11 +898,13 @@ impl ProcessSupervisor {
|
|||
ended: now,
|
||||
});
|
||||
managed.runtime = None;
|
||||
let _ = self.events_tx.send(ProcessEvent {
|
||||
id,
|
||||
kind: ProcessEventKind::Crashed { error: e.clone() },
|
||||
at: now,
|
||||
});
|
||||
if publish_failure {
|
||||
let _ = self.events_tx.send(ProcessEvent {
|
||||
id,
|
||||
kind: ProcessEventKind::Crashed { error: e.clone() },
|
||||
at: now,
|
||||
});
|
||||
}
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
|
|
@ -1207,7 +1242,7 @@ impl ProcessSupervisor {
|
|||
kind: ProcessEventKind::Restarting { attempt },
|
||||
at: now,
|
||||
});
|
||||
let _ = self.start_generation(id, &mut managed);
|
||||
let _ = self.start_generation(id, &mut managed, true);
|
||||
self.processes.insert(id, managed);
|
||||
} else {
|
||||
// Schedule a restart attempt for `restart_backoff` from
|
||||
|
|
@ -1547,7 +1582,12 @@ fn build_pty_runtime(
|
|||
)];
|
||||
let output_rx = if spec.ansi_events {
|
||||
let (ansi_tx, ansi_rx) = channel::bounded::<AnsiBatch>(ANSI_EVENT_CHANNEL_CAP);
|
||||
readers.push(spawn_ansi_parser(byte_rx, ansi_tx, Arc::clone(&cancel)));
|
||||
readers.push(spawn_ansi_parser(
|
||||
byte_rx,
|
||||
ansi_tx,
|
||||
Arc::clone(&cancel),
|
||||
spec.ansi_profile,
|
||||
));
|
||||
RuntimeOutputRx::Ansi(ansi_rx)
|
||||
} else {
|
||||
RuntimeOutputRx::Bytes(byte_rx)
|
||||
|
|
@ -1954,9 +1994,10 @@ fn spawn_ansi_parser(
|
|||
byte_rx: Receiver<ByteChunk>,
|
||||
ansi_tx: Sender<AnsiBatch>,
|
||||
cancel: Arc<AtomicBool>,
|
||||
profile: AnsiParserProfile,
|
||||
) -> JoinHandle<()> {
|
||||
std::thread::spawn(move || {
|
||||
let mut parser = AnsiParser::new();
|
||||
let mut parser = AnsiParser::with_profile(profile);
|
||||
loop {
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
return;
|
||||
|
|
@ -1964,31 +2005,44 @@ fn spawn_ansi_parser(
|
|||
let (kind, bytes) = match byte_rx.recv_timeout(READER_SEND_POLL_INTERVAL) {
|
||||
Ok(chunk) => chunk,
|
||||
Err(crossbeam::channel::RecvTimeoutError::Timeout) => continue,
|
||||
Err(crossbeam::channel::RecvTimeoutError::Disconnected) => return,
|
||||
Err(crossbeam::channel::RecvTimeoutError::Disconnected) => {
|
||||
let events = parser.finish();
|
||||
if !events.is_empty() {
|
||||
let _ = send_ansi_batch(&ansi_tx, &cancel, events);
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
if !matches!(kind, ReaderKind::Stdout) {
|
||||
continue;
|
||||
}
|
||||
let mut events = parser.feed(&bytes);
|
||||
if events.is_empty() {
|
||||
continue;
|
||||
}
|
||||
loop {
|
||||
match ansi_tx.send_timeout(events, READER_SEND_POLL_INTERVAL) {
|
||||
Ok(()) => break,
|
||||
Err(crossbeam::channel::SendTimeoutError::Timeout(rejected)) => {
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
events = rejected;
|
||||
}
|
||||
Err(crossbeam::channel::SendTimeoutError::Disconnected(_)) => return,
|
||||
}
|
||||
let events = parser.feed(&bytes);
|
||||
if !events.is_empty() && !send_ansi_batch(&ansi_tx, &cancel, events) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn send_ansi_batch(
|
||||
ansi_tx: &Sender<AnsiBatch>,
|
||||
cancel: &AtomicBool,
|
||||
mut events: AnsiBatch,
|
||||
) -> bool {
|
||||
loop {
|
||||
match ansi_tx.send_timeout(events, READER_SEND_POLL_INTERVAL) {
|
||||
Ok(()) => return true,
|
||||
Err(crossbeam::channel::SendTimeoutError::Timeout(rejected)) => {
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
return false;
|
||||
}
|
||||
events = rejected;
|
||||
}
|
||||
Err(crossbeam::channel::SendTimeoutError::Disconnected(_)) => return false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -2026,6 +2080,30 @@ mod tests {
|
|||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pty_signal_names_are_canonical_across_libc_variants() {
|
||||
assert_eq!(canonicalize_pty_signal_name("Terminated"), "SIGTERM");
|
||||
assert_eq!(canonicalize_pty_signal_name("Terminated: 15"), "SIGTERM");
|
||||
assert_eq!(canonicalize_pty_signal_name("Killed: 9"), "SIGKILL");
|
||||
assert_eq!(
|
||||
canonicalize_pty_signal_name("Unknown signal: 99"),
|
||||
"Unknown signal: 99"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_transactional_spawn_failure_has_no_event_or_process_residue() {
|
||||
let mut supervisor = ProcessSupervisor::new();
|
||||
let spec = ProcessSpec::new(
|
||||
"unpublished-terminal",
|
||||
"/definitely/not/a/real/pmacs-terminal-program",
|
||||
);
|
||||
assert!(supervisor.spawn_terminal(spec).is_err());
|
||||
supervisor.tick();
|
||||
assert_eq!(supervisor.ids().count(), 0);
|
||||
assert!(supervisor.take_all_events().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_pipes_lifecycle_started_then_exited() {
|
||||
let mut sup = ProcessSupervisor::new();
|
||||
|
|
@ -2618,7 +2696,12 @@ mod tests {
|
|||
let (byte_tx, byte_rx) = channel::bounded::<ByteChunk>(1);
|
||||
let (ansi_tx, _ansi_rx) = channel::bounded::<AnsiBatch>(1);
|
||||
let cancel = Arc::new(AtomicBool::new(false));
|
||||
let handle = spawn_ansi_parser(byte_rx, ansi_tx, Arc::clone(&cancel));
|
||||
let handle = spawn_ansi_parser(
|
||||
byte_rx,
|
||||
ansi_tx,
|
||||
Arc::clone(&cancel),
|
||||
AnsiParserProfile::LineOriented,
|
||||
);
|
||||
drop(byte_tx);
|
||||
|
||||
let deadline = Instant::now() + Duration::from_millis(500);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,366 @@
|
|||
use crate::cell::CellCoord;
|
||||
use crate::protocol::{Key, Modifiers, MouseButton, MouseKind};
|
||||
|
||||
use super::screen::{MouseTrackingMode, TerminalModes};
|
||||
|
||||
/// Encode one normalized key press for the child terminal.
|
||||
///
|
||||
/// Lock/media/unknown keys return `None`. Application-keypad mode is
|
||||
/// intentionally not applied to `Key::Char` digits because the normalized
|
||||
/// protocol cannot distinguish number-row and keypad input.
|
||||
#[must_use]
|
||||
pub fn encode_key(key: Key, mods: Modifiers, modes: TerminalModes) -> Option<Vec<u8>> {
|
||||
if mods.contains(Modifiers::META) || mods.contains(Modifiers::HYPER) {
|
||||
return None;
|
||||
}
|
||||
let alt = mods.contains(Modifiers::ALT);
|
||||
let ctrl = mods.contains(Modifiers::CTRL);
|
||||
let mut out = match key {
|
||||
Key::Char(ch) => {
|
||||
let mut bytes = Vec::with_capacity(4);
|
||||
if ctrl {
|
||||
bytes.push(control_byte(ch)?);
|
||||
} else {
|
||||
let mut encoded = [0; 4];
|
||||
bytes.extend_from_slice(ch.encode_utf8(&mut encoded).as_bytes());
|
||||
}
|
||||
bytes
|
||||
}
|
||||
Key::Enter => vec![b'\r'],
|
||||
Key::Tab => vec![b'\t'],
|
||||
Key::Backspace => vec![0x7f],
|
||||
Key::Escape => vec![0x1b],
|
||||
Key::BackTab if mods == Modifiers::NONE || mods == Modifiers::SHIFT => b"\x1b[Z".to_vec(),
|
||||
Key::BackTab => modified_csi(b'Z', mods, None),
|
||||
Key::Up => navigation(b'A', mods, modes.application_cursor),
|
||||
Key::Down => navigation(b'B', mods, modes.application_cursor),
|
||||
Key::Right => navigation(b'C', mods, modes.application_cursor),
|
||||
Key::Left => navigation(b'D', mods, modes.application_cursor),
|
||||
Key::Home => navigation(b'H', mods, modes.application_cursor),
|
||||
Key::End => navigation(b'F', mods, modes.application_cursor),
|
||||
Key::Insert => tilde_key(2, mods),
|
||||
Key::Delete => tilde_key(3, mods),
|
||||
Key::PageUp => tilde_key(5, mods),
|
||||
Key::PageDown => tilde_key(6, mods),
|
||||
Key::F(n @ 1..=4) => function_1_to_4(n, mods),
|
||||
Key::F(n @ 5..=12) => {
|
||||
let code = [15, 17, 18, 19, 20, 21, 23, 24][usize::from(n - 5)];
|
||||
tilde_key(code, mods)
|
||||
}
|
||||
Key::Null if ctrl => vec![0],
|
||||
Key::F(_)
|
||||
| Key::CapsLock
|
||||
| Key::ScrollLock
|
||||
| Key::NumLock
|
||||
| Key::PrintScreen
|
||||
| Key::Pause
|
||||
| Key::Menu
|
||||
| Key::KeypadBegin
|
||||
| Key::Null
|
||||
| Key::Unknown(_) => return None,
|
||||
};
|
||||
// Character/control/basic keys use the traditional ESC prefix for Alt.
|
||||
// Named CSI keys encode Alt in their xterm modifier parameter already.
|
||||
if alt
|
||||
&& matches!(
|
||||
key,
|
||||
Key::Char(_) | Key::Enter | Key::Tab | Key::Backspace | Key::Escape
|
||||
)
|
||||
{
|
||||
out.insert(0, 0x1b);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Encode pasted bytes, optionally framing them with bracketed-paste markers.
|
||||
#[must_use]
|
||||
pub fn encode_paste(bytes: &[u8], bracketed_paste: bool) -> Vec<u8> {
|
||||
if !bracketed_paste {
|
||||
return bytes.to_vec();
|
||||
}
|
||||
let mut out = Vec::with_capacity(bytes.len() + 12);
|
||||
out.extend_from_slice(b"\x1b[200~");
|
||||
out.extend_from_slice(bytes);
|
||||
out.extend_from_slice(b"\x1b[201~");
|
||||
out
|
||||
}
|
||||
|
||||
/// Encode a focus transition when focus reporting is enabled.
|
||||
#[must_use]
|
||||
pub fn encode_focus(focused: bool, focus_reporting: bool) -> Option<Vec<u8>> {
|
||||
focus_reporting.then(|| {
|
||||
if focused {
|
||||
b"\x1b[I".to_vec()
|
||||
} else {
|
||||
b"\x1b[O".to_vec()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Encode an xterm SGR mouse report using zero-based terminal coordinates.
|
||||
#[must_use]
|
||||
pub fn encode_mouse(
|
||||
kind: MouseKind,
|
||||
coord: CellCoord,
|
||||
mods: Modifiers,
|
||||
modes: TerminalModes,
|
||||
) -> Option<Vec<u8>> {
|
||||
if mods.contains(Modifiers::META) || mods.contains(Modifiers::HYPER) {
|
||||
return None;
|
||||
}
|
||||
if !modes.mouse_sgr || modes.mouse_tracking == MouseTrackingMode::Off {
|
||||
return None;
|
||||
}
|
||||
let allowed = match modes.mouse_tracking {
|
||||
MouseTrackingMode::Off => false,
|
||||
MouseTrackingMode::X10 => matches!(kind, MouseKind::Down(_)),
|
||||
MouseTrackingMode::Button => !matches!(kind, MouseKind::Move),
|
||||
MouseTrackingMode::Any => true,
|
||||
};
|
||||
if !allowed {
|
||||
return None;
|
||||
}
|
||||
let (mut code, release) = match kind {
|
||||
MouseKind::Down(button) => (button_code(button), false),
|
||||
MouseKind::Up(button) => (button_code(button), true),
|
||||
MouseKind::Drag(button) => (button_code(button) + 32, false),
|
||||
MouseKind::Move => (35, false),
|
||||
MouseKind::ScrollUp => (64, false),
|
||||
MouseKind::ScrollDown => (65, false),
|
||||
MouseKind::ScrollLeft => (66, false),
|
||||
MouseKind::ScrollRight => (67, false),
|
||||
};
|
||||
if mods.contains(Modifiers::SHIFT) {
|
||||
code += 4;
|
||||
}
|
||||
if mods.contains(Modifiers::ALT) {
|
||||
code += 8;
|
||||
}
|
||||
if mods.contains(Modifiers::CTRL) {
|
||||
code += 16;
|
||||
}
|
||||
let final_byte = if release { 'm' } else { 'M' };
|
||||
Some(
|
||||
format!(
|
||||
"\x1b[<{code};{};{}{final_byte}",
|
||||
coord.col.saturating_add(1),
|
||||
coord.row.saturating_add(1)
|
||||
)
|
||||
.into_bytes(),
|
||||
)
|
||||
}
|
||||
|
||||
fn control_byte(ch: char) -> Option<u8> {
|
||||
match ch {
|
||||
'@' | ' ' | '`' => Some(0),
|
||||
'a'..='z' => Some(ch as u8 - b'a' + 1),
|
||||
'A'..='Z' => Some(ch as u8 - b'A' + 1),
|
||||
'[' | '{' => Some(0x1b),
|
||||
'\\' | '|' => Some(0x1c),
|
||||
']' | '}' => Some(0x1d),
|
||||
'^' | '~' => Some(0x1e),
|
||||
'_' => Some(0x1f),
|
||||
'?' => Some(0x7f),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn navigation(final_byte: u8, mods: Modifiers, application: bool) -> Vec<u8> {
|
||||
let parameter = modifier_parameter(mods);
|
||||
if parameter == 1 {
|
||||
vec![0x1b, if application { b'O' } else { b'[' }, final_byte]
|
||||
} else {
|
||||
modified_csi(final_byte, mods, None)
|
||||
}
|
||||
}
|
||||
|
||||
fn function_1_to_4(n: u8, mods: Modifiers) -> Vec<u8> {
|
||||
let final_byte = b'P' + n - 1;
|
||||
if modifier_parameter(mods) == 1 {
|
||||
vec![0x1b, b'O', final_byte]
|
||||
} else {
|
||||
modified_csi(final_byte, mods, None)
|
||||
}
|
||||
}
|
||||
|
||||
fn tilde_key(code: u8, mods: Modifiers) -> Vec<u8> {
|
||||
let modifier = modifier_parameter(mods);
|
||||
if modifier == 1 {
|
||||
format!("\x1b[{code}~").into_bytes()
|
||||
} else {
|
||||
format!("\x1b[{code};{modifier}~").into_bytes()
|
||||
}
|
||||
}
|
||||
|
||||
fn modified_csi(final_byte: u8, mods: Modifiers, first: Option<u8>) -> Vec<u8> {
|
||||
let modifier = modifier_parameter(mods);
|
||||
if modifier == 1 && first.is_none() {
|
||||
return vec![0x1b, b'[', final_byte];
|
||||
}
|
||||
let first = first.unwrap_or(1);
|
||||
format!("\x1b[{first};{modifier}{}", final_byte as char).into_bytes()
|
||||
}
|
||||
|
||||
fn modifier_parameter(mods: Modifiers) -> u8 {
|
||||
1 + u8::from(mods.contains(Modifiers::SHIFT))
|
||||
+ 2 * u8::from(mods.contains(Modifiers::ALT))
|
||||
+ 4 * u8::from(mods.contains(Modifiers::CTRL))
|
||||
}
|
||||
|
||||
fn button_code(button: MouseButton) -> u8 {
|
||||
match button {
|
||||
MouseButton::Left => 0,
|
||||
MouseButton::Middle => 1,
|
||||
MouseButton::Right => 2,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn modes() -> TerminalModes {
|
||||
TerminalModes::default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf8_ctrl_and_alt_boundaries() {
|
||||
assert_eq!(
|
||||
encode_key(Key::Char('é'), Modifiers::NONE, modes()),
|
||||
Some("é".as_bytes().to_vec())
|
||||
);
|
||||
assert_eq!(
|
||||
encode_key(Key::Char('c'), Modifiers::CTRL, modes()),
|
||||
Some(vec![3])
|
||||
);
|
||||
assert_eq!(
|
||||
encode_key(Key::Char('?'), Modifiers::CTRL | Modifiers::ALT, modes()),
|
||||
Some(vec![0x1b, 0x7f])
|
||||
);
|
||||
assert_eq!(encode_key(Key::Char('é'), Modifiers::CTRL, modes()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn application_cursor_and_xterm_modifiers() {
|
||||
let mut app = modes();
|
||||
app.application_cursor = true;
|
||||
assert_eq!(
|
||||
encode_key(Key::Up, Modifiers::NONE, app),
|
||||
Some(b"\x1bOA".to_vec())
|
||||
);
|
||||
assert_eq!(
|
||||
encode_key(Key::Up, Modifiers::CTRL | Modifiers::SHIFT, app),
|
||||
Some(b"\x1b[1;6A".to_vec())
|
||||
);
|
||||
assert_eq!(
|
||||
encode_key(Key::Delete, Modifiers::ALT, modes()),
|
||||
Some(b"\x1b[3;3~".to_vec())
|
||||
);
|
||||
assert_eq!(
|
||||
encode_key(Key::F(1), Modifiers::NONE, modes()),
|
||||
Some(b"\x1bOP".to_vec())
|
||||
);
|
||||
assert_eq!(
|
||||
encode_key(Key::F(12), Modifiers::CTRL, modes()),
|
||||
Some(b"\x1b[24;5~".to_vec())
|
||||
);
|
||||
assert_eq!(
|
||||
encode_key(Key::BackTab, Modifiers::SHIFT, modes()),
|
||||
Some(b"\x1b[Z".to_vec())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ambiguous_digits_ignore_application_keypad() {
|
||||
let mut app = modes();
|
||||
app.application_keypad = true;
|
||||
assert_eq!(
|
||||
encode_key(Key::Char('7'), Modifiers::NONE, app),
|
||||
Some(b"7".to_vec())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_and_focus_are_exact() {
|
||||
assert_eq!(encode_paste(b"a\0b", false), b"a\0b".to_vec());
|
||||
assert_eq!(
|
||||
encode_paste(b"a\0b", true),
|
||||
b"\x1b[200~a\0b\x1b[201~".to_vec()
|
||||
);
|
||||
assert_eq!(encode_focus(true, true), Some(b"\x1b[I".to_vec()));
|
||||
assert_eq!(encode_focus(false, true), Some(b"\x1b[O".to_vec()));
|
||||
assert_eq!(encode_focus(true, false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sgr_mouse_modes_modifiers_and_coordinates() {
|
||||
let mut m = modes();
|
||||
m.mouse_sgr = true;
|
||||
m.mouse_tracking = MouseTrackingMode::Any;
|
||||
assert_eq!(
|
||||
encode_mouse(
|
||||
MouseKind::Down(MouseButton::Left),
|
||||
CellCoord::new(0, 0),
|
||||
Modifiers::NONE,
|
||||
m
|
||||
),
|
||||
Some(b"\x1b[<0;1;1M".to_vec())
|
||||
);
|
||||
assert_eq!(
|
||||
encode_mouse(
|
||||
MouseKind::Drag(MouseButton::Right),
|
||||
CellCoord::new(511, 511),
|
||||
Modifiers::CTRL | Modifiers::ALT,
|
||||
m
|
||||
),
|
||||
Some(b"\x1b[<58;512;512M".to_vec())
|
||||
);
|
||||
assert_eq!(
|
||||
encode_mouse(
|
||||
MouseKind::Up(MouseButton::Right),
|
||||
CellCoord::new(4, 9),
|
||||
Modifiers::NONE,
|
||||
m
|
||||
),
|
||||
Some(b"\x1b[<2;10;5m".to_vec())
|
||||
);
|
||||
assert_eq!(
|
||||
encode_mouse(
|
||||
MouseKind::ScrollDown,
|
||||
CellCoord::new(1, 2),
|
||||
Modifiers::SHIFT,
|
||||
m
|
||||
),
|
||||
Some(b"\x1b[<69;3;2M".to_vec())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_keys_are_invisible() {
|
||||
assert_eq!(encode_key(Key::Unknown(7), Modifiers::NONE, modes()), None);
|
||||
assert_eq!(encode_key(Key::F(13), Modifiers::NONE, modes()), None);
|
||||
assert_eq!(encode_key(Key::Char('c'), Modifiers::META, modes()), None);
|
||||
assert_eq!(encode_key(Key::Up, Modifiers::HYPER, modes()), None);
|
||||
let mut mouse_modes = modes();
|
||||
mouse_modes.mouse_sgr = true;
|
||||
mouse_modes.mouse_tracking = MouseTrackingMode::Any;
|
||||
assert_eq!(
|
||||
encode_mouse(
|
||||
MouseKind::Down(MouseButton::Left),
|
||||
CellCoord::new(0, 0),
|
||||
Modifiers::META,
|
||||
mouse_modes,
|
||||
),
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
encode_mouse(
|
||||
MouseKind::Move,
|
||||
CellCoord::new(4, 9),
|
||||
Modifiers::HYPER,
|
||||
mouse_modes,
|
||||
),
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,606 @@
|
|||
//! Terminal process/session registry.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::rc::Rc;
|
||||
use std::time::Instant;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::ansi::AnsiParserProfile;
|
||||
use crate::buffer::{Buffer, BufferId};
|
||||
use crate::cell::{Cell, CellCoord, CellSize};
|
||||
use crate::editor_core::EditorCore;
|
||||
use crate::process::{
|
||||
ProcessEventKind, ProcessId, ProcessMode, ProcessSpec, ProcessState, ProcessSupervisor,
|
||||
RestartPolicy, StdinMode, TerminalMode,
|
||||
};
|
||||
use crate::terminal::screen::TerminalScreen;
|
||||
use crate::terminal::{
|
||||
MAX_TERMINAL_COLS, MAX_TERMINAL_HISTORY_CELLS, MAX_TERMINAL_METADATA_BYTES, MAX_TERMINAL_ROWS,
|
||||
MAX_TERMINAL_VISIBLE_CELLS,
|
||||
};
|
||||
|
||||
/// Shared single-owner terminal registry used by editor and future Lua bindings.
|
||||
pub type SharedTerminalManager = Rc<RefCell<TerminalManager>>;
|
||||
|
||||
/// Complete owned description of a terminal child and its initial screen.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct TerminalSpec {
|
||||
/// Executable path or name resolved through `PATH`.
|
||||
pub command: String,
|
||||
/// Child arguments, excluding argv[0].
|
||||
pub args: Vec<String>,
|
||||
/// Working directory, or the editor process directory when absent.
|
||||
pub cwd: Option<PathBuf>,
|
||||
/// Environment overrides inherited by the child. `TERM` defaults to
|
||||
/// `xterm-256color` when the caller does not provide it.
|
||||
pub env: Vec<(String, String)>,
|
||||
/// Identity-buffer name. Defaults to `*terminal:<command>*`.
|
||||
pub name: Option<String>,
|
||||
/// Initial terminal rows.
|
||||
pub rows: u16,
|
||||
/// Initial terminal columns.
|
||||
pub cols: u16,
|
||||
/// Retained main-screen scrollback row cap.
|
||||
pub scrollback_rows: usize,
|
||||
}
|
||||
|
||||
impl TerminalSpec {
|
||||
/// Construct a conventional 24x80 terminal specification.
|
||||
#[must_use]
|
||||
pub fn new(command: impl Into<String>) -> Self {
|
||||
Self {
|
||||
command: command.into(),
|
||||
args: Vec::new(),
|
||||
cwd: None,
|
||||
env: Vec::new(),
|
||||
name: None,
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
scrollback_rows: crate::terminal::DEFAULT_TERMINAL_SCROLLBACK_ROWS,
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate every raw field before any buffer or process is created.
|
||||
pub fn validate(&self) -> Result<(), TerminalError> {
|
||||
if self.command.is_empty() {
|
||||
return Err(TerminalError::InvalidSpec(
|
||||
"command must not be empty".into(),
|
||||
));
|
||||
}
|
||||
reject_nul("command", self.command.as_bytes())?;
|
||||
for arg in &self.args {
|
||||
reject_nul("argument", arg.as_bytes())?;
|
||||
}
|
||||
if let Some(cwd) = &self.cwd {
|
||||
if cwd.as_os_str().is_empty() {
|
||||
return Err(TerminalError::InvalidSpec(
|
||||
"cwd must not be an empty path".into(),
|
||||
));
|
||||
}
|
||||
reject_nul("cwd", cwd.as_os_str().as_encoded_bytes())?;
|
||||
}
|
||||
let mut env_names = HashSet::with_capacity(self.env.len());
|
||||
for (name, value) in &self.env {
|
||||
if name.is_empty() || name.contains('=') {
|
||||
return Err(TerminalError::InvalidSpec(format!(
|
||||
"environment name {name:?} must be non-empty and contain no '='"
|
||||
)));
|
||||
}
|
||||
reject_nul("environment name", name.as_bytes())?;
|
||||
reject_nul("environment value", value.as_bytes())?;
|
||||
if !env_names.insert(name) {
|
||||
return Err(TerminalError::InvalidSpec(format!(
|
||||
"duplicate environment name {name:?}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
if let Some(name) = &self.name {
|
||||
if name.is_empty() {
|
||||
return Err(TerminalError::InvalidSpec(
|
||||
"buffer name must not be empty".into(),
|
||||
));
|
||||
}
|
||||
reject_nul("buffer name", name.as_bytes())?;
|
||||
if name.contains(['\r', '\n']) {
|
||||
return Err(TerminalError::InvalidSpec(
|
||||
"buffer name must fit on one line".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
validate_size(self.rows, self.cols)?;
|
||||
if self.scrollback_rows > MAX_TERMINAL_HISTORY_CELLS {
|
||||
return Err(TerminalError::InvalidSpec(format!(
|
||||
"scrollback row cap {} exceeds terminal history cell budget {}",
|
||||
self.scrollback_rows, MAX_TERMINAL_HISTORY_CELLS
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn buffer_name(&self) -> String {
|
||||
self.name.clone().unwrap_or_else(|| {
|
||||
let command = Path::new(&self.command)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.filter(|name| !name.is_empty())
|
||||
.unwrap_or(self.command.as_str());
|
||||
format!("*terminal:{command}*")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Process outcome published with an owned terminal snapshot.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum TerminalProcessState {
|
||||
/// Child is running or termination has only been requested.
|
||||
Running,
|
||||
/// Child exited with a status code.
|
||||
Exited(i32),
|
||||
/// Child was terminated by a sanitized symbolic signal.
|
||||
Signaled(String),
|
||||
/// Supervision failed after the session was published.
|
||||
Crashed(String),
|
||||
}
|
||||
|
||||
/// One selected terminal-row span. Stage 1 snapshots leave selection empty.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct TerminalSelectionSpan {
|
||||
/// Visible row.
|
||||
pub row: u32,
|
||||
/// Inclusive starting column.
|
||||
pub start_col: u32,
|
||||
/// Exclusive ending column.
|
||||
pub end_col: u32,
|
||||
}
|
||||
|
||||
/// Owned, renderer-safe terminal state captured after a manager tick.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TerminalSnapshot {
|
||||
/// Identity buffer backing this terminal.
|
||||
pub buffer_id: BufferId,
|
||||
/// Visible grid dimensions.
|
||||
pub size: CellSize,
|
||||
/// Row-major visible cells.
|
||||
pub cells: Vec<Cell>,
|
||||
/// Visible child cursor, if enabled.
|
||||
pub cursor: Option<CellCoord>,
|
||||
/// Sanitized child title.
|
||||
pub title: Option<String>,
|
||||
/// Published screen generation.
|
||||
pub screen_generation: u64,
|
||||
/// Context selection. Empty in context-free Stage 1 snapshots.
|
||||
pub selection: Vec<TerminalSelectionSpan>,
|
||||
/// Context scrollback offset. Zero in Stage 1 snapshots.
|
||||
pub scroll_offset: u32,
|
||||
/// Whether this context follows the bottom. Always true in Stage 1.
|
||||
pub at_bottom: bool,
|
||||
/// Exact operating-system process id for this session generation.
|
||||
pub pid: u32,
|
||||
/// Latest observed process state.
|
||||
pub process: TerminalProcessState,
|
||||
}
|
||||
|
||||
/// Terminal session/registry failures.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum TerminalError {
|
||||
/// Specification validation failed before creation began.
|
||||
#[error("invalid terminal specification: {0}")]
|
||||
InvalidSpec(String),
|
||||
/// The synchronous PTY spawn failed; no session is published.
|
||||
#[error("terminal spawn failed: {0}")]
|
||||
Spawn(String),
|
||||
/// Buffer registry work failed during transactional creation.
|
||||
#[error("terminal buffer operation failed: {0}")]
|
||||
Buffer(String),
|
||||
/// Screen construction or resize failed.
|
||||
#[error("terminal screen operation failed: {0}")]
|
||||
Screen(String),
|
||||
/// No session owns the requested identity buffer.
|
||||
#[error("buffer {0:?} is not a terminal")]
|
||||
NotTerminal(BufferId),
|
||||
/// Process I/O, resize, signal, or cleanup failed.
|
||||
#[error("terminal process operation failed: {0}")]
|
||||
Process(String),
|
||||
}
|
||||
|
||||
struct TerminalSession {
|
||||
process_id: ProcessId,
|
||||
pid: u32,
|
||||
screen: TerminalScreen,
|
||||
process: TerminalProcessState,
|
||||
annotated: bool,
|
||||
}
|
||||
|
||||
/// Owns the one-buffer/one-process/one-screen terminal registry.
|
||||
#[derive(Default)]
|
||||
pub struct TerminalManager {
|
||||
sessions: HashMap<BufferId, TerminalSession>,
|
||||
process_to_buffer: HashMap<ProcessId, BufferId>,
|
||||
/// Removed buffers whose children are still being reaped. Their events
|
||||
/// remain manager-owned so Lua/LSP/MCP consumers cannot steal a batch.
|
||||
closing: HashSet<ProcessId>,
|
||||
}
|
||||
|
||||
impl TerminalManager {
|
||||
/// Construct an empty manager.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Number of published terminal sessions.
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.sessions.len()
|
||||
}
|
||||
|
||||
/// Whether no terminal session is currently published.
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.sessions.is_empty()
|
||||
}
|
||||
|
||||
/// Transactionally create an internal terminal identity, PTY, and screen.
|
||||
pub fn open(
|
||||
&mut self,
|
||||
spec: TerminalSpec,
|
||||
core: &mut EditorCore,
|
||||
supervisor: &mut ProcessSupervisor,
|
||||
) -> Result<BufferId, TerminalError> {
|
||||
spec.validate()?;
|
||||
let size = CellSize::new(u32::from(spec.rows), u32::from(spec.cols));
|
||||
let screen = TerminalScreen::new(size, spec.scrollback_rows)
|
||||
.map_err(|error| TerminalError::Screen(error.to_string()))?;
|
||||
|
||||
let buffer_name = spec.buffer_name();
|
||||
let buffer_id = BufferId::next();
|
||||
let mut buffer = Buffer::new(buffer_id, buffer_name.clone());
|
||||
buffer.set_read_only(true);
|
||||
core.registry.borrow_mut().insert(buffer);
|
||||
|
||||
let mut process_spec = ProcessSpec::new(buffer_name, spec.command);
|
||||
process_spec.args = spec.args;
|
||||
process_spec.cwd = spec.cwd;
|
||||
process_spec.env = spec.env;
|
||||
if !process_spec.env.iter().any(|(name, _)| name == "TERM") {
|
||||
process_spec
|
||||
.env
|
||||
.push(("TERM".into(), "xterm-256color".into()));
|
||||
}
|
||||
process_spec.mode = ProcessMode::Pty {
|
||||
rows: spec.rows,
|
||||
cols: spec.cols,
|
||||
mode: TerminalMode::Raw,
|
||||
};
|
||||
process_spec.restart = RestartPolicy::Never;
|
||||
process_spec.ansi_events = true;
|
||||
process_spec.ansi_profile = AnsiParserProfile::FullScreen;
|
||||
process_spec.stdin = StdinMode::Piped;
|
||||
process_spec.group = false;
|
||||
|
||||
let process_id = match supervisor.spawn_terminal(process_spec) {
|
||||
Ok(id) => id,
|
||||
Err(error) => {
|
||||
core.registry
|
||||
.borrow_mut()
|
||||
.remove(buffer_id)
|
||||
.map_err(|rollback| {
|
||||
TerminalError::Buffer(format!(
|
||||
"spawn failed ({error}); buffer rollback failed: {rollback}"
|
||||
))
|
||||
})?;
|
||||
return Err(TerminalError::Spawn(error));
|
||||
}
|
||||
};
|
||||
let pid =
|
||||
if let Some(ProcessState::Running { pid, .. } | ProcessState::Exiting { pid, .. }) =
|
||||
supervisor.state(process_id)
|
||||
{
|
||||
*pid
|
||||
} else {
|
||||
let _ = supervisor.terminate(process_id);
|
||||
let _ = core.registry.borrow_mut().remove(buffer_id);
|
||||
return Err(TerminalError::Spawn(
|
||||
"supervisor published a PTY without a running pid".into(),
|
||||
));
|
||||
};
|
||||
|
||||
let previous = self.sessions.insert(
|
||||
buffer_id,
|
||||
TerminalSession {
|
||||
process_id,
|
||||
pid,
|
||||
screen,
|
||||
process: TerminalProcessState::Running,
|
||||
annotated: false,
|
||||
},
|
||||
);
|
||||
debug_assert!(previous.is_none(), "fresh BufferId collided");
|
||||
self.process_to_buffer.insert(process_id, buffer_id);
|
||||
core.set_round_trip_input(buffer_id, true);
|
||||
Ok(buffer_id)
|
||||
}
|
||||
|
||||
/// Whether `buffer_id` identifies a published terminal session.
|
||||
#[must_use]
|
||||
pub fn is_terminal(&self, buffer_id: BufferId) -> bool {
|
||||
self.sessions.contains_key(&buffer_id)
|
||||
}
|
||||
|
||||
/// Owned process id for a terminal buffer. The OS pid stays in snapshots.
|
||||
#[must_use]
|
||||
pub fn process_id(&self, buffer_id: BufferId) -> Option<ProcessId> {
|
||||
self.sessions
|
||||
.get(&buffer_id)
|
||||
.map(|session| session.process_id)
|
||||
}
|
||||
|
||||
/// Capture context-free owned visible state after the latest tick.
|
||||
#[must_use]
|
||||
pub fn snapshot(&self, buffer_id: BufferId) -> Option<TerminalSnapshot> {
|
||||
let session = self.sessions.get(&buffer_id)?;
|
||||
let screen = session.screen.snapshot();
|
||||
Some(TerminalSnapshot {
|
||||
buffer_id,
|
||||
size: screen.size,
|
||||
cells: screen.cells,
|
||||
cursor: screen.cursor,
|
||||
title: screen.title.map(|title| sanitize_metadata(&title)),
|
||||
screen_generation: screen.generation,
|
||||
selection: Vec::new(),
|
||||
scroll_offset: 0,
|
||||
at_bottom: true,
|
||||
pid: session.pid,
|
||||
process: session.process.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Drain only terminal-owned process IDs after the supervisor tick.
|
||||
pub fn tick(&mut self, supervisor: &mut ProcessSupervisor) {
|
||||
let process_ids: Vec<ProcessId> = self.process_to_buffer.keys().copied().collect();
|
||||
for process_id in process_ids {
|
||||
let Some(buffer_id) = self.process_to_buffer.get(&process_id).copied() else {
|
||||
continue;
|
||||
};
|
||||
let events = supervisor.take_events(process_id);
|
||||
let Some(session) = self.sessions.get_mut(&buffer_id) else {
|
||||
continue;
|
||||
};
|
||||
let mut outcome = None;
|
||||
for event in events {
|
||||
match event.kind {
|
||||
ProcessEventKind::Started { pid } => session.pid = pid,
|
||||
ProcessEventKind::Ansi(events) => {
|
||||
for event in events {
|
||||
if let Some(response) = session.screen.apply_event(event) {
|
||||
let _ = supervisor.write_stdin(process_id, &response);
|
||||
}
|
||||
}
|
||||
}
|
||||
ProcessEventKind::Exited { code } => {
|
||||
outcome = Some(TerminalProcessState::Exited(code));
|
||||
}
|
||||
ProcessEventKind::Signaled { signal } => {
|
||||
outcome = Some(TerminalProcessState::Signaled(sanitize_metadata(&signal)));
|
||||
}
|
||||
ProcessEventKind::Crashed { error } => {
|
||||
outcome = Some(TerminalProcessState::Crashed(sanitize_metadata(&error)));
|
||||
}
|
||||
ProcessEventKind::Stdout(_)
|
||||
| ProcessEventKind::Stderr(_)
|
||||
| ProcessEventKind::Restarting { .. } => {}
|
||||
}
|
||||
}
|
||||
let _ = session.screen.synchronized_watchdog_expired(Instant::now());
|
||||
if let Some(outcome) = outcome {
|
||||
finish_session(session, outcome);
|
||||
}
|
||||
}
|
||||
|
||||
let closing: Vec<ProcessId> = self.closing.iter().copied().collect();
|
||||
for process_id in closing {
|
||||
// Continue owning and discarding every final batch until reaped.
|
||||
let _ = supervisor.take_events(process_id);
|
||||
if matches!(
|
||||
supervisor.state(process_id),
|
||||
Some(ProcessState::Terminated(_)) | None
|
||||
) {
|
||||
let _ = supervisor.forget(process_id);
|
||||
self.closing.remove(&process_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue raw terminal input for a running child.
|
||||
pub fn send(
|
||||
&self,
|
||||
buffer_id: BufferId,
|
||||
bytes: &[u8],
|
||||
supervisor: &mut ProcessSupervisor,
|
||||
) -> Result<(), TerminalError> {
|
||||
let session = self
|
||||
.sessions
|
||||
.get(&buffer_id)
|
||||
.ok_or(TerminalError::NotTerminal(buffer_id))?;
|
||||
supervisor
|
||||
.write_stdin(session.process_id, bytes)
|
||||
.map_err(TerminalError::Process)
|
||||
}
|
||||
|
||||
/// Resize a terminal screen and its PTY after validating shared limits.
|
||||
pub fn resize(
|
||||
&mut self,
|
||||
buffer_id: BufferId,
|
||||
rows: u16,
|
||||
cols: u16,
|
||||
supervisor: &mut ProcessSupervisor,
|
||||
) -> Result<(), TerminalError> {
|
||||
validate_size(rows, cols)?;
|
||||
let session = self
|
||||
.sessions
|
||||
.get_mut(&buffer_id)
|
||||
.ok_or(TerminalError::NotTerminal(buffer_id))?;
|
||||
if matches!(session.process, TerminalProcessState::Running) {
|
||||
supervisor
|
||||
.resize_pty(session.process_id, rows, cols)
|
||||
.map_err(TerminalError::Process)?;
|
||||
}
|
||||
session
|
||||
.screen
|
||||
.resize(CellSize::new(u32::from(rows), u32::from(cols)))
|
||||
.map_err(|error| TerminalError::Screen(error.to_string()))
|
||||
}
|
||||
|
||||
/// Request SIGTERM. Snapshot state stays `Running` until the outcome event.
|
||||
pub fn terminate(
|
||||
&mut self,
|
||||
buffer_id: BufferId,
|
||||
supervisor: &mut ProcessSupervisor,
|
||||
) -> Result<(), TerminalError> {
|
||||
let session = self
|
||||
.sessions
|
||||
.get(&buffer_id)
|
||||
.ok_or(TerminalError::NotTerminal(buffer_id))?;
|
||||
if matches!(session.process, TerminalProcessState::Running) {
|
||||
supervisor
|
||||
.terminate(session.process_id)
|
||||
.map_err(TerminalError::Process)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tear down sessions whose identity buffers were removed by any path.
|
||||
pub fn prune(&mut self, core: &mut EditorCore, supervisor: &mut ProcessSupervisor) {
|
||||
let removed: Vec<BufferId> = {
|
||||
let registry = core.registry.borrow();
|
||||
self.sessions
|
||||
.keys()
|
||||
.copied()
|
||||
.filter(|buffer_id| !registry.contains(*buffer_id))
|
||||
.collect()
|
||||
};
|
||||
for buffer_id in removed {
|
||||
core.set_round_trip_input(buffer_id, false);
|
||||
let Some(session) = self.sessions.remove(&buffer_id) else {
|
||||
continue;
|
||||
};
|
||||
self.process_to_buffer.remove(&session.process_id);
|
||||
match supervisor.state(session.process_id) {
|
||||
Some(
|
||||
ProcessState::Starting
|
||||
| ProcessState::Running { .. }
|
||||
| ProcessState::Exiting { .. },
|
||||
) => {
|
||||
let _ = supervisor.terminate(session.process_id);
|
||||
self.closing.insert(session.process_id);
|
||||
}
|
||||
Some(ProcessState::Terminated(_)) => {
|
||||
let _ = supervisor.take_events(session.process_id);
|
||||
let _ = supervisor.forget(session.process_id);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Terminate every terminal child and unpublish all sessions.
|
||||
///
|
||||
/// The editor follows this with the supervisor's bounded global shutdown,
|
||||
/// which performs final TERM/KILL escalation for terminal and non-terminal
|
||||
/// processes alike.
|
||||
pub fn shutdown(&mut self, supervisor: &mut ProcessSupervisor) {
|
||||
let process_ids: Vec<ProcessId> = self.process_to_buffer.keys().copied().collect();
|
||||
for process_id in process_ids {
|
||||
if matches!(
|
||||
supervisor.state(process_id),
|
||||
Some(
|
||||
ProcessState::Running { .. }
|
||||
| ProcessState::Exiting { .. }
|
||||
| ProcessState::Starting
|
||||
)
|
||||
) {
|
||||
let _ = supervisor.terminate(process_id);
|
||||
}
|
||||
self.closing.insert(process_id);
|
||||
}
|
||||
self.sessions.clear();
|
||||
self.process_to_buffer.clear();
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_session(session: &mut TerminalSession, outcome: TerminalProcessState) {
|
||||
if session.annotated {
|
||||
session.process = outcome;
|
||||
return;
|
||||
}
|
||||
session.screen.finish_output();
|
||||
let annotation = match &outcome {
|
||||
TerminalProcessState::Running => return,
|
||||
TerminalProcessState::Exited(0) => {
|
||||
format!("Process {} exited normally with code 0", session.pid)
|
||||
}
|
||||
TerminalProcessState::Exited(code) => {
|
||||
format!("Process {} exited abnormally with code {code}", session.pid)
|
||||
}
|
||||
TerminalProcessState::Signaled(signal) => format!(
|
||||
"Process {} exited abnormally with signal {signal}",
|
||||
session.pid
|
||||
),
|
||||
TerminalProcessState::Crashed(error) => {
|
||||
format!("Process {} crashed: {error}", session.pid)
|
||||
}
|
||||
};
|
||||
session.screen.append_process_annotation(&annotation);
|
||||
session.annotated = true;
|
||||
// Publish exit metadata only after final bytes and annotation are applied.
|
||||
session.process = outcome;
|
||||
}
|
||||
|
||||
fn validate_size(rows: u16, cols: u16) -> Result<(), TerminalError> {
|
||||
let cells = usize::from(rows) * usize::from(cols);
|
||||
if rows == 0 || rows > MAX_TERMINAL_ROWS {
|
||||
return Err(TerminalError::InvalidSpec(format!(
|
||||
"rows must be in 1..={MAX_TERMINAL_ROWS}; got {rows}"
|
||||
)));
|
||||
}
|
||||
if cols == 0 || cols > MAX_TERMINAL_COLS {
|
||||
return Err(TerminalError::InvalidSpec(format!(
|
||||
"cols must be in 1..={MAX_TERMINAL_COLS}; got {cols}"
|
||||
)));
|
||||
}
|
||||
if cells > MAX_TERMINAL_VISIBLE_CELLS {
|
||||
return Err(TerminalError::InvalidSpec(format!(
|
||||
"visible cell count {cells} exceeds {MAX_TERMINAL_VISIBLE_CELLS}"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reject_nul(field: &str, bytes: &[u8]) -> Result<(), TerminalError> {
|
||||
if bytes.contains(&0) {
|
||||
Err(TerminalError::InvalidSpec(format!(
|
||||
"{field} must not contain NUL"
|
||||
)))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_metadata(value: &str) -> String {
|
||||
let mut clean = String::with_capacity(value.len().min(MAX_TERMINAL_METADATA_BYTES));
|
||||
for ch in value.chars() {
|
||||
let ch = if ch == '\r' || ch == '\n' || ch.is_control() {
|
||||
' '
|
||||
} else {
|
||||
ch
|
||||
};
|
||||
if clean.len() + ch.len_utf8() > MAX_TERMINAL_METADATA_BYTES {
|
||||
break;
|
||||
}
|
||||
clean.push(ch);
|
||||
}
|
||||
clean
|
||||
}
|
||||
|
|
@ -0,0 +1,436 @@
|
|||
//! Shared Stage 1 terminal registry, lifecycle, and read-only acceptance.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use pmacs::ansi::AnsiEvent;
|
||||
use pmacs::buffer::{Buffer, BufferError, BufferId, EditOp};
|
||||
use pmacs::cell::{CellSize, Glyph};
|
||||
use pmacs::editor::EditorState;
|
||||
use pmacs::process::ProcessState;
|
||||
use pmacs::rope::Range;
|
||||
use pmacs::terminal::screen::TerminalScreen;
|
||||
use pmacs::terminal::{TerminalProcessState, TerminalSpec};
|
||||
|
||||
fn rope_bytes(buffer: &Buffer) -> Vec<u8> {
|
||||
let mut bytes = vec![0; buffer.len() as usize];
|
||||
if !bytes.is_empty() {
|
||||
buffer.snapshot_rope().slice(0, buffer.len(), &mut bytes);
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
fn screen_text(snapshot: &pmacs::terminal::TerminalSnapshot) -> String {
|
||||
let mut text = String::new();
|
||||
for (index, cell) in snapshot.cells.iter().enumerate() {
|
||||
if index > 0 && index % snapshot.size.cols as usize == 0 {
|
||||
text.push('\n');
|
||||
}
|
||||
match &cell.glyph {
|
||||
Glyph::Char(ch) => text.push(*ch),
|
||||
Glyph::Cluster(bytes) => text.push_str(&String::from_utf8_lossy(bytes)),
|
||||
Glyph::Continuation => {}
|
||||
}
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
fn tick_until(
|
||||
state: &mut EditorState,
|
||||
timeout: Duration,
|
||||
mut done: impl FnMut(&EditorState) -> bool,
|
||||
) {
|
||||
let deadline = Instant::now() + timeout;
|
||||
while Instant::now() < deadline {
|
||||
state.tick_processes();
|
||||
if done(state) {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
panic!("terminal condition did not settle before {timeout:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_cells_reject_child_control_characters() {
|
||||
let mut screen = TerminalScreen::new(CellSize::new(2, 4), 0).expect("valid screen");
|
||||
let before = screen.snapshot();
|
||||
screen.apply_event(AnsiEvent::Text("\u{9b}\n\0".into()));
|
||||
assert_eq!(screen.snapshot(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_failure_is_transactional() {
|
||||
let mut state = EditorState::new();
|
||||
let buffers_before = state.core.borrow().registry.borrow().len();
|
||||
let processes_before = state.process_supervisor.borrow().ids().count();
|
||||
state.process_supervisor.borrow_mut().shutdown();
|
||||
let result = state.open_terminal(TerminalSpec::new("/bin/sh"));
|
||||
|
||||
assert!(result.is_err());
|
||||
assert_eq!(state.core.borrow().registry.borrow().len(), buffers_before);
|
||||
assert_eq!(state.terminal_manager.borrow().len(), 0);
|
||||
assert_eq!(
|
||||
state.process_supervisor.borrow().ids().count(),
|
||||
processes_before
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_owned_spec_rejects_before_spawn_and_is_mutation_independent() {
|
||||
let mut state = EditorState::new();
|
||||
let buffers_before = state.core.borrow().registry.borrow().len();
|
||||
let mut invalid = TerminalSpec::new("/bin/sh");
|
||||
invalid.rows = 0;
|
||||
assert!(state.open_terminal(invalid).is_err());
|
||||
assert_eq!(state.core.borrow().registry.borrow().len(), buffers_before);
|
||||
assert!(state.terminal_manager.borrow().is_empty());
|
||||
assert_eq!(state.process_supervisor.borrow().ids().count(), 0);
|
||||
|
||||
let mut spec = TerminalSpec::new("/bin/sh");
|
||||
spec.args = vec!["-c".into(), "sleep 30".into()];
|
||||
spec.env = vec![("PMACS_VTERM_OWNED".into(), "original".into())];
|
||||
let mut caller_copy = spec.clone();
|
||||
let buffer_id = state.open_terminal(spec).expect("valid owned spec");
|
||||
caller_copy.command.clear();
|
||||
caller_copy.args.clear();
|
||||
caller_copy.env[0].1 = "mutated".into();
|
||||
let lua_processes: usize = state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load("return #pmacs.process.list()")
|
||||
.eval()
|
||||
.expect("process list");
|
||||
assert_eq!(
|
||||
lua_processes, 0,
|
||||
"terminal-owned ProcessId must not be exposed through pmacs.process"
|
||||
);
|
||||
let terminal_module_absent: bool = state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load("return pmacs.terminal == nil")
|
||||
.eval()
|
||||
.expect("terminal module absence");
|
||||
assert!(
|
||||
terminal_module_absent,
|
||||
"Stage 1 must not publish an unrenderable interactive Lua terminal API"
|
||||
);
|
||||
|
||||
let process_id = state
|
||||
.terminal_manager
|
||||
.borrow()
|
||||
.process_id(buffer_id)
|
||||
.expect("terminal process");
|
||||
let supervisor = state.process_supervisor.borrow();
|
||||
let process_spec = supervisor.spec(process_id).expect("owned process spec");
|
||||
assert_eq!(process_spec.command, "/bin/sh");
|
||||
assert_eq!(
|
||||
process_spec.args,
|
||||
[String::from("-c"), String::from("sleep 30")]
|
||||
);
|
||||
assert_eq!(
|
||||
process_spec.env,
|
||||
[
|
||||
(String::from("PMACS_VTERM_OWNED"), String::from("original")),
|
||||
(String::from("TERM"), String::from("xterm-256color")),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_only_guard_covers_direct_skip_undo_and_redo_without_state_change() {
|
||||
let mut buffer = Buffer::from_bytes(BufferId::next(), "*protected*", b"abc");
|
||||
buffer
|
||||
.apply_edit(EditOp::Insert {
|
||||
pos: 3,
|
||||
bytes: b"d",
|
||||
})
|
||||
.expect("seed undo");
|
||||
buffer.undo().expect("seed redo");
|
||||
buffer.set_read_only(true);
|
||||
let before = (rope_bytes(&buffer), buffer.revision(), buffer.is_modified());
|
||||
assert!(matches!(
|
||||
buffer.begin_edit(),
|
||||
Err(BufferError::ReadOnly { .. })
|
||||
));
|
||||
assert!(!buffer.editing_in_progress());
|
||||
|
||||
let results = [
|
||||
buffer.apply_edit(EditOp::Insert {
|
||||
pos: 0,
|
||||
bytes: b"x",
|
||||
}),
|
||||
buffer.apply_edit_skip_intercepts(EditOp::Replace {
|
||||
range: Range::new(0, 1),
|
||||
bytes: b"y",
|
||||
}),
|
||||
buffer.undo(),
|
||||
buffer.redo(),
|
||||
];
|
||||
|
||||
assert!(
|
||||
results
|
||||
.iter()
|
||||
.all(|result| matches!(result, Err(BufferError::ReadOnly { .. })))
|
||||
);
|
||||
assert_eq!(
|
||||
before,
|
||||
(rope_bytes(&buffer), buffer.revision(), buffer.is_modified())
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "crdt")]
|
||||
#[test]
|
||||
fn read_only_empty_crdt_bootstrap_is_immutable_against_remote_content() {
|
||||
let mut buffer = Buffer::new(BufferId::next(), "*terminal*");
|
||||
buffer.set_read_only(true);
|
||||
buffer
|
||||
.upgrade_to_crdt(1)
|
||||
.expect("empty immutable bootstrap is allowed");
|
||||
|
||||
let donor = pmacs::crdt::CrdtState::new(2).expect("donor");
|
||||
let version = donor.version();
|
||||
donor.insert(0, "forged").expect("donor edit");
|
||||
let update = donor.export_updates_since(&version).expect("update");
|
||||
|
||||
assert!(matches!(
|
||||
buffer.apply_remote_crdt_op(&update),
|
||||
Err(BufferError::ReadOnly { .. })
|
||||
));
|
||||
assert!(buffer.is_empty());
|
||||
assert_eq!(buffer.revision(), 0);
|
||||
assert!(!buffer.is_modified());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn final_output_precedes_exact_nonzero_annotation_and_buffer_is_retained() {
|
||||
let mut state = EditorState::new();
|
||||
let mut spec = TerminalSpec::new("/bin/sh");
|
||||
spec.args = vec![
|
||||
"-c".into(),
|
||||
concat!(
|
||||
"printf 'main-home'; ",
|
||||
"printf '\\033'; sleep 0.03; printf '[?1049h'; ",
|
||||
"printf '\\033[2;'; sleep 0.03; printf '4HALT'; ",
|
||||
"IFS= read -r gate; ",
|
||||
"printf '\\033[?1049l'; ",
|
||||
"printf '\\033[2;'; sleep 0.03; printf '3Hfinal-'; ",
|
||||
"sleep 0.03; printf 'output'; exit 7"
|
||||
)
|
||||
.into(),
|
||||
];
|
||||
spec.rows = 8;
|
||||
spec.cols = 80;
|
||||
let buffer_id = state.open_terminal(spec).expect("open terminal");
|
||||
|
||||
tick_until(&mut state, Duration::from_secs(5), |state| {
|
||||
state
|
||||
.terminal_manager
|
||||
.borrow()
|
||||
.snapshot(buffer_id)
|
||||
.is_some_and(|snapshot| {
|
||||
matches!(snapshot.process, TerminalProcessState::Running)
|
||||
&& screen_text(&snapshot)
|
||||
.lines()
|
||||
.nth(1)
|
||||
.is_some_and(|row| row.starts_with(" ALT"))
|
||||
})
|
||||
});
|
||||
let alternate = state
|
||||
.terminal_manager
|
||||
.borrow()
|
||||
.snapshot(buffer_id)
|
||||
.expect("running alternate-screen snapshot");
|
||||
let alternate_text = screen_text(&alternate);
|
||||
assert!(alternate_text.contains("ALT"));
|
||||
assert!(
|
||||
!alternate_text.contains("main-home"),
|
||||
"alternate screen must not expose the preserved main grid"
|
||||
);
|
||||
{
|
||||
let manager = state.terminal_manager.borrow();
|
||||
let mut supervisor = state.process_supervisor.borrow_mut();
|
||||
manager
|
||||
.send(buffer_id, b"\n", &mut supervisor)
|
||||
.expect("raw stdin unblocks child");
|
||||
}
|
||||
|
||||
tick_until(&mut state, Duration::from_secs(5), |state| {
|
||||
state
|
||||
.terminal_manager
|
||||
.borrow()
|
||||
.snapshot(buffer_id)
|
||||
.is_some_and(|snapshot| matches!(snapshot.process, TerminalProcessState::Exited(7)))
|
||||
});
|
||||
|
||||
let snapshot = state
|
||||
.terminal_manager
|
||||
.borrow()
|
||||
.snapshot(buffer_id)
|
||||
.expect("retained terminal snapshot");
|
||||
let text = screen_text(&snapshot);
|
||||
assert!(
|
||||
text.contains("main-home"),
|
||||
"leaving alternate screen must restore the main grid"
|
||||
);
|
||||
assert!(
|
||||
!text.contains("ALT"),
|
||||
"alternate-screen output must not enter the retained main grid"
|
||||
);
|
||||
assert!(
|
||||
text.lines()
|
||||
.nth(1)
|
||||
.is_some_and(|row| row.starts_with(" final-output")),
|
||||
"FullScreen parser/profile must honor CSI cursor addressing"
|
||||
);
|
||||
let output_at = text
|
||||
.find("final-output")
|
||||
.expect("final child output visible");
|
||||
let annotation = format!("Process {} exited abnormally with code 7", snapshot.pid);
|
||||
let annotation_at = text
|
||||
.find(&annotation)
|
||||
.expect("exact exit annotation visible");
|
||||
assert!(
|
||||
output_at < annotation_at,
|
||||
"final output must precede annotation"
|
||||
);
|
||||
assert!(state.core.borrow().registry.borrow().contains(buffer_id));
|
||||
let core = state.core.borrow();
|
||||
let registry = core.registry.borrow();
|
||||
let buffer = registry.get(buffer_id).expect("identity buffer retained");
|
||||
assert!(buffer.is_read_only());
|
||||
assert!(buffer.is_empty());
|
||||
assert!(!buffer.is_modified());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_and_signal_annotations_use_exact_pid_and_outcome() {
|
||||
for (script, expected, annotation_tail) in [
|
||||
(
|
||||
"printf normal-output; exit 0",
|
||||
TerminalProcessState::Exited(0),
|
||||
"exited normally with code 0",
|
||||
),
|
||||
(
|
||||
"printf signal-output; kill -TERM $$",
|
||||
TerminalProcessState::Signaled("SIGTERM".into()),
|
||||
"exited abnormally with signal SIGTERM",
|
||||
),
|
||||
] {
|
||||
let mut state = EditorState::new();
|
||||
let mut spec = TerminalSpec::new("/bin/sh");
|
||||
spec.args = vec!["-c".into(), script.into()];
|
||||
spec.rows = 6;
|
||||
spec.cols = 80;
|
||||
let buffer_id = state.open_terminal(spec).expect("open terminal");
|
||||
tick_until(&mut state, Duration::from_secs(5), |state| {
|
||||
state
|
||||
.terminal_manager
|
||||
.borrow()
|
||||
.snapshot(buffer_id)
|
||||
.is_some_and(|snapshot| snapshot.process == expected)
|
||||
});
|
||||
let snapshot = state
|
||||
.terminal_manager
|
||||
.borrow()
|
||||
.snapshot(buffer_id)
|
||||
.expect("snapshot retained");
|
||||
assert!(
|
||||
screen_text(&snapshot).contains(&format!("Process {} {annotation_tail}", snapshot.pid)),
|
||||
"missing exact annotation for {:?}",
|
||||
snapshot.process
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn killing_terminal_buffer_prunes_session_and_reaps_owned_process() {
|
||||
let mut state = EditorState::new();
|
||||
let mut spec = TerminalSpec::new("/bin/sh");
|
||||
spec.args = vec!["-c".into(), "sleep 30".into()];
|
||||
let buffer_id = state.open_terminal(spec).expect("open terminal");
|
||||
let process_id = state
|
||||
.terminal_manager
|
||||
.borrow()
|
||||
.process_id(buffer_id)
|
||||
.expect("owned process");
|
||||
|
||||
state
|
||||
.core
|
||||
.borrow_mut()
|
||||
.kill_buffer(buffer_id)
|
||||
.expect("kill identity buffer");
|
||||
state.tick_processes();
|
||||
assert!(!state.terminal_manager.borrow().is_terminal(buffer_id));
|
||||
|
||||
tick_until(&mut state, Duration::from_secs(5), |state| {
|
||||
state
|
||||
.process_supervisor
|
||||
.borrow()
|
||||
.state(process_id)
|
||||
.is_none()
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_shutdown_kills_term_ignoring_terminal_child() {
|
||||
let pid = {
|
||||
let mut state = EditorState::new();
|
||||
state
|
||||
.process_supervisor
|
||||
.borrow_mut()
|
||||
.set_grace_period(Duration::from_millis(50));
|
||||
let mut spec = TerminalSpec::new("/bin/sh");
|
||||
spec.args = vec![
|
||||
"-c".into(),
|
||||
"trap '' TERM; while :; do sleep 1; done".into(),
|
||||
];
|
||||
let buffer_id = state.open_terminal(spec).expect("open terminal");
|
||||
state
|
||||
.terminal_manager
|
||||
.borrow()
|
||||
.snapshot(buffer_id)
|
||||
.expect("snapshot")
|
||||
.pid
|
||||
};
|
||||
|
||||
let pid = nix::unistd::Pid::from_raw(i32::try_from(pid).expect("pid fits i32"));
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
while Instant::now() < deadline && nix::sys::signal::kill(pid, None).is_ok() {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
assert_eq!(
|
||||
nix::sys::signal::kill(pid, None),
|
||||
Err(nix::errno::Errno::ESRCH),
|
||||
"terminal child {pid} survived EditorState shutdown"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_tick_does_not_take_non_terminal_process_events() {
|
||||
let mut state = EditorState::new();
|
||||
let mut process = pmacs::process::ProcessSpec::new("ordinary", "/bin/sh");
|
||||
process.args = vec!["-c".into(), "printf ordinary".into()];
|
||||
let ordinary_id = state
|
||||
.process_supervisor
|
||||
.borrow_mut()
|
||||
.spawn(process)
|
||||
.expect("ordinary process");
|
||||
|
||||
tick_until(&mut state, Duration::from_secs(5), |state| {
|
||||
matches!(
|
||||
state.process_supervisor.borrow().state(ordinary_id),
|
||||
Some(ProcessState::Terminated(_))
|
||||
)
|
||||
});
|
||||
let events = state
|
||||
.process_supervisor
|
||||
.borrow_mut()
|
||||
.take_events(ordinary_id);
|
||||
assert!(
|
||||
events.iter().any(|event| matches!(
|
||||
&event.kind,
|
||||
pmacs::process::ProcessEventKind::Stdout(bytes) if bytes == b"ordinary"
|
||||
)),
|
||||
"TerminalManager must not steal ordinary process output"
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue