From 8702791de96baf7354bcca23cb351f9a07732a5b Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 22 Jul 2026 08:28:18 -0400 Subject: [PATCH] fix(vterm): honor terminal escape and view contracts Require the fixed C-c escape before editor-local terminal bindings, reject context-implicit Lua operations from document windows, and make controller replacement atomic per frontend. Borrow screen rows during view projection instead of deep-cloning retained history, preserve view anchors through zero-area layouts, and remove redundant detach paths. Add focused child-input coverage for unescaped bound keys and C-c C-c, plus controller, zero-area, context-error, and clone-free projection assertions. Co-authored-by: OpenAI Codex --- src/daemon.rs | 5 - src/editor.rs | 83 ++++++++-------- src/lua_bindings/mod.rs | 14 ++- src/terminal/screen.rs | 81 +++++++++++++++ src/terminal/session.rs | 6 ++ src/terminal/view.rs | 141 ++++++++++++++++---------- tests/vterm_stage2_acceptance.rs | 165 ++++++++++++++++++++++++++++++- 7 files changed, 389 insertions(+), 106 deletions(-) diff --git a/src/daemon.rs b/src/daemon.rs index 390fc11..f153625 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1302,7 +1302,6 @@ fn dispatcher_loop( last_active_buffer_sent.remove(fid); terminal_bell_baselines.remove(fid); editor.detach_frontend_input(*fid); - editor.terminal_manager.borrow_mut().detach_frontend(*fid); session_registry.unregister_session(*fid); editor .statusline_registry @@ -1743,10 +1742,6 @@ fn handle_dispatcher_event( last_active_buffer_sent.remove(&frontend_id); terminal_bell_baselines.remove(&frontend_id); editor.detach_frontend_input(frontend_id); - editor - .terminal_manager - .borrow_mut() - .detach_frontend(frontend_id); session_registry.unregister_session(frontend_id); editor .statusline_registry diff --git a/src/editor.rs b/src/editor.rs index 6d1d5f4..693a56e 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -825,17 +825,6 @@ 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 @@ -856,23 +845,21 @@ impl EditorState { self.claim_terminal_controller(view_key); return; } - 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); - } + 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; } } @@ -954,11 +941,6 @@ impl EditorState { fn claim_terminal_controller(&self, key: TerminalViewKey) { let mut manager = self.terminal_manager.borrow_mut(); - if let Some(previous) = manager.controller_view_for_frontend(key.frontend_id) - && previous != key - { - let _ = manager.release_controller(previous); - } let _ = manager.register_view(key); let _ = manager.claim_controller(key); } @@ -1070,6 +1052,7 @@ impl EditorState { .get(&key.window_id) .copied() else { + let _ = self.terminal_manager.borrow_mut().release_controller(key); return false; }; placement.content @@ -1115,19 +1098,33 @@ impl EditorState { let core = self.core.borrow(); let placements = window_placements(&core, frontend_id, term_size); let mut live = HashSet::new(); + if let Some(view) = core.views.get(&frontend_id) { + for window_id in view.layout.iter_ids() { + let Some(window) = core.windows.get(&window_id) else { + continue; + }; + if self.terminal_manager.borrow().is_terminal(window.buffer_id) { + live.insert(TerminalViewKey::new( + frontend_id, + window_id, + window.buffer_id, + )); + } + } + } let mut sizes = Vec::new(); for (window_id, placement) in placements { let Some(window) = core.windows.get(&window_id) else { continue; }; - if placement.content.size.rows == 0 || placement.content.size.cols == 0 { + let key = TerminalViewKey::new(frontend_id, window_id, window.buffer_id); + if !live.contains(&key) + || placement.content.size.rows == 0 + || placement.content.size.cols == 0 + { continue; } - 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)); - } + sizes.push((key, placement.content.size)); } (live, sizes) }; @@ -1155,10 +1152,14 @@ impl EditorState { { messages.push(InstanceMessage::Signal(InstanceSignal::Bell)); } - if let Some((target, bytes)) = self.core.borrow_mut().take_pending_clipboard() - && target == frontend_id - { - messages.push(InstanceMessage::Signal(InstanceSignal::Clipboard(bytes))); + if let Some((target, bytes)) = self.core.borrow_mut().take_pending_clipboard() { + debug_assert_eq!( + target, frontend_id, + "local signal drain received a non-local clipboard target" + ); + if target == frontend_id { + messages.push(InstanceMessage::Signal(InstanceSignal::Clipboard(bytes))); + } } messages } diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index 60b8b86..44b7707 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -8162,6 +8162,7 @@ fn terminal_command_frontend(lua: &Lua, core: &SharedCore) -> crate::protocol::F fn active_terminal_view_key( lua: &Lua, core: &SharedCore, + manager: &crate::terminal::SharedTerminalManager, operation: &str, ) -> mlua::Result { let frontend_id = lua @@ -8178,6 +8179,11 @@ fn active_terminal_view_key( "pmacs.terminal.{operation}: invoking frontend has no active window" )) })?; + if !manager.borrow().is_terminal(window.buffer_id) { + return Err(mlua::Error::external(format!( + "pmacs.terminal.{operation}: invoking frontend's active window is not a terminal" + ))); + } Ok(crate::terminal::TerminalViewKey::new( frontend_id, core.views @@ -8417,7 +8423,7 @@ fn install_terminal( } }); let core = terminal_shared_core(lua, "scroll")?; - let key = active_terminal_view_key(lua, &core, "scroll")?; + let key = active_terminal_view_key(lua, &core, &manager, "scroll")?; Ok(manager.borrow_mut().scroll_lines(key, lines)) })?, )?; @@ -8434,7 +8440,7 @@ fn install_terminal( ) })?; let core = terminal_shared_core(lua, "_scroll_page")?; - let key = active_terminal_view_key(lua, &core, "_scroll_page")?; + let key = active_terminal_view_key(lua, &core, &manager, "_scroll_page")?; Ok(manager.borrow_mut().scroll_page(key, direction)) })?, )?; @@ -8446,7 +8452,7 @@ fn install_terminal( "scroll_to_bottom", lua.create_function(move |lua, ()| { let core = terminal_shared_core(lua, "scroll_to_bottom")?; - let key = active_terminal_view_key(lua, &core, "scroll_to_bottom")?; + let key = active_terminal_view_key(lua, &core, &manager, "scroll_to_bottom")?; Ok(manager.borrow_mut().scroll_to_bottom(key)) })?, )?; @@ -8458,7 +8464,7 @@ fn install_terminal( "copy_selection", lua.create_function(move |lua, ()| { let core = terminal_shared_core(lua, "copy_selection")?; - let key = active_terminal_view_key(lua, &core, "copy_selection")?; + let key = active_terminal_view_key(lua, &core, &manager, "copy_selection")?; let Some(bytes) = manager.borrow_mut().copy_selection(key) else { return Ok(false); }; diff --git a/src/terminal/screen.rs b/src/terminal/screen.rs index 7d7cbdb..67c8bd7 100644 --- a/src/terminal/screen.rs +++ b/src/terminal/screen.rs @@ -116,6 +116,39 @@ pub struct ScreenProjection { pub generation: u64, } +/// Borrowed, publication-consistent row projection for in-process views. +#[allow(missing_docs)] +#[derive(Clone, Copy)] +pub(crate) struct BorrowedScreenProjection<'a> { + pub alternate_active: bool, + pub history_head: &'a [TerminalRow], + pub history_tail: &'a [TerminalRow], + pub visible_rows: &'a [TerminalRow], + pub cursor: Option, + pub title: Option<&'a str>, + pub generation: u64, +} + +impl BorrowedScreenProjection<'_> { + pub(crate) fn history_len(self) -> usize { + self.history_head.len() + self.history_tail.len() + } +} + +impl ScreenProjection { + pub(crate) fn as_borrowed(&self) -> BorrowedScreenProjection<'_> { + BorrowedScreenProjection { + alternate_active: self.alternate_active, + history_head: &self.history, + history_tail: &[], + visible_rows: &self.visible_rows, + cursor: self.cursor, + title: self.title.as_deref(), + generation: self.generation, + } + } +} + #[derive(Clone, Copy, Debug, Default)] struct Cursor { row: usize, @@ -538,6 +571,30 @@ impl TerminalScreen { } } + /// Borrow one publication-consistent row projection without cloning cells. + pub(crate) fn projection_ref(&self) -> BorrowedScreenProjection<'_> { + if self.modes.synchronized_output { + return self.published.as_borrowed(); + } + let (history_head, history_tail) = if self.alt_active { + (&[][..], &[][..]) + } else { + self.main.history.as_slices() + }; + BorrowedScreenProjection { + alternate_active: self.alt_active, + history_head, + history_tail, + visible_rows: &self.active().rows, + cursor: self + .modes + .cursor_visible + .then(|| CellCoord::new(self.cursor.row as u32, self.cursor.col as u32)), + title: self.title.as_deref(), + generation: self.generation, + } + } + pub fn modes(&self) -> TerminalModes { self.modes } @@ -1768,6 +1825,30 @@ mod tests { assert!(s.alternate_active()); } + #[test] + fn borrowed_projection_reuses_live_and_published_row_storage() { + let mut s = screen(2, 4); + s.apply_event(AnsiEvent::Text("main".into())); + s.apply_event(AnsiEvent::LineFeed); + s.apply_event(AnsiEvent::LineFeed); + let (live_history, _) = s.main.history.as_slices(); + let live_history_ptr = live_history.as_ptr(); + let live_visible_ptr = s.main.rows.as_ptr(); + let projection = s.projection_ref(); + assert_eq!(projection.history_head.as_ptr(), live_history_ptr); + assert_eq!(projection.visible_rows.as_ptr(), live_visible_ptr); + + s.apply_event(AnsiEvent::SetMode { + mode: TerminalMode::SynchronizedOutput, + enabled: true, + }); + let published_history_ptr = s.published.history.as_ptr(); + let published_visible_ptr = s.published.visible_rows.as_ptr(); + let projection = s.projection_ref(); + assert_eq!(projection.history_head.as_ptr(), published_history_ptr); + assert_eq!(projection.visible_rows.as_ptr(), published_visible_ptr); + } + #[test] fn main_reflows_soft_wrap_and_alt_clips() { let mut s = screen(2, 4); diff --git a/src/terminal/session.rs b/src/terminal/session.rs index c7761db..686214b 100644 --- a/src/terminal/session.rs +++ b/src/terminal/session.rs @@ -406,10 +406,16 @@ impl TerminalManager { } /// Give an exact registered view durable PTY control for its session. + /// + /// A frontend controls at most one session. Claiming another registered + /// view atomically releases that frontend's previous session first. pub fn claim_controller(&mut self, key: TerminalViewKey) -> bool { if !self.views.contains_key(&key) || !self.sessions.contains_key(&key.buffer_id) { return false; } + self.controllers.retain(|buffer_id, controller| { + controller.frontend_id != key.frontend_id || *buffer_id == key.buffer_id + }); self.controllers .insert(key.buffer_id, TerminalController::from_view(key)); true diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 1cb5117..7740cce 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -6,7 +6,7 @@ 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::screen::{BorrowedScreenProjection, TerminalModes, TerminalRow}; use crate::terminal::session::{TerminalManager, TerminalSelectionSpan, TerminalSnapshot}; use crate::terminal::{MAX_TERMINAL_COLS, MAX_TERMINAL_ROWS, MAX_TERMINAL_VISIBLE_CELLS}; use crate::window::WindowId; @@ -119,7 +119,7 @@ impl TerminalManager { return None; } let session = self.sessions.get(&key.buffer_id)?; - let projection = session.screen.projection(); + let projection = session.screen.projection_ref(); let pid = session.pid; let process = session.process.clone(); let bell_count = session.screen.bell_count(); @@ -129,12 +129,12 @@ impl TerminalManager { last_bell_count: bell_count, ..TerminalViewState::default() }); - normalize_state(state, &projection); + normalize_state(state, projection); state.viewport_size = Some(viewport_size); Some(project_snapshot( key.buffer_id, viewport_size, - &projection, + projection, state, pid, process, @@ -157,16 +157,16 @@ impl TerminalManager { let Some(session) = self.sessions.get(&key.buffer_id) else { return false; }; - let projection = session.screen.projection(); + let projection = session.screen.projection_ref(); let bell_count = session.screen.bell_count(); let state = self.views.entry(key).or_insert_with(|| TerminalViewState { alternate_active: Some(projection.alternate_active), last_bell_count: bell_count, ..TerminalViewState::default() }); - normalize_state(state, &projection); + normalize_state(state, projection); state.viewport_size = Some(viewport_size); - let rows = retained_rows(&projection); + let rows = retained_rows(projection); if rows.is_empty() { return false; } @@ -184,7 +184,7 @@ impl TerminalManager { state.top = if next == tail_start && state.selection.is_none() { None } else { - Some(row_lead(rows[next])) + Some(row_lead(rows.get(next).expect("bounded retained row"))) }; true } @@ -215,10 +215,10 @@ impl TerminalManager { /// Return fresh geometric status for one registered view. #[must_use] pub fn view_status(&mut self, key: TerminalViewKey) -> Option { - let projection = self.sessions.get(&key.buffer_id)?.screen.projection(); + let projection = self.sessions.get(&key.buffer_id)?.screen.projection_ref(); let state = self.views.get_mut(&key)?; - normalize_state(state, &projection); - let rows = retained_rows(&projection); + normalize_state(state, projection); + let rows = retained_rows(projection); let size = state.viewport_size?; let geometry = view_geometry(&rows, state, size.rows); Some(TerminalViewStatus { @@ -245,11 +245,11 @@ impl TerminalManager { #[must_use] pub fn copy_selection(&mut self, key: TerminalViewKey) -> Option> { let session = self.sessions.get(&key.buffer_id)?; - let projection = session.screen.projection(); + let projection = session.screen.projection_ref(); let state = self.views.get_mut(&key)?; - normalize_state(state, &projection); + normalize_state(state, projection); let selection = state.selection?; - let rows = retained_rows(&projection); + let rows = retained_rows(projection); copy_selection_bytes(&rows, selection) } @@ -263,15 +263,15 @@ impl TerminalManager { let Some(session) = self.sessions.get(&key.buffer_id) else { return false; }; - let projection = session.screen.projection(); + let projection = session.screen.projection_ref(); let bell_count = session.screen.bell_count(); let state = self.views.entry(key).or_insert_with(|| TerminalViewState { alternate_active: Some(projection.alternate_active), last_bell_count: bell_count, ..TerminalViewState::default() }); - normalize_state(state, &projection); - let rows = retained_rows(&projection); + normalize_state(state, projection); + let rows = retained_rows(projection); let geometry = view_geometry(&rows, state, viewport_size.rows); let Some(anchor) = anchor_at(&rows, &geometry, viewport_size, coord) else { state.selection = None; @@ -280,7 +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).map(row_lead); } state.selection = Some(TerminalSelection { anchor, @@ -300,15 +300,15 @@ impl TerminalManager { let Some(session) = self.sessions.get(&key.buffer_id) else { return false; }; - let projection = session.screen.projection(); + let projection = session.screen.projection_ref(); let Some(state) = self.views.get_mut(&key) else { return false; }; - normalize_state(state, &projection); + normalize_state(state, projection); if state.drag.is_none() { return false; } - let rows = retained_rows(&projection); + let rows = retained_rows(projection); let geometry = view_geometry(&rows, state, viewport_size.rows); let Some(head) = anchor_at(&rows, &geometry, viewport_size, coord) else { return false; @@ -412,6 +412,46 @@ struct ViewGeometry { scroll_offset: u32, } +#[derive(Clone, Copy)] +struct RetainedRows<'a> { + projection: BorrowedScreenProjection<'a>, +} + +impl<'a> RetainedRows<'a> { + fn len(self) -> usize { + self.projection.history_len() + self.projection.visible_rows.len() + } + + fn is_empty(self) -> bool { + self.len() == 0 + } + + fn get(self, index: usize) -> Option<&'a TerminalRow> { + let head_len = self.projection.history_head.len(); + if index < head_len { + return self.projection.history_head.get(index); + } + let index = index - head_len; + let tail_len = self.projection.history_tail.len(); + if index < tail_len { + return self.projection.history_tail.get(index); + } + self.projection.visible_rows.get(index - tail_len) + } + + fn first(self) -> Option<&'a TerminalRow> { + self.get(0) + } + + fn iter(self) -> impl Iterator { + self.projection + .history_head + .iter() + .chain(self.projection.history_tail) + .chain(self.projection.visible_rows) + } +} + fn valid_viewport(size: CellSize) -> bool { size.rows > 0 && size.cols > 0 @@ -420,12 +460,8 @@ fn valid_viewport(size: CellSize) -> bool { && size.area() as usize <= MAX_TERMINAL_VISIBLE_CELLS } -fn retained_rows(projection: &ScreenProjection) -> Vec<&TerminalRow> { - projection - .history - .iter() - .chain(projection.visible_rows.iter()) - .collect() +fn retained_rows(projection: BorrowedScreenProjection<'_>) -> RetainedRows<'_> { + RetainedRows { projection } } fn row_lead(row: &TerminalRow) -> LogicalCellAnchor { @@ -435,7 +471,7 @@ fn row_lead(row: &TerminalRow) -> LogicalCellAnchor { } } -fn resolve_anchor(rows: &[&TerminalRow], anchor: LogicalCellAnchor) -> Option { +fn resolve_anchor(rows: &RetainedRows<'_>, anchor: LogicalCellAnchor) -> Option { rows.iter().enumerate().find_map(|(row_index, row)| { if row.logical_line_id != anchor.logical_line_id { return None; @@ -461,15 +497,15 @@ fn canonical_col(row: &TerminalRow, mut col: usize) -> usize { col } -fn anchor_for(rows: &[&TerminalRow], resolved: ResolvedCell) -> LogicalCellAnchor { - let row = rows[resolved.row]; +fn anchor_for(rows: &RetainedRows<'_>, resolved: ResolvedCell) -> LogicalCellAnchor { + let row = rows.get(resolved.row).expect("resolved retained row"); LogicalCellAnchor { logical_line_id: row.logical_line_id, cell_offset: row.cell_offset.saturating_add(resolved.col as u32), } } -fn clamp_or_clear(rows: &[&TerminalRow], anchor: LogicalCellAnchor) -> Option { +fn clamp_or_clear(rows: &RetainedRows<'_>, anchor: LogicalCellAnchor) -> Option { if let Some(resolved) = resolve_anchor(rows, anchor) { return Some(anchor_for(rows, resolved)); } @@ -477,7 +513,7 @@ fn clamp_or_clear(rows: &[&TerminalRow], anchor: LogicalCellAnchor) -> Option) { if state .alternate_active .is_some_and(|active| active != projection.alternate_active) @@ -505,7 +541,7 @@ fn normalize_state(state: &mut TerminalViewState, projection: &ScreenProjection) } fn view_geometry( - rows: &[&TerminalRow], + rows: &RetainedRows<'_>, state: &TerminalViewState, viewport_rows: u32, ) -> ViewGeometry { @@ -550,7 +586,7 @@ fn viewport_row( } fn anchor_at( - rows: &[&TerminalRow], + rows: &RetainedRows<'_>, geometry: &ViewGeometry, viewport_size: CellSize, coord: CellCoord, @@ -565,7 +601,7 @@ fn anchor_at( let retained_row = geometry .start .saturating_add(viewport_row - geometry.top_padding); - let row = *rows.get(retained_row)?; + let row = rows.get(retained_row)?; if coord.col as usize >= row.cells.len() { return None; } @@ -577,7 +613,7 @@ fn anchor_at( } fn normalized_selection( - rows: &[&TerminalRow], + rows: &RetainedRows<'_>, selection: TerminalSelection, ) -> Option<(ResolvedCell, ResolvedCell)> { let mut start = resolve_anchor(rows, selection.anchor)?; @@ -599,7 +635,7 @@ fn glyph_width(row: &TerminalRow, col: usize) -> usize { fn project_snapshot( buffer_id: BufferId, viewport_size: CellSize, - projection: &ScreenProjection, + projection: BorrowedScreenProjection<'_>, state: &TerminalViewState, pid: u32, process: crate::terminal::session::TerminalProcessState, @@ -607,7 +643,7 @@ fn project_snapshot( let rows = retained_rows(projection); let geometry = view_geometry(&rows, state, viewport_size.rows); let mut cells = vec![Cell::default(); viewport_size.area() as usize]; - for (retained_row, &row) in rows.iter().enumerate().skip(geometry.start) { + for (retained_row, row) in rows.iter().enumerate().skip(geometry.start) { let Some(target_row) = viewport_row(&geometry, viewport_size.rows as usize, retained_row) else { continue; @@ -622,7 +658,7 @@ fn project_snapshot( .and_then(|selection| normalized_selection(&rows, selection)) .map_or_else(Vec::new, |(start, end)| { let mut spans = Vec::new(); - for (retained_row, &row) in rows.iter().enumerate().take(end.row + 1).skip(start.row) { + for (retained_row, row) in rows.iter().enumerate().take(end.row + 1).skip(start.row) { let Some(target_row) = viewport_row(&geometry, viewport_size.rows as usize, retained_row) else { @@ -652,7 +688,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)) }) @@ -665,7 +701,7 @@ fn project_snapshot( size: viewport_size, cells, cursor, - title: projection.title.clone(), + title: projection.title.map(str::to_owned), screen_generation: projection.generation, selection, scroll_offset: geometry.scroll_offset, @@ -681,10 +717,10 @@ 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: &RetainedRows<'_>, selection: TerminalSelection) -> Option> { let (start, end) = normalized_selection(rows, selection)?; let mut out = Vec::new(); - for (row_index, &row) in rows.iter().enumerate().take(end.row + 1).skip(start.row) { + for (row_index, row) in rows.iter().enumerate().take(end.row + 1).skip(start.row) { let from = if row_index == start.row { start.col } else { 0 }; let mut to = if row_index == end.row { end.col.saturating_add(glyph_width(row, end.col)) @@ -714,6 +750,7 @@ fn copy_selection_bytes(rows: &[&TerminalRow], selection: TerminalSelection) -> #[cfg(test)] mod tests { use super::*; + use crate::terminal::screen::ScreenProjection; use crate::terminal::session::TerminalProcessState; fn row(id: u64, offset: u32, text: &str, soft_wrapped: bool) -> TerminalRow { @@ -755,7 +792,7 @@ mod tests { let snapshot = project_snapshot( BufferId::next(), CellSize::new(4, 5), - &source, + source.as_borrowed(), &TerminalViewState::default(), 42, TerminalProcessState::Running, @@ -800,7 +837,7 @@ mod tests { let snapshot = project_snapshot( BufferId::next(), CellSize::new(3, 3), - &source, + source.as_borrowed(), &state, 1, TerminalProcessState::Running, @@ -816,9 +853,10 @@ mod tests { row(1, 3, "cd ", false), row(2, 0, "e ", false), ]; - let refs: Vec<&TerminalRow> = rows.iter().collect(); + let source = projection(Vec::new(), rows.into()); + let retained = retained_rows(source.as_borrowed()); let bytes = copy_selection_bytes( - &refs, + &retained, TerminalSelection { anchor: LogicalCellAnchor { logical_line_id: 1, @@ -854,9 +892,10 @@ mod tests { cell_offset: 0, soft_wrapped: false, }; - let refs = vec![&wide]; + let source = projection(Vec::new(), vec![wide]); + let retained = retained_rows(source.as_borrowed()); let continuation = resolve_anchor( - &refs, + &retained, LogicalCellAnchor { logical_line_id: 9, cell_offset: 1, @@ -865,7 +904,7 @@ mod tests { .expect("continuation resolves"); assert_eq!(continuation.col, 0); let bytes = copy_selection_bytes( - &refs, + &retained, TerminalSelection { anchor: LogicalCellAnchor { logical_line_id: 9, @@ -910,7 +949,7 @@ mod tests { alternate_active: Some(false), ..TerminalViewState::default() }; - normalize_state(&mut state, &source); + normalize_state(&mut state, source.as_borrowed()); assert_eq!(state.top, None); assert_eq!(state.selection, None); assert_eq!(state.alternate_active, Some(true)); diff --git a/tests/vterm_stage2_acceptance.rs b/tests/vterm_stage2_acceptance.rs index c777010..86d1201 100644 --- a/tests/vterm_stage2_acceptance.rs +++ b/tests/vterm_stage2_acceptance.rs @@ -61,6 +61,7 @@ fn snapshot_text(snapshot: &pmacs::terminal::TerminalSnapshot) -> String { fn lua_surface_is_strict_fresh_transactional_and_context_safe() { let mut state = EditorState::new(); let command_lua = lua_string("/bin/sh"); + let baseline_buffer_id = state.core.borrow().active_buffer_id(); let baseline_sessions = state.terminal_manager.borrow().len(); let baseline_buffers = state.core.borrow().registry.borrow().ids().len(); @@ -170,6 +171,29 @@ fn lua_surface_is_strict_fresh_transactional_and_context_safe() { 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))); } + assert!( + state + .prepare_terminal_views(frontend_id, CellSize::new(1, 1)) + .is_empty(), + "zero-area terminal placements paint no snapshot" + ); + assert!( + state + .terminal_manager + .borrow() + .view_state(view_key) + .is_some_and(|view| view.selection.is_some()), + "a transient zero-area placement must retain existing view anchors" + ); + assert!( + state + .prepare_terminal_views(frontend_id, CellSize::new(8, 30)) + .contains_key(&window_id) + ); + state.dispatch_key( + frontend_id, + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + ); state.dispatch_key( frontend_id, KeyEvent::new(KeyCode::Char('w'), KeyModifiers::ALT), @@ -216,6 +240,46 @@ fn lua_surface_is_strict_fresh_transactional_and_context_safe() { assert!(error.contains("interactive frontend context")); } + state + .lua_host + .lua() + .load( + r#" + pmacs.command.define { + name = "test.terminal-context", + description = "Exercise context-implicit terminal failure", + fn = function() pmacs.terminal.scroll(1) end, + } + pmacs.keymap.bind { + scope = "global", sequence = "", command = "test.terminal-context" + } + "#, + ) + .exec() + .expect("install context failure probe"); + state + .core + .borrow_mut() + .switch_active_buffer_for(frontend_id, baseline_buffer_id) + .expect("switch to document buffer"); + state.dispatch_key( + frontend_id, + KeyEvent::new(KeyCode::F(12), KeyModifiers::NONE), + ); + assert!( + state + .core + .borrow() + .status + .contains("active window is not a terminal"), + "context-implicit terminal command must raise a named Lua error" + ); + state + .core + .borrow_mut() + .switch_active_buffer_for(frontend_id, buffer_id) + .expect("restore terminal buffer"); + state .terminal_manager .borrow_mut() @@ -257,6 +321,7 @@ fn lua_surface_is_strict_fresh_transactional_and_context_safe() { } #[test] +#[allow(clippy::too_many_lines, reason = "shared view and controller scenario")] fn shared_screen_keeps_view_scroll_selection_and_controller_independent() { let mut state = EditorState::new(); let mut spec = TerminalSpec::new("/bin/sh"); @@ -283,10 +348,24 @@ fn shared_screen_keeps_view_scroll_selection_and_controller_independent() { .snapshot(buffer_id) .is_some_and(|snapshot| snapshot_text(&snapshot).contains("row29")) }); + let mut replacement_spec = TerminalSpec::new("/bin/sh"); + replacement_spec.args = vec!["-c".into(), "sleep 30".into()]; + replacement_spec.rows = 4; + replacement_spec.cols = 24; + let replacement_buffer = state + .terminal_manager + .borrow_mut() + .open( + replacement_spec, + &mut state.core.borrow_mut(), + &mut state.process_supervisor.borrow_mut(), + ) + .expect("open replacement terminal"); 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 replacement = TerminalViewKey::new(FrontendId(11), WindowId::next(), replacement_buffer); { let mut manager = state.terminal_manager.borrow_mut(); let first_tail = manager @@ -295,6 +374,9 @@ fn shared_screen_keeps_view_scroll_selection_and_controller_independent() { let second_tail = manager .snapshot_for_view(second, size) .expect("second snapshot"); + manager + .snapshot_for_view(replacement, size) + .expect("replacement snapshot"); assert_eq!(first_tail.title, second_tail.title); assert_eq!(first_tail.process, second_tail.process); @@ -327,12 +409,14 @@ fn shared_screen_keeps_view_scroll_selection_and_controller_independent() { assert!(manager.copy_selection(second).is_none()); assert!(manager.claim_controller(first)); - assert!(manager.claim_controller(second)); + assert!(manager.claim_controller(replacement)); assert_eq!( manager.controller_view_for_frontend(FrontendId(11)), - None, - "one terminal session has one most-recent controller" + Some(replacement), + "claiming another session atomically replaces a frontend's controller" ); + assert!(manager.controller(buffer_id).is_none()); + assert!(manager.claim_controller(second)); assert_eq!( manager.controller_view_for_frontend(FrontendId(22)), Some(second) @@ -356,6 +440,75 @@ fn shared_screen_keeps_view_scroll_selection_and_controller_independent() { .borrow_mut() .terminate(buffer_id, &mut state.process_supervisor.borrow_mut()) .expect("terminate terminal child"); + state + .terminal_manager + .borrow_mut() + .terminate( + replacement_buffer, + &mut state.process_supervisor.borrow_mut(), + ) + .expect("terminate replacement terminal child"); +} + +#[test] +fn terminal_escape_gates_local_bindings_and_double_escape_sends_interrupt() { + let temp = tempfile::TempDir::new().expect("tempdir"); + let ready_path = temp.path().join("ready"); + let input_path = temp.path().join("input"); + let probe = format!( + concat!( + "import os, tty\n", + "tty.setraw(0)\n", + "open({:?}, 'wb').write(b'1')\n", + "data = b''\n", + "while len(data) < 5: data += os.read(0, 5 - len(data))\n", + "open({:?}, 'wb').write(data)\n", + ), + ready_path.to_str().expect("UTF-8 ready path"), + input_path.to_str().expect("UTF-8 input path") + ); + let mut state = EditorState::new(); + state + .lua_host + .lua() + .load(format!( + r#" + return pmacs.terminal.open {{ + command = "/usr/bin/python3", + args = {{ "-c", {} }}, + rows = 4, + cols = 20, + }} + "#, + lua_string(&probe) + )) + .eval::() + .expect("open raw input probe"); + assert_eq!(wait_for_file(&ready_path, Duration::from_secs(5)), b"1"); + + let frontend_id = FrontendId::LOCAL; + state.dispatch_key( + frontend_id, + KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT), + ); + state.dispatch_key( + frontend_id, + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + ); + state.dispatch_key( + frontend_id, + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + ); + state.dispatch_key( + frontend_id, + KeyEvent::new(KeyCode::Char('w'), KeyModifiers::ALT), + ); + + assert_eq!( + wait_for_file(&input_path, Duration::from_secs(5)), + b"\x1bv\x03\x1bw", + "unescaped local bindings reach the child; C-c C-c sends one literal interrupt" + ); } fn wait_for_output(pty: &PmacsPty, needle: &[u8], timeout: Duration) { @@ -471,12 +624,14 @@ fn real_tui_terminal_smoke_restores_host_after_output_input_resize_scroll_copy_a pty.resize(30, 90).expect("resize host PTY"); thread::sleep(Duration::from_millis(150)); - pty.write_input(b"\x1bv").expect("terminal page-up binding"); + pty.write_input(b"\x03\x1bv") + .expect("escaped 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"); + pty.write_input(b"\x03\x1bw") + .expect("escaped 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");