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 <codex@openai.com>
This commit is contained in:
Levi Neuwirth 2026-07-22 08:28:18 -04:00
parent 6725e9a598
commit 8702791de9
7 changed files with 389 additions and 106 deletions

View File

@ -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

View File

@ -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
}

View File

@ -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<crate::terminal::TerminalViewKey> {
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);
};

View File

@ -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<CellCoord>,
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);

View File

@ -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

View File

@ -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<TerminalViewStatus> {
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<Vec<u8>> {
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<Item = &'a TerminalRow> {
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<ResolvedCell> {
fn resolve_anchor(rows: &RetainedRows<'_>, anchor: LogicalCellAnchor) -> Option<ResolvedCell> {
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<LogicalCellAnchor> {
fn clamp_or_clear(rows: &RetainedRows<'_>, anchor: LogicalCellAnchor) -> Option<LogicalCellAnchor> {
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<Lo
(anchor.logical_line_id < first.logical_line_id).then(|| row_lead(first))
}
fn normalize_state(state: &mut TerminalViewState, projection: &ScreenProjection) {
fn normalize_state(state: &mut TerminalViewState, projection: BorrowedScreenProjection<'_>) {
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<Vec<u8>> {
fn copy_selection_bytes(rows: &RetainedRows<'_>, selection: TerminalSelection) -> Option<Vec<u8>> {
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));

View File

@ -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 = "<f12>", 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::<AnyUserData>()
.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");