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 per-window TUI composition, a pure built-in LSP segment, dynamic modeline
faces, and semantic/GPU transport through protocol v18. 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, - **Compile-mode landed** in #113: line-oriented PTY/ANSI output,
error-regex navigation, and `M-x compile`. error-regex navigation, and `M-x compile`.
- **Vterm Stage 1 terminal core** is implemented, two review rounds are - **Vterm Stage 1 terminal core landed** in #126: compatibility parser
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 profiles, bounded VT screen/scrollback/reflow state, IND/NEL/RI, input
encoders, internal `TerminalManager`, read-only identity buffers, process encoders, internal `TerminalManager`, read-only identity buffers, process
lifecycle, renderer-safe control-free cells, and headless real-PTY lifecycle, renderer-safe control-free cells, and headless real-PTY
acceptance. It intentionally adds no interactive Lua command or frontend acceptance.
rendering. - **Vterm Stage 2 TUI** is implemented on `vterm-tui`: terminal-window
- **Vterm Stage 2 TUI** starts only after Stage 1 merges: terminal-window composition, input/resize, per-context scroll/selection/copy, authenticated
composition, input/resize, per-context scroll/selection/copy, and the Lua frontend ownership, BEL/clipboard drainage, and the strict Lua surface.
surface.
- **Vterm Stage 3 protocol/GPU** starts only after Stage 2 merges: additive - **Vterm Stage 3 protocol/GPU** starts only after Stage 2 merges: additive
protocol v19 complete frames, authenticated daemon routing, and native GPU protocol v19 complete frames, authenticated daemon routing, and native GPU
cell rendering. Its framing must resolve the current 16 MiB transport cap's 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) # Vterm — framing (Arc 5 stage 2, three-PR delivery)
**Revision 7 — 2026-07-21. Status: Stage 1 landed on `main` as PR #126 **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 at merge `643d1e1`; Stage 2 is implemented on branch `vterm-tui` and Stage 3
is complete and the contract is ready for implementation.** is not implemented.**
Revision 7 closes the final three precision findings: `at_bottom` is geometric Revision 7 closes the final three precision findings: `at_bottom` is geometric
and distinct from live-tail following; the fixed `C-c` transport escape 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 exercises key/paste, resize, scroll/select/copy, BEL, and clean exit, then
proves host raw/alternate-screen state is restored. 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 ### Stage 3 — GPU/protocol
28. Protocol v19 appends all new variants after v18 pins; v18 grid traffic 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(); let mut last_active_buffer_sent: HashMap<FrontendId, crate::buffer::BufferId> = HashMap::new();
// Active-terminal BEL delivery baseline. Switching away forgets the // Active-terminal BEL delivery baseline. Switching away forgets the
// terminal so historical bells are never replayed on later activation. // terminal so historical bells are never replayed on later activation.
let mut terminal_bell_baselines: HashMap< let mut terminal_bell_baselines: HashMap<FrontendId, (crate::buffer::BufferId, u64)> =
FrontendId, HashMap::new();
(crate::buffer::BufferId, u64),
> = HashMap::new();
let mut session_registry = SessionRegistry::new(); let mut session_registry = SessionRegistry::new();
// T M10.11 Q8 — jitter PRNG, seeded once so the // T M10.11 Q8 — jitter PRNG, seeded once so the
// convergence-under-jitter scenario is deterministically // convergence-under-jitter scenario is deterministically
@ -1077,12 +1075,7 @@ fn dispatcher_loop(
let render_state = render_states let render_state = render_states
.get_mut(fid) .get_mut(fid)
.expect("render_state present for attached grid fid"); .expect("render_state present for attached grid fid");
render_state.render_frame( render_state.render_frame(editor, *fid, &terminal_snapshots, &other_presences)
editor,
*fid,
&terminal_snapshots,
&other_presences,
)
}; };
// T M10.6 per-frontend presence sweep. The snapshot is // T M10.6 per-frontend presence sweep. The snapshot is
@ -1537,10 +1530,7 @@ fn handle_dispatcher_event(
term_sizes: &mut HashMap<FrontendId, CellSize>, term_sizes: &mut HashMap<FrontendId, CellSize>,
last_dispatch_idle_sent: &mut HashMap<FrontendId, bool>, last_dispatch_idle_sent: &mut HashMap<FrontendId, bool>,
last_active_buffer_sent: &mut HashMap<FrontendId, crate::buffer::BufferId>, last_active_buffer_sent: &mut HashMap<FrontendId, crate::buffer::BufferId>,
terminal_bell_baselines: &mut HashMap< terminal_bell_baselines: &mut HashMap<FrontendId, (crate::buffer::BufferId, u64)>,
FrontendId,
(crate::buffer::BufferId, u64),
>,
session_registry: &mut SessionRegistry, session_registry: &mut SessionRegistry,
) { ) {
match event { 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 /// Kill ring Q#KR10a — the unified paste route trusts only the
/// dispatcher's authenticated source. A forged payload id must not /// dispatcher's authenticated source. A forged payload id must not
/// paste into another frontend's active window, and the paste /// paste into another frontend's active window, and the paste

View File

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

View File

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

View File

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

View File

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

View File

@ -12,6 +12,7 @@
use std::ffi::OsString; use std::ffi::OsString;
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::path::Path; use std::path::Path;
use std::sync::{Arc, Mutex};
use std::thread; use std::thread;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@ -26,7 +27,8 @@ pub struct PmacsPty {
child: Box<dyn portable_pty::Child + Send + Sync>, child: Box<dyn portable_pty::Child + Send + Sync>,
writer: Box<dyn Write + Send>, writer: Box<dyn Write + Send>,
_reader_thread: thread::JoinHandle<()>, _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 { impl PmacsPty {
@ -36,6 +38,27 @@ impl PmacsPty {
self.writer.flush() 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 /// Poll-wait for pmacs to exit, up to `timeout`. Returns the
/// exit status on success, `None` on timeout (and leaves the /// exit status on success, `None` on timeout (and leaves the
/// child running for the caller to clean up). /// 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 writer = pair.master.take_writer().expect("take_writer");
let mut reader = pair.master.try_clone_reader().expect("try_clone_reader"); 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 let output = Arc::new(Mutex::new(Vec::new()));
// backed-up terminal output buffer. We don't need to inspect the let captured = output.clone();
// bytes; the tests assert on exit status and side effects, not // Drain and retain reader bytes so terminal-mode restoration can be
// on screen content. // asserted without ever exposing pmacs to the test runner's own TTY.
let reader_thread = thread::spawn(move || { let reader_thread = thread::spawn(move || {
let mut buf = [0u8; 4096]; let mut buf = [0u8; 4096];
loop { loop {
match reader.read(&mut buf) { match reader.read(&mut buf) {
Ok(0) | Err(_) => return, 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, child,
writer, writer,
_reader_thread: reader_thread, _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"
);
}