From dc9225778a90574399006ba1e17197198302f2c0 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 21 Jul 2026 20:16:23 -0400 Subject: [PATCH] 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 --- docs/roadmap-2026-07.md | 15 +- docs/vterm-framing.md | 46 ++- src/daemon.rs | 131 +++++++- src/editor.rs | 143 ++++----- src/instance_render.rs | 8 +- src/lua_bindings/mod.rs | 37 ++- src/semantic_render.rs | 7 +- src/terminal/view.rs | 59 ++-- tests/common/pty.rs | 41 ++- tests/vterm_stage2_acceptance.rs | 508 +++++++++++++++++++++++++++++++ 10 files changed, 819 insertions(+), 176 deletions(-) create mode 100644 tests/vterm_stage2_acceptance.rs diff --git a/docs/roadmap-2026-07.md b/docs/roadmap-2026-07.md index 72f5e12..8c326f2 100644 --- a/docs/roadmap-2026-07.md +++ b/docs/roadmap-2026-07.md @@ -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 diff --git a/docs/vterm-framing.md b/docs/vterm-framing.md index 4c676c3..ffe16ec 100644 --- a/docs/vterm-framing.md +++ b/docs/vterm-framing.md @@ -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 diff --git a/src/daemon.rs b/src/daemon.rs index 0fb8cee..e47bff2 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -871,10 +871,8 @@ fn dispatcher_loop( let mut last_active_buffer_sent: HashMap = 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 = + 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, last_dispatch_idle_sent: &mut HashMap, last_active_buffer_sent: &mut HashMap, - terminal_bell_baselines: &mut HashMap< - FrontendId, - (crate::buffer::BufferId, u64), - >, + terminal_bell_baselines: &mut HashMap, 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 = + HashMap::new(); + let mut streams: HashMap = 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 diff --git a/src/editor.rs b/src/editor.rs index 8cd6984..7aa879e 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -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) -> 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) } diff --git a/src/instance_render.rs b/src/instance_render.rs index dc19f2d..3c8cf17 100644 --- a/src/instance_render.rs +++ b/src/instance_render.rs @@ -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. diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index cb25a00..d919811 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -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 mlua: } } -fn strict_terminal_usize( - value: Value, - field: &'static str, - default: usize, -) -> mlua::Result { +fn strict_terminal_usize(value: Value, field: &'static str, default: usize) -> mlua::Result { match value { Value::Nil => Ok(default), Value::Integer(value) => usize::try_from(value).map_err(|_| { diff --git a/src/semantic_render.rs b/src/semantic_render.rs index 35b6389..2d3ec0a 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -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 { .. }), diff --git a/src/terminal/view.rs b/src/terminal/view.rs index d05ac71..fb3bedf 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -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 { + pub fn controller_view_for_frontend(&self, frontend_id: FrontendId) -> Option { 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 { +fn clamp_or_clear(rows: &[&TerminalRow], anchor: LogicalCellAnchor) -> Option { 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> { +fn copy_selection_bytes(rows: &[&TerminalRow], selection: TerminalSelection) -> Option> { 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, visible_rows: Vec) -> 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')); diff --git a/tests/common/pty.rs b/tests/common/pty.rs index f500899..a1f16c8 100644 --- a/tests/common/pty.rs +++ b/tests/common/pty.rs @@ -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, writer: Box, _reader_thread: thread::JoinHandle<()>, - _master: Box, + output: Arc>>, + master: Box, } 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 { + 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, } } diff --git a/tests/vterm_stage2_acceptance.rs b/tests/vterm_stage2_acceptance.rs new file mode 100644 index 0000000..1ead710 --- /dev/null +++ b/tests/vterm_stage2_acceptance.rs @@ -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::() + .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::() + .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::("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::() + .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 { + 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" + ); +}