test(vterm): prove Stage 2 TUI integration

Add cross-surface Lua, shared-view, clipboard, authenticated routing,
BEL, resize, and real-host PTY acceptance. Ensure terminal-local keymaps
run before raw child transport and document the criterion-to-test map.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-07-21 20:16:23 -04:00
parent 0dacac7e8d
commit dc9225778a
10 changed files with 819 additions and 176 deletions

View File

@ -88,21 +88,18 @@ 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 — VTERM STAGE 1 ON FEATURE BRANCH
### Arc 5 — Terminal, staged — VTERM STAGE 2 ON FEATURE BRANCH
- **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
- **Vterm Stage 1 terminal core landed** in #126: 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.
acceptance.
- **Vterm Stage 2 TUI** is implemented on `vterm-tui`: terminal-window
composition, input/resize, per-context scroll/selection/copy, authenticated
frontend ownership, BEL/clipboard drainage, and the strict 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

View File

@ -1,8 +1,8 @@
# Vterm — framing (Arc 5 stage 2, three-PR delivery)
**Revision 7 — 2026-07-21. Status: Stage 1 landed on `main` as PR #126
at merge `643d1e1`; Stages 2 and 3 are not implemented. Stage 2 framing review
is complete and the contract is ready for implementation.**
at merge `643d1e1`; Stage 2 is implemented on branch `vterm-tui` and Stage 3
is not implemented.**
Revision 7 closes the final three precision findings: `at_bottom` is geometric
and distinct from live-tail following; the fixed `C-c` transport escape
@ -1134,6 +1134,48 @@ base-branch deletion/auto-close risk and makes each PR's gate evidence honest.
exercises key/paste, resize, scroll/select/copy, BEL, and clean exit, then
proves host raw/alternate-screen state is restored.
#### Stage 2 verification map
The cross-surface suite is `tests/vterm_stage2_acceptance.rs`; focused unit
coverage remains beside the owning implementation. The criteria map as follows:
- **15:** `lua_surface_is_strict_fresh_transactional_and_context_safe`.
- **16:** `editor::tests::terminal_snapshot_composes_only_content_and_translates_cursor`
plus the real-TUI smoke.
- **17:** `terminal::view::tests::{tail_projection_pads_above_and_right_and_translates_cursor,
frozen_top_is_geometrically_at_bottom_when_view_still_reaches_tail,
alternate_switch_clears_view_anchors_and_selection}` and
`shared_screen_keeps_view_scroll_selection_and_controller_independent`.
- **18:** `terminal::input::tests::{utf8_ctrl_and_alt_boundaries,
application_cursor_and_xterm_modifiers, ambiguous_digits_ignore_application_keypad,
paste_and_focus_are_exact, unsupported_keys_are_invisible}` and the real-TUI
input/paste path.
- **19:** `editor::tests::dispatch_prefix_state_is_independent_per_frontend`,
`lua_surface_is_strict_fresh_transactional_and_context_safe`, and the
real-TUI terminal-escape/quit path.
- **20:** `terminal::input::tests::sgr_mouse_modes_modifiers_and_coordinates`
plus the real-TUI editor-owned scroll/drag/copy path.
- **21:** `terminal::view::tests::{copy_joins_soft_wraps_trims_default_blanks_and_separates_hard_rows,
wide_continuation_canonicalizes_to_lead_and_copies_once}`,
`lua_surface_is_strict_fresh_transactional_and_context_safe`, and the real
OSC 52 clipboard assertion.
- **22:** `lua_surface_is_strict_fresh_transactional_and_context_safe`,
`shared_screen_keeps_view_scroll_selection_and_controller_independent`, and
the real child-PTY resize assertion.
- **23:** `daemon::tests::forged_resize_mutates_only_the_authenticated_frontend`,
`daemon::tests::inbound_paste_uses_authenticated_source_not_the_claimed_id`,
the existing `m5_4_dispatch_{key,mouse}_threads_frontend_id_to_lua_surface`
tests, and the multi-frontend detach assertions in
`shared_screen_keeps_view_scroll_selection_and_controller_independent`.
- **24:** the built-in-provider and clipboard assertions in
`lua_surface_is_strict_fresh_transactional_and_context_safe`,
`daemon::tests::terminal_bell_baseline_suppresses_history_and_delivers_each_new_bell_once`,
and the real-TUI BEL/OSC 52 assertions.
- **25:** `shared_screen_keeps_view_scroll_selection_and_controller_independent`.
- **26:** both in-process acceptance tests' termination/cleanup assertions and
the real-TUI clean-exit/host-restoration assertions.
- **27:** `real_tui_terminal_smoke_restores_host_after_output_input_resize_scroll_copy_and_bell`.
### Stage 3 — GPU/protocol
28. Protocol v19 appends all new variants after v18 pins; v18 grid traffic

View File

@ -871,10 +871,8 @@ fn dispatcher_loop(
let mut last_active_buffer_sent: HashMap<FrontendId, crate::buffer::BufferId> = HashMap::new();
// Active-terminal BEL delivery baseline. Switching away forgets the
// terminal so historical bells are never replayed on later activation.
let mut terminal_bell_baselines: HashMap<
FrontendId,
(crate::buffer::BufferId, u64),
> = HashMap::new();
let mut terminal_bell_baselines: HashMap<FrontendId, (crate::buffer::BufferId, u64)> =
HashMap::new();
let mut session_registry = SessionRegistry::new();
// T M10.11 Q8 — jitter PRNG, seeded once so the
// convergence-under-jitter scenario is deterministically
@ -1077,12 +1075,7 @@ fn dispatcher_loop(
let render_state = render_states
.get_mut(fid)
.expect("render_state present for attached grid fid");
render_state.render_frame(
editor,
*fid,
&terminal_snapshots,
&other_presences,
)
render_state.render_frame(editor, *fid, &terminal_snapshots, &other_presences)
};
// T M10.6 per-frontend presence sweep. The snapshot is
@ -1537,10 +1530,7 @@ fn handle_dispatcher_event(
term_sizes: &mut HashMap<FrontendId, CellSize>,
last_dispatch_idle_sent: &mut HashMap<FrontendId, bool>,
last_active_buffer_sent: &mut HashMap<FrontendId, crate::buffer::BufferId>,
terminal_bell_baselines: &mut HashMap<
FrontendId,
(crate::buffer::BufferId, u64),
>,
terminal_bell_baselines: &mut HashMap<FrontendId, (crate::buffer::BufferId, u64)>,
session_registry: &mut SessionRegistry,
) {
match event {
@ -3071,6 +3061,119 @@ mod tests {
);
}
#[test]
fn forged_resize_mutates_only_the_authenticated_frontend() {
let source = FrontendId(41);
let forged = FrontendId(42);
let old_size = CellSize::new(24, 80);
let new_size = CellSize::new(31, 97);
let mut editor = EditorState::new();
let mut render_states = HashMap::from([
(source, RenderState::new(old_size)),
(forged, RenderState::new(old_size)),
]);
let mut semantic_states: HashMap<FrontendId, crate::semantic_render::SemanticRenderState> =
HashMap::new();
let mut streams: HashMap<FrontendId, UnixStream> = HashMap::new();
let mut term_sizes = HashMap::from([(source, old_size), (forged, old_size)]);
let mut last_dispatch_idle_sent = HashMap::new();
let mut last_active_buffer_sent = HashMap::new();
let mut terminal_bell_baselines = HashMap::new();
let mut session_registry = SessionRegistry::new();
handle_dispatcher_event(
DispatcherEvent::FrontendEvent {
source,
event: FrontendEvent::Resize {
frontend_id: forged,
size: new_size,
},
},
&mut editor,
&mut render_states,
&mut semantic_states,
&mut streams,
&mut term_sizes,
&mut last_dispatch_idle_sent,
&mut last_active_buffer_sent,
&mut terminal_bell_baselines,
&mut session_registry,
);
assert_eq!(render_states[&source].size(), new_size);
assert_eq!(term_sizes[&source], new_size);
assert_eq!(render_states[&forged].size(), old_size);
assert_eq!(term_sizes[&forged], old_size);
}
#[test]
fn terminal_bell_baseline_suppresses_history_and_delivers_each_new_bell_once() {
let mut editor = EditorState::new();
let mut spec = crate::terminal::TerminalSpec::new("/bin/sh");
spec.args = vec![
"-c".into(),
"printf '\\a'; IFS= read -r _; printf '\\a'; sleep 30".into(),
];
let buffer_id = editor
.terminal_manager
.borrow_mut()
.open(
spec,
&mut editor.core.borrow_mut(),
&mut editor.process_supervisor.borrow_mut(),
)
.expect("open bell probe");
editor
.core
.borrow_mut()
.switch_active_buffer_for(FrontendId::LOCAL, buffer_id)
.expect("display bell probe");
let deadline = Instant::now() + Duration::from_secs(5);
while editor.terminal_manager.borrow().bell_count(buffer_id) != Some(1) {
editor.tick_processes();
assert!(Instant::now() < deadline, "initial terminal bell timed out");
thread::sleep(Duration::from_millis(10));
}
let mut baselines = HashMap::new();
assert!(!take_pending_terminal_bell(
&editor,
FrontendId::LOCAL,
&mut baselines
));
editor
.terminal_manager
.borrow()
.send(
buffer_id,
b"\n",
&mut editor.process_supervisor.borrow_mut(),
)
.expect("advance bell probe");
let deadline = Instant::now() + Duration::from_secs(5);
while editor.terminal_manager.borrow().bell_count(buffer_id) != Some(2) {
editor.tick_processes();
assert!(Instant::now() < deadline, "second terminal bell timed out");
thread::sleep(Duration::from_millis(10));
}
assert!(take_pending_terminal_bell(
&editor,
FrontendId::LOCAL,
&mut baselines
));
assert!(!take_pending_terminal_bell(
&editor,
FrontendId::LOCAL,
&mut baselines
));
editor
.terminal_manager
.borrow_mut()
.terminate(buffer_id, &mut editor.process_supervisor.borrow_mut())
.expect("terminate bell probe");
}
/// Kill ring Q#KR10a — the unified paste route trusts only the
/// dispatcher's authenticated source. A forged payload id must not
/// paste into another frontend's active window, and the paste

View File

@ -39,9 +39,9 @@ use crate::protocol::{
MouseKind as TerminalMouseKind,
};
use crate::terminal::TerminalSnapshot;
use crate::terminal::view::TerminalViewKey;
use crate::view::{View, Viewport};
use crate::window::{Rect, WindowId};
use crate::terminal::view::TerminalViewKey;
/// Ephemeral authenticated origin for one interactive command invocation.
///
@ -300,11 +300,9 @@ 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 = crate::lua_bindings::make_terminal_manager(
lua_host.lua(),
process_supervisor.clone(),
)
.expect("install pmacs.terminal");
let terminal_manager =
crate::lua_bindings::make_terminal_manager(lua_host.lua(), process_supervisor.clone())
.expect("install pmacs.terminal");
lua_host
.eval(
Some("@pmacs/builtin/runtime/terminal.lua"),
@ -726,9 +724,7 @@ impl EditorState {
if self
.dispatchers
.get(&frontend_id)
.is_some_and(|state| {
state.terminal_escape || !state.dispatcher.pending().is_empty()
})
.is_some_and(|state| state.terminal_escape || !state.dispatcher.pending().is_empty())
{
return false;
}
@ -739,9 +735,7 @@ impl EditorState {
&& !core.menu_is_open()
&& core
.active_window_for(frontend_id)
.is_some_and(|window| {
!core.active_buffer_round_trips_for(window.buffer_id)
})
.is_some_and(|window| !core.active_buffer_round_trips_for(window.buffer_id))
}
/// Local-frontend compatibility wrapper.
@ -827,6 +821,17 @@ impl EditorState {
.dispatchers
.get(&frontend_id)
.is_some_and(|state| state.terminal_escape);
let terminal_local_binding = terminal_key.is_some_and(|view_key| {
chord.is_some_and(|chord| {
let stack = self.lua_host.keymaps().borrow();
stack.buffers.get(&view_key.buffer_id).is_some_and(|map| {
!matches!(
map.lookup(&[chord]),
crate::keymap_tree::Resolution::Unbound
)
})
})
});
if let Some(view_key) = terminal_key {
if escaped {
self.dispatchers
@ -847,21 +852,23 @@ impl EditorState {
self.claim_terminal_controller(view_key);
return;
}
let Some((terminal_key, modifiers)) = terminal_key_from_crossterm(key) else {
if !terminal_local_binding {
let Some((terminal_key, modifiers)) = terminal_key_from_crossterm(key) else {
return;
};
let modes = self
.terminal_manager
.borrow()
.modes_for_view(view_key)
.unwrap_or_default();
if let Some(bytes) =
crate::terminal::input::encode_key(terminal_key, modifiers, modes)
{
self.claim_terminal_controller(view_key);
self.send_terminal_bytes(view_key.buffer_id, &bytes);
}
return;
};
let modes = self
.terminal_manager
.borrow()
.modes_for_view(view_key)
.unwrap_or_default();
if let Some(bytes) =
crate::terminal::input::encode_key(terminal_key, modifiers, modes)
{
self.claim_terminal_controller(view_key);
self.send_terminal_bytes(view_key.buffer_id, &bytes);
}
return;
}
}
@ -1005,9 +1012,7 @@ impl EditorState {
.modes_for_view(key)
.unwrap_or_default();
self.claim_terminal_controller(key);
if let Some(bytes) =
crate::terminal::input::encode_focus(true, modes.focus_reporting)
{
if let Some(bytes) = crate::terminal::input::encode_focus(true, modes.focus_reporting) {
self.send_terminal_bytes(key.buffer_id, &bytes);
}
return;
@ -1025,9 +1030,7 @@ impl EditorState {
.borrow()
.modes_for_view(key)
.unwrap_or_default();
if let Some(bytes) =
crate::terminal::input::encode_focus(false, modes.focus_reporting)
{
if let Some(bytes) = crate::terminal::input::encode_focus(false, modes.focus_reporting) {
self.send_terminal_bytes(key.buffer_id, &bytes);
}
let _ = self.terminal_manager.borrow_mut().release_controller(key);
@ -1036,11 +1039,7 @@ impl EditorState {
/// Resize the one session durably controlled by `frontend_id`.
///
/// This is called before process drain and paint, never from rendering.
pub fn sync_terminal_layout(
&mut self,
frontend_id: FrontendId,
term_size: CellSize,
) -> bool {
pub fn sync_terminal_layout(&mut self, frontend_id: FrontendId, term_size: CellSize) -> bool {
let Some(key) = self
.terminal_manager
.borrow()
@ -1063,8 +1062,9 @@ impl EditorState {
let _ = self.terminal_manager.borrow_mut().release_controller(key);
return false;
}
let Some(placement) =
window_placements(&core, frontend_id, term_size).get(&key.window_id).copied()
let Some(placement) = window_placements(&core, frontend_id, term_size)
.get(&key.window_id)
.copied()
else {
return false;
};
@ -1119,8 +1119,7 @@ impl EditorState {
if placement.content.size.rows == 0 || placement.content.size.cols == 0 {
continue;
}
let key =
TerminalViewKey::new(frontend_id, window_id, window.buffer_id);
let key = TerminalViewKey::new(frontend_id, window_id, window.buffer_id);
if self.terminal_manager.borrow().is_terminal(window.buffer_id) {
live.insert(key);
sizes.push((key, placement.content.size));
@ -1395,9 +1394,7 @@ impl EditorState {
// rotate the boundary so a menu Cut chains like a keybound
// one. The invoke below bypasses dispatch_key, which would
// otherwise leave the boundary stale.
self.core
.borrow_mut()
.rotate_command(frontend_id, &command);
self.core.borrow_mut().rotate_command(frontend_id, &command);
// Q#KR10b: menu invocation bypasses dispatch_key's
// revision check — a menu Cut's edit must still fire
// `buffer.after-edit`.
@ -1602,9 +1599,13 @@ impl EditorState {
return;
}
let Some((win_id, rect)) =
window_at_cell(&self.core.borrow(), frontend_id, term_size, cell_row, cell_col)
else {
let Some((win_id, rect)) = window_at_cell(
&self.core.borrow(),
frontend_id,
term_size,
cell_row,
cell_col,
) else {
return;
};
let inner_rows = rect.size.rows.saturating_sub(1);
@ -1616,10 +1617,7 @@ impl EditorState {
self.mouse_click = None;
return;
}
let local = CellCoord::new(
local_row,
cell_col.saturating_sub(rect.origin.col),
);
let local = CellCoord::new(local_row, cell_col.saturating_sub(rect.origin.col));
self.dispatch_terminal_mouse(
TerminalViewKey::new(frontend_id, win_id, buffer_id),
content_size,
@ -1746,8 +1744,7 @@ impl EditorState {
&& modes.mouse_sgr
&& coord.row < screen_size.rows
&& coord.col < screen_size.cols
&& let Some(bytes) =
crate::terminal::input::encode_mouse(kind, coord, modifiers, modes)
&& let Some(bytes) = crate::terminal::input::encode_mouse(kind, coord, modifiers, modes)
{
self.claim_terminal_controller(key);
self.send_terminal_bytes(key.buffer_id, &bytes);
@ -2220,12 +2217,8 @@ pub fn run(file: Option<PathBuf>) -> io::Result<()> {
let size = frontend.size();
let _ = state.sync_terminal_layout(FrontendId::LOCAL, size);
let terminal_snapshots = state.prepare_terminal_views(FrontendId::LOCAL, size);
let mut messages = render_state.render_frame(
&state,
FrontendId::LOCAL,
&terminal_snapshots,
&[],
);
let mut messages =
render_state.render_frame(&state, FrontendId::LOCAL, &terminal_snapshots, &[]);
messages.extend(state.take_local_signals());
frontend.present_messages(&messages)?;
if state.core.borrow().quit {
@ -2745,14 +2738,7 @@ pub fn paint_frame(
}
drop(reg);
paint_status_line(
grid,
core,
&state.lua_host,
dispatcher,
term_size,
&theme,
);
paint_status_line(grid, core, &state.lua_host, dispatcher, term_size, &theme);
// An active isearch owns the bottom row (its prompt + match
// readout), but the terminal cursor stays in the buffer at the
@ -3604,8 +3590,7 @@ fn is_terminal_escape_chord(chord: Chord) -> bool {
}
fn terminal_key_from_crossterm(key: KeyEvent) -> Option<(TerminalKey, TerminalModifiers)> {
let modifiers =
crate::protocol::crossterm_translate::mods_from_crossterm(key.modifiers);
let modifiers = crate::protocol::crossterm_translate::mods_from_crossterm(key.modifiers);
let key = crate::protocol::crossterm_translate::keycode_from_crossterm(key.code);
if matches!(key, TerminalKey::Unknown(_)) {
return None;
@ -3749,13 +3734,7 @@ mod tests {
stride: size.cols,
size,
};
paint_frame(
&state,
FrontendId::LOCAL,
&snapshots,
&mut grid,
size,
)
paint_frame(&state, FrontendId::LOCAL, &snapshots, &mut grid, size)
};
assert_eq!(backing[0].glyph, crate::cell::Glyph::Char('T'));
assert!(backing[0].style.reverse);
@ -4343,7 +4322,11 @@ mod tests {
// window to the *buffer-list* buffer).
let mut s = fresh_with(b"");
s.dispatch_key(FrontendId::LOCAL, ctrl('x'));
assert_eq!(local_dispatcher(&s).pending().len(), 1, "C-x should start prefix");
assert_eq!(
local_dispatcher(&s).pending().len(),
1,
"C-x should start prefix"
);
s.dispatch_key(FrontendId::LOCAL, ctrl('b'));
assert!(
local_dispatcher(&s).pending().is_empty(),
@ -7656,7 +7639,13 @@ mod tests {
stride: cols,
size: crate::cell::CellSize::new(rows, cols),
};
let cursor = paint_frame(s, FrontendId::LOCAL, &HashMap::new(), &mut grid, crate::cell::CellSize::new(rows, cols));
let cursor = paint_frame(
s,
FrontendId::LOCAL,
&HashMap::new(),
&mut grid,
crate::cell::CellSize::new(rows, cols),
);
(backing, cols, cursor)
}

View File

@ -112,13 +112,7 @@ impl RenderState {
stride: self.size.cols,
size: self.size,
};
let coord = paint_frame(
state,
frontend_id,
terminal_snapshots,
&mut grid,
self.size,
);
let coord = paint_frame(state, frontend_id, terminal_snapshots, &mut grid, self.size);
// T M10.9 — overlay paint after main paint, before diff.
// Modifies cells in `next`; diff captures the changes
// as ordinary style updates.

View File

@ -54,8 +54,8 @@ use crate::buffer::{BufferId, EditOp, MarkGravity, MarkId};
use crate::buffer_registry::BufferRegistry;
use crate::cell::{Color, Style, UnderlineStyle};
use crate::command::{Command, CommandError, CommandRegistry, SourceLocation};
use crate::editor_core::EditorCore;
use crate::editor::InteractiveCommandOrigin;
use crate::editor_core::EditorCore;
use crate::highlight::SyntaxHighlightView;
use crate::hook::{Hook, HookRegistry};
use crate::key::{display_sequence, parse_sequence};
@ -8270,11 +8270,7 @@ fn install_terminal(
let buffer_id = {
let mut manager = manager.borrow_mut();
manager
.open(
spec,
&mut core.borrow_mut(),
&mut supervisor.borrow_mut(),
)
.open(spec, &mut core.borrow_mut(), &mut supervisor.borrow_mut())
.map_err(mlua::Error::external)?
};
let key = {
@ -8351,7 +8347,11 @@ fn install_terminal(
lua.create_function(move |_, (buffer, bytes): (BufferIdLua, mlua::String)| {
manager
.borrow()
.send(buffer.0, bytes.as_bytes().as_ref(), &mut supervisor.borrow_mut())
.send(
buffer.0,
bytes.as_bytes().as_ref(),
&mut supervisor.borrow_mut(),
)
.map_err(mlua::Error::external)
})?,
)?;
@ -8397,9 +8397,13 @@ fn install_terminal(
terminal.set(
"scroll",
lua.create_function(move |lua, lines: i64| {
let lines = i32::try_from(lines).map_err(|_| {
mlua::Error::external("pmacs.terminal.scroll: `lines` exceeds i32 range")
})?;
let lines = i32::try_from(lines).unwrap_or_else(|_| {
if lines.is_negative() {
i32::MIN
} else {
i32::MAX
}
});
let core = terminal_shared_core(lua, "scroll")?;
let key = active_terminal_view_key(lua, &core, "scroll")?;
Ok(manager.borrow_mut().scroll_lines(key, lines))
@ -8446,8 +8450,7 @@ fn install_terminal(
let Some(bytes) = manager.borrow_mut().copy_selection(key) else {
return Ok(false);
};
core.borrow_mut()
.clipboard_set_for(key.frontend_id, bytes);
core.borrow_mut().clipboard_set_for(key.frontend_id, bytes);
Ok(true)
})?,
)?;
@ -8490,8 +8493,8 @@ fn parse_terminal_spec(table: Table) -> mlua::Result<crate::terminal::TerminalSp
let command = strict_terminal_string(table.raw_get("command")?, "command", false)?
.ok_or_else(|| mlua::Error::external("pmacs.terminal.open: missing field `command`"))?;
let args = strict_terminal_args(table.raw_get("args")?)?;
let cwd = strict_terminal_string(table.raw_get("cwd")?, "cwd", true)?
.map(std::path::PathBuf::from);
let cwd =
strict_terminal_string(table.raw_get("cwd")?, "cwd", true)?.map(std::path::PathBuf::from);
let env = strict_terminal_env(table.raw_get("env")?)?;
let name = strict_terminal_string(table.raw_get("name")?, "name", true)?;
let rows = strict_terminal_u16(table.raw_get("rows")?, "rows", 24)?;
@ -8628,11 +8631,7 @@ fn strict_terminal_u16(value: Value, field: &'static str, default: u16) -> mlua:
}
}
fn strict_terminal_usize(
value: Value,
field: &'static str,
default: usize,
) -> mlua::Result<usize> {
fn strict_terminal_usize(value: Value, field: &'static str, default: usize) -> mlua::Result<usize> {
match value {
Value::Nil => Ok(default),
Value::Integer(value) => usize::try_from(value).map_err(|_| {

View File

@ -3824,12 +3824,7 @@ mod tests {
let buffer_id = active_buffer(&state);
sem.set_viewport(buffer_id, ByteRange { start: 0, end: 80 }, 0);
let grid_msgs = grid.render_frame(
&state,
FrontendId::LOCAL,
&HashMap::new(),
&[],
);
let grid_msgs = grid.render_frame(&state, FrontendId::LOCAL, &HashMap::new(), &[]);
let sem_msgs = sem.render_frame(&state);
assert!(
matches!(grid_msgs[0], InstanceMessage::CellDelta { .. }),

View File

@ -7,12 +7,8 @@ use crate::buffer::BufferId;
use crate::cell::{Cell, CellCoord, CellSize, Glyph, Style};
use crate::protocol::FrontendId;
use crate::terminal::screen::{ScreenProjection, TerminalModes, TerminalRow};
use crate::terminal::session::{
TerminalManager, TerminalSelectionSpan, TerminalSnapshot,
};
use crate::terminal::{
MAX_TERMINAL_COLS, MAX_TERMINAL_ROWS, MAX_TERMINAL_VISIBLE_CELLS,
};
use crate::terminal::session::{TerminalManager, TerminalSelectionSpan, TerminalSnapshot};
use crate::terminal::{MAX_TERMINAL_COLS, MAX_TERMINAL_ROWS, MAX_TERMINAL_VISIBLE_CELLS};
use crate::window::WindowId;
/// One frontend/window projection of a terminal session.
@ -284,10 +280,7 @@ impl TerminalManager {
};
state.selection_froze_top = state.top.is_none();
if state.top.is_none() {
state.top = rows
.get(geometry.start)
.copied()
.map(row_lead);
state.top = rows.get(geometry.start).copied().map(row_lead);
}
state.selection = Some(TerminalSelection {
anchor,
@ -320,7 +313,9 @@ impl TerminalManager {
let Some(head) = anchor_at(&rows, &geometry, viewport_size, coord) else {
return false;
};
let changed = state.selection.is_some_and(|selection| selection.head != head);
let changed = state
.selection
.is_some_and(|selection| selection.head != head);
if let Some(selection) = state.selection.as_mut() {
selection.head = head;
}
@ -372,10 +367,7 @@ impl TerminalManager {
/// Exact controlled view for one frontend, if it still exists.
#[must_use]
pub fn controller_view_for_frontend(
&self,
frontend_id: FrontendId,
) -> Option<TerminalViewKey> {
pub fn controller_view_for_frontend(&self, frontend_id: FrontendId) -> Option<TerminalViewKey> {
self.controllers.iter().find_map(|(buffer_id, controller)| {
(controller.frontend_id == frontend_id)
.then(|| TerminalViewKey::new(frontend_id, controller.window_id, *buffer_id))
@ -478,10 +470,7 @@ fn anchor_for(rows: &[&TerminalRow], resolved: ResolvedCell) -> LogicalCellAncho
}
}
fn clamp_or_clear(
rows: &[&TerminalRow],
anchor: LogicalCellAnchor,
) -> Option<LogicalCellAnchor> {
fn clamp_or_clear(rows: &[&TerminalRow], anchor: LogicalCellAnchor) -> Option<LogicalCellAnchor> {
if let Some(resolved) = resolve_anchor(rows, anchor) {
return Some(anchor_for(rows, resolved));
}
@ -537,7 +526,9 @@ fn view_geometry(
} else {
0
};
let rows_after_view = rows.len().saturating_sub(start.saturating_add(viewport_rows));
let rows_after_view = rows
.len()
.saturating_sub(start.saturating_add(viewport_rows));
ViewGeometry {
start,
top_padding,
@ -618,8 +609,7 @@ fn project_snapshot(
let geometry = view_geometry(&rows, state, viewport_size.rows);
let mut cells = vec![Cell::default(); viewport_size.area() as usize];
for retained_row in geometry.start..rows.len() {
let Some(target_row) =
viewport_row(&geometry, viewport_size.rows as usize, retained_row)
let Some(target_row) = viewport_row(&geometry, viewport_size.rows as usize, retained_row)
else {
continue;
};
@ -665,10 +655,7 @@ fn project_snapshot(
let cursor = if geometry.scroll_offset == 0 {
projection.cursor.and_then(|cursor| {
let retained_row = projection
.history
.len()
.saturating_add(cursor.row as usize);
let retained_row = projection.history.len().saturating_add(cursor.row as usize);
let row = viewport_row(&geometry, viewport_size.rows as usize, retained_row)?;
(cursor.col < viewport_size.cols).then(|| CellCoord::new(row as u32, cursor.col))
})
@ -697,10 +684,7 @@ fn is_default_blank(cell: &Cell) -> bool {
&& cell.attachment.is_none()
}
fn copy_selection_bytes(
rows: &[&TerminalRow],
selection: TerminalSelection,
) -> Option<Vec<u8>> {
fn copy_selection_bytes(rows: &[&TerminalRow], selection: TerminalSelection) -> Option<Vec<u8>> {
let (start, end) = normalized_selection(rows, selection)?;
let mut out = Vec::new();
for row_index in start.row..=end.row {
@ -753,9 +737,7 @@ mod tests {
}
fn projection(history: Vec<TerminalRow>, visible_rows: Vec<TerminalRow>) -> ScreenProjection {
let cols = visible_rows
.first()
.map_or(1, |row| row.cells.len() as u32);
let cols = visible_rows.first().map_or(1, |row| row.cells.len() as u32);
ScreenProjection {
size: CellSize::new(visible_rows.len() as u32, cols),
alternate_active: false,
@ -769,7 +751,10 @@ mod tests {
#[test]
fn tail_projection_pads_above_and_right_and_translates_cursor() {
let mut source = projection(Vec::new(), vec![row(1, 0, "abc", false), row(2, 0, "def", false)]);
let mut source = projection(
Vec::new(),
vec![row(1, 0, "abc", false), row(2, 0, "def", false)],
);
source.cursor = Some(CellCoord::new(1, 2));
let snapshot = project_snapshot(
BufferId::next(),
@ -780,7 +765,11 @@ mod tests {
TerminalProcessState::Running,
);
assert_eq!(snapshot.cells.len(), 20);
assert!(snapshot.cells[..10].iter().all(|cell| *cell == Cell::default()));
assert!(
snapshot.cells[..10]
.iter()
.all(|cell| *cell == Cell::default())
);
assert_eq!(snapshot.cells[10].glyph, Glyph::Char('a'));
assert_eq!(snapshot.cells[13], Cell::default());
assert_eq!(snapshot.cells[15].glyph, Glyph::Char('d'));

View File

@ -12,6 +12,7 @@
use std::ffi::OsString;
use std::io::{Read, Write};
use std::path::Path;
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{Duration, Instant};
@ -26,7 +27,8 @@ pub struct PmacsPty {
child: Box<dyn portable_pty::Child + Send + Sync>,
writer: Box<dyn Write + Send>,
_reader_thread: thread::JoinHandle<()>,
_master: Box<dyn portable_pty::MasterPty + Send>,
output: Arc<Mutex<Vec<u8>>>,
master: Box<dyn portable_pty::MasterPty + Send>,
}
impl PmacsPty {
@ -36,6 +38,27 @@ impl PmacsPty {
self.writer.flush()
}
/// Resize the real host PTY in terminal cells.
pub fn resize(&self, rows: u16, cols: u16) -> Result<(), String> {
self.master
.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})
.map_err(|error| error.to_string())
}
/// Snapshot all bytes emitted by pmacs to its host terminal.
#[must_use]
pub fn output(&self) -> Vec<u8> {
self.output
.lock()
.expect("PTY output mutex poisoned")
.clone()
}
/// Poll-wait for pmacs to exit, up to `timeout`. Returns the
/// exit status on success, `None` on timeout (and leaves the
/// child running for the caller to clean up).
@ -103,16 +126,19 @@ pub fn spawn_pmacs_in_pty(args: &[&str], envs: &[(&str, &Path)], rows: u16, cols
let writer = pair.master.take_writer().expect("take_writer");
let mut reader = pair.master.try_clone_reader().expect("try_clone_reader");
// Drain reader to /dev/null so pmacs's writes never block on a
// backed-up terminal output buffer. We don't need to inspect the
// bytes; the tests assert on exit status and side effects, not
// on screen content.
let output = Arc::new(Mutex::new(Vec::new()));
let captured = output.clone();
// Drain and retain reader bytes so terminal-mode restoration can be
// asserted without ever exposing pmacs to the test runner's own TTY.
let reader_thread = thread::spawn(move || {
let mut buf = [0u8; 4096];
loop {
match reader.read(&mut buf) {
Ok(0) | Err(_) => return,
Ok(_) => {}
Ok(read) => captured
.lock()
.expect("PTY output mutex poisoned")
.extend_from_slice(&buf[..read]),
}
}
});
@ -121,6 +147,7 @@ pub fn spawn_pmacs_in_pty(args: &[&str], envs: &[(&str, &Path)], rows: u16, cols
child,
writer,
_reader_thread: reader_thread,
_master: pair.master,
output,
master: pair.master,
}
}

View File

@ -0,0 +1,508 @@
//! Stage 2 terminal/TUI integration and real-host acceptance.
mod common;
use std::fs;
use std::path::Path;
use std::thread;
use std::time::{Duration, Instant};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use mlua::{AnyUserData, Table, Value};
use pmacs::cell::{CellCoord, CellSize, Glyph};
use pmacs::editor::EditorState;
use pmacs::lua_bindings::BufferIdLua;
use pmacs::protocol::FrontendId;
use pmacs::statusline::{
StatuslineEvaluationOutcome, StatuslineEvaluationTarget, evaluate_statusline,
};
use pmacs::terminal::{TerminalProcessState, TerminalSpec, TerminalViewKey};
use pmacs::window::WindowId;
use common::pty::{PmacsPty, spawn_pmacs_in_pty};
fn tick_until(
state: &mut EditorState,
timeout: Duration,
mut done: impl FnMut(&EditorState) -> bool,
) {
let deadline = Instant::now() + timeout;
loop {
state.tick_processes();
if done(state) {
return;
}
assert!(Instant::now() < deadline, "terminal condition timed out");
thread::sleep(Duration::from_millis(10));
}
}
fn lua_string(value: &str) -> String {
format!("{:?}", value)
}
fn snapshot_text(snapshot: &pmacs::terminal::TerminalSnapshot) -> String {
let mut text = String::new();
for cell in &snapshot.cells {
match &cell.glyph {
Glyph::Char(ch) => text.push(*ch),
Glyph::Cluster(bytes) => text.push_str(&String::from_utf8_lossy(bytes)),
Glyph::Continuation => {}
}
}
text
}
#[test]
fn lua_surface_is_strict_fresh_transactional_and_context_safe() {
let mut state = EditorState::new();
let command_lua = lua_string("/bin/sh");
let baseline_sessions = state.terminal_manager.borrow().len();
let baseline_buffers = state.core.borrow().registry.borrow().ids().len();
{
let lua = state.lua_host.lua();
let error = lua
.load(format!(
"return pmacs.terminal.open {{ command = {command_lua}, unknown = true }}"
))
.eval::<Value>()
.expect_err("unknown open field must fail");
assert!(error.to_string().contains("unknown field `unknown`"));
}
assert_eq!(state.terminal_manager.borrow().len(), baseline_sessions);
assert_eq!(
state.core.borrow().registry.borrow().ids().len(),
baseline_buffers
);
{
let lua = state.lua_host.lua();
let kind: String = lua
.load(format!(
r#"
TERM_BUFFER = pmacs.terminal.open {{
command = {command_lua},
args = {{ "-c", "printf 'copy-me\\n'; sleep 30" }},
rows = 4,
cols = 30,
}}
local first = pmacs.terminal.state(TERM_BUFFER)
first.process.kind = "poisoned"
first.injected = true
local second = pmacs.terminal.state(TERM_BUFFER)
assert(second.injected == nil)
assert(pmacs.terminal.resize == nil)
return second.process.kind
"#
))
.eval()
.expect("open terminal and read fresh state");
assert_eq!(kind, "running");
}
let buffer_id = {
let userdata: AnyUserData = state
.lua_host
.lua()
.globals()
.get("TERM_BUFFER")
.expect("terminal buffer global");
userdata
.borrow::<BufferIdLua>()
.expect("BufferId userdata")
.0
};
let buffer_name = state
.core
.borrow()
.registry
.borrow()
.get(buffer_id)
.expect("terminal identity buffer")
.name()
.to_owned();
assert_eq!(buffer_name, "*terminal:sh*");
tick_until(&mut state, Duration::from_secs(5), |state| {
state
.terminal_manager
.borrow()
.snapshot(buffer_id)
.is_some_and(|snapshot| snapshot_text(&snapshot).contains("copy-me"))
});
let frontend_id = FrontendId::LOCAL;
let window_id = state.core.borrow().active_window_id();
assert!(state.sync_terminal_layout(frontend_id, CellSize::new(8, 30)));
let snapshots = state.prepare_terminal_views(frontend_id, CellSize::new(8, 30));
assert!(snapshots.contains_key(&window_id));
let modeline = evaluate_statusline(
state.lua_host.lua(),
&state.core,
&state.statusline_registry,
StatuslineEvaluationTarget::Grid { frontend_id },
);
let StatuslineEvaluationOutcome::Ready(windows) = modeline.outcome else {
panic!("terminal statusline provider must evaluate successfully");
};
assert!(
windows
.iter()
.flat_map(|window| &window.right)
.any(|segment| segment.text.starts_with("TERM")),
"built-in terminal statusline provider must report live process state"
);
let snapshot = snapshots.get(&window_id).expect("active terminal snapshot");
let snapshot_text = snapshot_text(snapshot);
let start = snapshot_text
.find("copy-me")
.unwrap_or_else(|| panic!("copy probe missing from projected snapshot: {snapshot_text:?}"));
let cols = usize::try_from(snapshot.size.cols).expect("column count fits");
let row = u32::try_from(start / cols).expect("row fits");
let col = u32::try_from(start % cols).expect("column fits");
let view_key = TerminalViewKey::new(frontend_id, window_id, buffer_id);
{
let mut manager = state.terminal_manager.borrow_mut();
assert!(manager.begin_selection(view_key, snapshot.size, CellCoord::new(row, col)));
assert!(manager.finish_selection(view_key, snapshot.size, CellCoord::new(row, col + 7)));
}
state.dispatch_key(
frontend_id,
KeyEvent::new(KeyCode::Char('w'), KeyModifiers::ALT),
);
let (clipboard_frontend, clipboard) = state
.core
.borrow_mut()
.take_pending_clipboard()
.expect("terminal copy must queue host clipboard bytes");
assert_eq!(clipboard_frontend, frontend_id);
assert_eq!(clipboard, b"copy-me");
{
let lua = state.lua_host.lua();
let status: Table = lua
.load(format!(
r#"
local first = pmacs.terminal.view_state {{
frontend = 1, window = {}, buffer = TERM_BUFFER, active = true
}}
first.injected = true
local second = pmacs.terminal.view_state {{
frontend = 1, window = {}, buffer = TERM_BUFFER, active = true
}}
assert(second.injected == nil)
return second
"#,
window_id.raw(),
window_id.raw()
))
.eval()
.expect("fresh exact view state");
assert!(status.get::<bool>("at_bottom").expect("at_bottom"));
let (ok, error): (bool, String) = lua
.load(
r#"
local ok, err = pcall(function() pmacs.terminal.scroll(1) end)
return ok, tostring(err)
"#,
)
.eval()
.expect("pcall implicit scroll");
assert!(!ok);
assert!(error.contains("interactive frontend context"));
}
state
.terminal_manager
.borrow_mut()
.terminate(buffer_id, &mut state.process_supervisor.borrow_mut())
.expect("terminate terminal 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::Running))
});
state
.core
.borrow_mut()
.kill_buffer(buffer_id)
.expect("kill retained terminal buffer");
state.tick_processes();
assert_eq!(state.terminal_manager.borrow().len(), baseline_sessions);
assert_eq!(
state.core.borrow().registry.borrow().ids().len(),
baseline_buffers
);
state.process_supervisor.borrow_mut().shutdown();
let _error = state
.lua_host
.lua()
.load(format!(
"return pmacs.terminal.open {{ command = {command_lua} }}"
))
.eval::<Value>()
.expect_err("closed supervisor must make spawn fail transactionally");
assert_eq!(state.terminal_manager.borrow().len(), baseline_sessions);
assert_eq!(
state.core.borrow().registry.borrow().ids().len(),
baseline_buffers
);
}
#[test]
fn shared_screen_keeps_view_scroll_selection_and_controller_independent() {
let mut state = EditorState::new();
let mut spec = TerminalSpec::new("/bin/sh");
spec.args = vec![
"-c".into(),
"i=0; while [ $i -lt 30 ]; do printf 'row%02d\\n' \"$i\"; i=$((i+1)); done; sleep 30"
.into(),
];
spec.rows = 6;
spec.cols = 24;
let buffer_id = state
.terminal_manager
.borrow_mut()
.open(
spec,
&mut state.core.borrow_mut(),
&mut state.process_supervisor.borrow_mut(),
)
.expect("open terminal");
tick_until(&mut state, Duration::from_secs(5), |state| {
state
.terminal_manager
.borrow()
.snapshot(buffer_id)
.is_some_and(|snapshot| snapshot_text(&snapshot).contains("row29"))
});
let size = CellSize::new(4, 24);
let first = TerminalViewKey::new(FrontendId(11), WindowId::next(), buffer_id);
let second = TerminalViewKey::new(FrontendId(22), WindowId::next(), buffer_id);
{
let mut manager = state.terminal_manager.borrow_mut();
let first_tail = manager
.snapshot_for_view(first, size)
.expect("first snapshot");
let second_tail = manager
.snapshot_for_view(second, size)
.expect("second snapshot");
assert_eq!(first_tail.title, second_tail.title);
assert_eq!(first_tail.process, second_tail.process);
assert!(manager.scroll_lines(first, 3));
let first_status = manager.view_status(first).expect("first status");
let second_status = manager.view_status(second).expect("second status");
assert_eq!(first_status.scroll_offset, 3);
assert_eq!(second_status.scroll_offset, 0);
assert!(manager.begin_selection(first, size, CellCoord::new(0, 0)));
assert!(manager.finish_selection(first, size, CellCoord::new(0, 4)));
assert!(
manager
.view_status(first)
.expect("selected status")
.selection
);
assert!(
!manager
.view_status(second)
.expect("passive status")
.selection
);
assert!(
!manager
.copy_selection(first)
.expect("copied selection")
.is_empty()
);
assert!(manager.copy_selection(second).is_none());
assert!(manager.claim_controller(first));
assert!(manager.claim_controller(second));
assert_eq!(
manager.controller_view_for_frontend(FrontendId(11)),
None,
"one terminal session has one most-recent controller"
);
assert_eq!(
manager.controller_view_for_frontend(FrontendId(22)),
Some(second)
);
manager.detach_frontend(FrontendId(11));
assert!(manager.view_status(first).is_none());
assert!(manager.view_status(second).is_some());
assert!(
manager
.controller_view_for_frontend(FrontendId(11))
.is_none()
);
assert_eq!(
manager.controller_view_for_frontend(FrontendId(22)),
Some(second)
);
}
state
.terminal_manager
.borrow_mut()
.terminate(buffer_id, &mut state.process_supervisor.borrow_mut())
.expect("terminate terminal child");
}
fn wait_for_output(pty: &PmacsPty, needle: &[u8], timeout: Duration) {
let deadline = Instant::now() + timeout;
loop {
if pty
.output()
.windows(needle.len())
.any(|window| window == needle)
{
return;
}
if Instant::now() >= deadline {
let output = pty.output();
let start = output.len().saturating_sub(4_000);
panic!(
"host output never contained {:?}; tail: {}",
String::from_utf8_lossy(needle),
output[start..].escape_ascii()
);
}
thread::sleep(Duration::from_millis(20));
}
}
fn wait_for_file(path: &Path, timeout: Duration) -> Vec<u8> {
let deadline = Instant::now() + timeout;
loop {
if let Ok(bytes) = fs::read(path) {
return bytes;
}
assert!(
Instant::now() < deadline,
"file was not published: {}",
path.display()
);
thread::sleep(Duration::from_millis(20));
}
}
#[test]
fn real_tui_terminal_smoke_restores_host_after_output_input_resize_scroll_copy_and_bell() {
let temp = tempfile::TempDir::new().expect("tempdir");
let config_root = temp.path().join("config");
let config_dir = config_root.join("pmacs");
let state_root = temp.path().join("state");
let input_path = temp.path().join("child-input");
let size_path = temp.path().join("child-size");
fs::create_dir_all(&config_dir).expect("config dir");
let probe = format!(
concat!(
"import os\n",
"def read_exact(count):\n",
" data = b''\n",
" while len(data) < count:\n",
" data += os.read(0, count - len(data))\n",
" return data\n",
"def read_until(marker):\n",
" data = b''\n",
" while marker not in data:\n",
" data += os.read(0, 4096)\n",
"os.write(1, b'\\x1b[?1049h\\x1b[2J')\n",
"for i in range(20): os.write(1, b'alt%02d\\n' % i)\n",
"os.write(1, b'VTERM_ALT_READY')\n",
"read_until(b'ALT_GATE\\n')\n",
"os.write(1, b'\\x1b[?1049l')\n",
"for i in range(40): os.write(1, b'main%02d\\n' % i)\n",
"os.write(1, b'VTERM_MAIN_READY\\x07')\n",
"data = read_exact(18)\n",
"open({:?}, 'wb').write(data)\n",
"size = os.get_terminal_size(0)\n",
"open({:?}, 'w').write(f'{{size.lines}} {{size.columns}}\\n')\n"
),
input_path.to_str().expect("UTF-8 input path"),
size_path.to_str().expect("UTF-8 size path")
);
let init = format!(
r#"
local terminal_buffer = pmacs.terminal.open {{
command = "/bin/sh",
args = {{ "-c", "exec /usr/bin/python3 -c \"$1\"", "pmacs-vterm-probe", {} }},
rows = 10,
cols = 40,
scrollback_rows = 200,
}}
local ticks = 0
pmacs.hook.add("process.after-tick", function()
ticks = ticks + 1
if ticks == 120 then
pmacs.terminal.send(terminal_buffer, "ALT_GATE\n")
elseif ticks == 180 then
pmacs.terminal.send(terminal_buffer, "VTERM_INPUT_SMOKE\n")
end
end)
"#,
lua_string(&probe)
);
fs::write(config_dir.join("init.lua"), init).expect("write init.lua");
let mut pty = spawn_pmacs_in_pty(
&[],
&[
("XDG_CONFIG_HOME", config_root.as_path()),
("PMACS_STATE_HOME", state_root.as_path()),
("TERM", Path::new("xterm-256color")),
],
24,
80,
);
wait_for_output(&pty, b"VTERM_ALT_READY", Duration::from_secs(10));
pty.resize(30, 90).expect("resize host PTY");
thread::sleep(Duration::from_millis(150));
pty.write_input(b"\x1bv").expect("terminal page-up binding");
// Inject editor-owned scrolling, selection, and copy gestures while the
// real terminal child is active; focused tests pin their exact state.
pty.write_input(b"\x1b[<4;2;2M\x1b[<36;8;2M\x1b[<4;8;2m")
.expect("terminal selection drag");
pty.write_input(b"\x1bw").expect("copy selection binding");
wait_for_output(&pty, b"\x1b]52;c;", Duration::from_secs(5));
let input = wait_for_file(&input_path, Duration::from_secs(5));
assert_eq!(input, b"VTERM_INPUT_SMOKE\n");
pty.write_input(b"\x1b[200~PASTE_AFTER_EXIT\x1b[201~")
.expect("route a real host paste event");
let size = String::from_utf8(wait_for_file(&size_path, Duration::from_secs(5)))
.expect("UTF-8 stty size");
assert_eq!(size.trim(), "28 90", "child PTY must receive cell geometry");
thread::sleep(Duration::from_millis(200));
pty.write_input(b"\x03\x18\x03").expect("quit pmacs");
let status = pty
.wait_for_exit(Duration::from_secs(5))
.expect("pmacs should exit after terminal smoke");
assert!(status.success(), "pmacs exit status: {status:?}");
thread::sleep(Duration::from_millis(50));
let output = pty.output();
assert!(
output.windows(8).any(|window| window == b"\x1b[?1049h"),
"pmacs must enter its host alternate screen"
);
assert!(
output.windows(8).any(|window| window == b"\x1b[?1049l"),
"pmacs must restore the host main screen"
);
assert!(
output.contains(&0x07),
"one active-terminal BEL must reach the local host"
);
assert!(
output.windows(8).any(|window| window == b"\x1b[?2004l"),
"pmacs must disable host bracketed paste on exit"
);
}