fix(vterm): harden integrated Stage 2 behavior

Resolve post-main integration drift in authenticated routing, terminal view projection, Lua installation, and inherited acceptance callers. Preserve the terminal statusline provider alongside the landed Themes provider and record the final Stage 2 gate evidence.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Levi Neuwirth 2026-07-21 21:38:08 -04:00
parent 0ddff24589
commit da8f6aeae4
9 changed files with 103 additions and 56 deletions

View File

@ -1,9 +1,9 @@
# Agent handoff — cross-machine continuity # Agent handoff — cross-machine continuity
**Last updated: 2026-07-21, after the config registry (#127) and Vterm **Last updated: 2026-07-21, after the config registry (#127), Vterm
Stage 1 terminal core (#126) both landed on `main`, atop completed Stage 1 terminal core (#126), and handoff refresh (#128) landed on
Themes Arc 4 (#120/#124/#125). Vterm Stages 2 and 3 are not `main`, atop completed Themes Arc 4 (#120/#124/#125). Vterm Stage 2 is
implemented.** implemented on `vterm-tui`; Stage 3 is not implemented.**
This file is the This file is the
bridge between development machines. If you are an agent reading bridge between development machines. If you are an agent reading
this on a fresh clone: this document plus the `docs/*-framing.md` this on a fresh clone: this document plus the `docs/*-framing.md`
@ -17,7 +17,8 @@ commands, read `docs/active-work.md` immediately after this file.
## 1. Where the project stands (2026-07-21) ## 1. Where the project stands (2026-07-21)
- `main` @ `2e37c04` (config registry #127), protocol **v18** - `main` @ `f1a2f75` (handoff refresh #128 atop config registry #127),
protocol **v18**
(`SUPPORTED=[6..18]`; v16 = `ThemeFacts`, v17 = `FontFacts`, v18 = (`SUPPORTED=[6..18]`; v16 = `ThemeFacts`, v17 = `FontFacts`, v18 =
`StatuslineSegments`). `StatuslineSegments`).
- **Config registry LANDED — #127** (`docs/config-registry-framing.md` - **Config registry LANDED — #127** (`docs/config-registry-framing.md`
@ -211,7 +212,7 @@ commands, read `docs/active-work.md` immediately after this file.
resize. Review round 2 rejects C0/C1 controls before they enter screen resize. Review round 2 rejects C0/C1 controls before they enter screen
cells, preserves the released button code in SGR mouse reports, removes cells, preserves the released button code in SGR mouse reports, removes
dead screen paths, and clears stale round-trip state during prune. Stage 2 dead screen paths, and clears stale round-trip state during prune. Stage 2
must uniquify default terminal buffer names. now uniquifies default terminal buffer names transactionally.
- Exact CUU/CUD and out-of-range DECSTBM clamping, combining across controls, - Exact CUU/CUD and out-of-range DECSTBM clamping, combining across controls,
xterm alternate-screen details, legacy non-SGR mouse, printable ASCII and xterm alternate-screen details, legacy non-SGR mouse, printable ASCII and
CSI-dispatch allocation fast paths, and scrollback-cap naming are explicit CSI-dispatch allocation fast paths, and scrollback-cap naming are explicit
@ -225,12 +226,37 @@ commands, read `docs/active-work.md` immediately after this file.
is a clean behavioral bite. The parser dispatch has its independent clean is a clean behavioral bite. The parser dispatch has its independent clean
behavioral bite; the original `main`/crate-root bite remains explicitly behavioral bite; the original `main`/crate-root bite remains explicitly
weaker compile-time API evidence. weaker compile-time API evidence.
- Stage 2 reviews require a durable focus/input resize owner, owning - Stage 3 owns `pmacs-gpu/src/attach.rs`, authenticated source routing,
`FrontendId` for the global `C-c` continuation, and local clipboard/BEL protocol-owned wire types/limits, and a deliberate complete-frame limit
signal drainage. Stage 3 additionally owns `pmacs-gpu/src/attach.rs`, decision: 16 MiB is insufficient; use a measured legal-worst cap or
authenticated source routing, protocol-owned wire types/limits, and a aggregate bound, never silent chunking.
deliberate complete-frame limit decision: 16 MiB is insufficient; use a - **Stage 2 TUI is implemented on `vterm-tui`** (`docs/vterm-framing.md`
measured legal-worst cap or aggregate bound, never silent chunking. Revision 7, criteria 1527). `TerminalViewKey` keys per-frontend/window
projection state over one shared process/screen; logical row anchors retain
scroll/selection through reflow. A most-recent authenticated controller
owns input and cell resize, with release on focus/switch/kill/detach.
- The strict `pmacs.terminal` Lua surface owns open/state/view/send/terminate
and context-implicit scroll/copy commands. Buffer-local terminal keymaps
resolve before raw child transport; fixed `C-c` remains the terminal escape
and owns its continuation per frontend. Copy drains through the acting
frontend's clipboard path; active BELs drain once locally and per daemon
frontend, while historical/passive bells are baseline-suppressed.
- TUI composition paints owned terminal cells/styles only inside each
window's content rectangle, suppresses document overlays, and keeps sibling
splits independent. Daemon key/mouse/paste/focus/resize/detach routing uses
the authenticated connection source rather than client-claimed IDs.
`builtin/runtime/terminal.lua` provides the terminal command, view commands,
and pure `ui.modeline.terminal` process/scroll segment.
- `tests/vterm_stage2_acceptance.rs` maps Lua transactionality, shared-view
isolation, clipboard/modeline behavior, and a hermetic real `/bin/sh` TUI
PTY smoke. Stage 2 changes no wire schema or GPU renderer; protocol remains
v18 until Stage 3.
- Integrated Stage 2 final gate: `cargo fmt --check`; strict workspace
Clippy; 1,742 default + 1,918 CRDT library tests (3 ignored each);
Stage 1 acceptance 9 default + 10 CRDT; Stage 2 acceptance 3 default +
3 CRDT; statusline acceptance 7 default + 8 CRDT; M4 114 passed
(3 ignored, 1 filtered); required GPU 109; workspace 2,869 passed
across 81 suites (19 ignored, 1 filtered); `git diff --check` clean.
- **PARKED: kill-ring browser + persistence.** Revision 2 framing is - **PARKED: kill-ring browser + persistence.** Revision 2 framing is
preserved on branch `kill-ring-browser`, but its `0efb5cd` scout is stale preserved on branch `kill-ring-browser`, but its `0efb5cd` scout is stale
and must be repeated before implementation. No PR or implementation is and must be repeated before implementation. No PR or implementation is

View File

@ -1406,7 +1406,6 @@ fn dispatcher_loop(
/// initial-full-grid analogue — it emits nothing until the frontend /// initial-full-grid analogue — it emits nothing until the frontend
/// declares a viewport); every other session keeps the M5.3 /// declares a viewport); every other session keeps the M5.3
/// force-full-grid grid path. /// force-full-grid grid path.
#[allow(clippy::too_many_arguments)]
fn take_pending_terminal_bell( fn take_pending_terminal_bell(
editor: &EditorState, editor: &EditorState,
frontend_id: FrontendId, frontend_id: FrontendId,
@ -1446,6 +1445,10 @@ fn take_pending_terminal_bell(
} }
} }
#[allow(
clippy::too_many_arguments,
reason = "one session bootstrap transaction"
)]
fn handle_session_established( fn handle_session_established(
editor: &mut EditorState, editor: &mut EditorState,
render_states: &mut HashMap<FrontendId, RenderState>, render_states: &mut HashMap<FrontendId, RenderState>,

View File

@ -301,7 +301,7 @@ impl EditorState {
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 = let terminal_manager =
crate::lua_bindings::make_terminal_manager(lua_host.lua(), process_supervisor.clone()) crate::lua_bindings::make_terminal_manager(lua_host.lua(), &process_supervisor)
.expect("install pmacs.terminal"); .expect("install pmacs.terminal");
lua_host lua_host
.eval( .eval(
@ -759,6 +759,10 @@ impl EditorState {
/// (`pmacs.frontend.id()`). Sets [`EditorCore::active_frontend`] /// (`pmacs.frontend.id()`). Sets [`EditorCore::active_frontend`]
/// before any command body runs, so observers always see a fresh /// before any command body runs, so observers always see a fresh
/// value. /// value.
#[allow(
clippy::too_many_lines,
reason = "single input-precedence state machine"
)]
pub fn dispatch_key(&mut self, frontend_id: FrontendId, key: KeyEvent) { pub fn dispatch_key(&mut self, frontend_id: FrontendId, key: KeyEvent) {
if !matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) { if !matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) {
return; return;
@ -1579,6 +1583,10 @@ impl EditorState {
/// (the click neither activates the window nor positions the /// (the click neither activates the window nor positions the
/// cursor; that gesture is reserved for future binding to /// cursor; that gesture is reserved for future binding to
/// "switch to this window" without disturbing buffer state). /// "switch to this window" without disturbing buffer state).
#[allow(
clippy::too_many_lines,
reason = "shared document/terminal mouse router"
)]
pub fn dispatch_mouse( pub fn dispatch_mouse(
&mut self, &mut self,
frontend_id: FrontendId, frontend_id: FrontendId,
@ -1722,9 +1730,7 @@ impl EditorState {
) { ) {
use crossterm::event::{MouseButton, MouseEventKind}; use crossterm::event::{MouseButton, MouseEventKind};
let Some(kind) = terminal_mouse_kind(event.kind) else { let kind = terminal_mouse_kind(event.kind);
return;
};
let modifiers = terminal_modifiers(event.modifiers); let modifiers = terminal_modifiers(event.modifiers);
let shift = modifiers.contains(TerminalModifiers::SHIFT); let shift = modifiers.contains(TerminalModifiers::SHIFT);
let (at_bottom, modes, screen_size) = { let (at_bottom, modes, screen_size) = {
@ -2313,8 +2319,7 @@ fn process_event(state: &mut EditorState, ev: Event, term_size: crate::cell::Cel
} }
Event::FocusGained => state.dispatch_focus(frontend_id, true), Event::FocusGained => state.dispatch_focus(frontend_id, true),
Event::FocusLost => state.dispatch_focus(frontend_id, false), Event::FocusLost => state.dispatch_focus(frontend_id, false),
Event::Key(_) => {} Event::Key(_) | Event::Resize(_, _) => {}
Event::Resize(_, _) => {}
} }
} }
@ -2538,10 +2543,10 @@ impl CompletionPopupKey {
/// Vec-backed [`crate::cell::CellGrid`] without going through a /// Vec-backed [`crate::cell::CellGrid`] without going through a
/// `RenderState`. /// `RenderState`.
#[allow(clippy::too_many_lines, reason = "linear paint pipeline")] #[allow(clippy::too_many_lines, reason = "linear paint pipeline")]
pub fn paint_frame( pub fn paint_frame<S: std::hash::BuildHasher>(
state: &EditorState, state: &EditorState,
frontend_id: FrontendId, frontend_id: FrontendId,
terminal_snapshots: &HashMap<WindowId, TerminalSnapshot>, terminal_snapshots: &HashMap<WindowId, TerminalSnapshot, S>,
grid: &mut crate::cell::CellGrid<'_>, grid: &mut crate::cell::CellGrid<'_>,
term_size: CellSize, term_size: CellSize,
) -> Option<CellCoord> { ) -> Option<CellCoord> {
@ -3602,14 +3607,14 @@ fn terminal_modifiers(modifiers: KeyModifiers) -> TerminalModifiers {
crate::protocol::crossterm_translate::mods_from_crossterm(modifiers) crate::protocol::crossterm_translate::mods_from_crossterm(modifiers)
} }
fn terminal_mouse_kind(kind: crossterm::event::MouseEventKind) -> Option<TerminalMouseKind> { fn terminal_mouse_kind(kind: crossterm::event::MouseEventKind) -> TerminalMouseKind {
use crossterm::event::{MouseButton, MouseEventKind}; use crossterm::event::{MouseButton, MouseEventKind};
let button = |button| match button { let button = |button| match button {
MouseButton::Left => TerminalMouseButton::Left, MouseButton::Left => TerminalMouseButton::Left,
MouseButton::Right => TerminalMouseButton::Right, MouseButton::Right => TerminalMouseButton::Right,
MouseButton::Middle => TerminalMouseButton::Middle, MouseButton::Middle => TerminalMouseButton::Middle,
}; };
Some(match kind { match kind {
MouseEventKind::Down(value) => TerminalMouseKind::Down(button(value)), MouseEventKind::Down(value) => TerminalMouseKind::Down(button(value)),
MouseEventKind::Up(value) => TerminalMouseKind::Up(button(value)), MouseEventKind::Up(value) => TerminalMouseKind::Up(button(value)),
MouseEventKind::Drag(value) => TerminalMouseKind::Drag(button(value)), MouseEventKind::Drag(value) => TerminalMouseKind::Drag(button(value)),
@ -3618,7 +3623,7 @@ fn terminal_mouse_kind(kind: crossterm::event::MouseEventKind) -> Option<Termina
MouseEventKind::ScrollDown => TerminalMouseKind::ScrollDown, MouseEventKind::ScrollDown => TerminalMouseKind::ScrollDown,
MouseEventKind::ScrollLeft => TerminalMouseKind::ScrollLeft, MouseEventKind::ScrollLeft => TerminalMouseKind::ScrollLeft,
MouseEventKind::ScrollRight => TerminalMouseKind::ScrollRight, MouseEventKind::ScrollRight => TerminalMouseKind::ScrollRight,
}) }
} }
fn key_event_to_chord(key: KeyEvent) -> Option<Chord> { fn key_event_to_chord(key: KeyEvent) -> Option<Chord> {

View File

@ -8136,10 +8136,10 @@ pub fn make_process_supervisor(lua: &Lua) -> mlua::Result<SharedProcessSuperviso
/// Build the shared terminal registry and install strict raw Lua primitives. /// Build the shared terminal registry and install strict raw Lua primitives.
pub fn make_terminal_manager( pub fn make_terminal_manager(
lua: &Lua, lua: &Lua,
supervisor: SharedProcessSupervisor, supervisor: &SharedProcessSupervisor,
) -> mlua::Result<crate::terminal::SharedTerminalManager> { ) -> mlua::Result<crate::terminal::SharedTerminalManager> {
let manager = Rc::new(RefCell::new(crate::terminal::TerminalManager::new())); let manager = Rc::new(RefCell::new(crate::terminal::TerminalManager::new()));
install_terminal(lua, &manager, &supervisor)?; install_terminal(lua, &manager, supervisor)?;
Ok(manager) Ok(manager)
} }
@ -8248,6 +8248,10 @@ fn terminal_view_key_from_context(
))) )))
} }
#[allow(
clippy::too_many_lines,
reason = "single strict Lua module installation"
)]
fn install_terminal( fn install_terminal(
lua: &Lua, lua: &Lua,
manager: &crate::terminal::SharedTerminalManager, manager: &crate::terminal::SharedTerminalManager,
@ -8262,7 +8266,7 @@ fn install_terminal(
terminal.set( terminal.set(
"_open", "_open",
lua.create_function(move |lua, spec: Table| -> mlua::Result<BufferIdLua> { lua.create_function(move |lua, spec: Table| -> mlua::Result<BufferIdLua> {
let spec = parse_terminal_spec(spec)?; let spec = parse_terminal_spec(&spec)?;
let core = lua let core = lua
.app_data_ref::<SharedCore>() .app_data_ref::<SharedCore>()
.map(|core| core.clone()) .map(|core| core.clone())
@ -8467,7 +8471,7 @@ fn install_terminal(
pmacs.set("terminal", terminal) pmacs.set("terminal", terminal)
} }
fn parse_terminal_spec(table: Table) -> mlua::Result<crate::terminal::TerminalSpec> { fn parse_terminal_spec(table: &Table) -> mlua::Result<crate::terminal::TerminalSpec> {
const FIELDS: &[&str] = &[ const FIELDS: &[&str] = &[
"command", "command",
"args", "args",

View File

@ -353,8 +353,7 @@ impl TerminalManager {
let Some(state) = self.views.get_mut(&key) else { let Some(state) = self.views.get_mut(&key) else {
return false; return false;
}; };
let changed = state.selection.take().is_some() || state.drag.take().is_some(); state.selection.take().is_some() || state.drag.take().is_some()
changed
} }
/// Current child input modes for one session. /// Current child input modes for one session.
@ -608,12 +607,11 @@ fn project_snapshot(
let rows = retained_rows(projection); let rows = retained_rows(projection);
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, &row) in rows.iter().enumerate().skip(geometry.start) {
let Some(target_row) = viewport_row(&geometry, viewport_size.rows as usize, retained_row) let Some(target_row) = viewport_row(&geometry, viewport_size.rows as usize, retained_row)
else { else {
continue; continue;
}; };
let row = rows[retained_row];
let copy_cols = row.cells.len().min(viewport_size.cols as usize); let copy_cols = row.cells.len().min(viewport_size.cols as usize);
let target = target_row * viewport_size.cols as usize; let target = target_row * viewport_size.cols as usize;
cells[target..target + copy_cols].clone_from_slice(&row.cells[..copy_cols]); cells[target..target + copy_cols].clone_from_slice(&row.cells[..copy_cols]);
@ -624,13 +622,12 @@ fn project_snapshot(
.and_then(|selection| normalized_selection(&rows, selection)) .and_then(|selection| normalized_selection(&rows, selection))
.map_or_else(Vec::new, |(start, end)| { .map_or_else(Vec::new, |(start, end)| {
let mut spans = Vec::new(); let mut spans = Vec::new();
for retained_row in start.row..=end.row { for (retained_row, &row) in rows.iter().enumerate().take(end.row + 1).skip(start.row) {
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;
}; };
let row = rows[retained_row];
let start_col = if retained_row == start.row { let start_col = if retained_row == start.row {
start.col start.col
} else { } else {
@ -687,8 +684,7 @@ fn is_default_blank(cell: &Cell) -> bool {
fn copy_selection_bytes(rows: &[&TerminalRow], selection: TerminalSelection) -> Option<Vec<u8>> { fn copy_selection_bytes(rows: &[&TerminalRow], selection: TerminalSelection) -> Option<Vec<u8>> {
let (start, end) = normalized_selection(rows, selection)?; let (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, &row) in rows.iter().enumerate().take(end.row + 1).skip(start.row) {
let row = rows[row_index];
let from = if row_index == start.row { start.col } else { 0 }; let from = if row_index == start.row { start.col } else { 0 };
let mut to = if row_index == end.row { let mut to = if row_index == end.row {
end.col.saturating_add(glyph_width(row, end.col)) end.col.saturating_add(glyph_width(row, end.col))
@ -815,7 +811,7 @@ mod tests {
#[test] #[test]
fn copy_joins_soft_wraps_trims_default_blanks_and_separates_hard_rows() { fn copy_joins_soft_wraps_trims_default_blanks_and_separates_hard_rows() {
let rows = vec![ let rows = [
row(1, 0, "ab ", true), row(1, 0, "ab ", true),
row(1, 3, "cd ", false), row(1, 3, "cd ", false),
row(2, 0, "e ", false), row(2, 0, "e ", false),

View File

@ -57,7 +57,13 @@ fn paint(state: &EditorState, rows: u32, cols: u32) -> Vec<Cell> {
stride: cols, stride: cols,
size: CellSize::new(rows, cols), size: CellSize::new(rows, cols),
}; };
let _ = pmacs::editor::paint_frame(state, &mut grid, CellSize::new(rows, cols)); let _ = pmacs::editor::paint_frame(
state,
FrontendId::LOCAL,
&std::collections::HashMap::new(),
&mut grid,
CellSize::new(rows, cols),
);
cells cells
} }
@ -124,8 +130,14 @@ fn a01_04_registry_contract_limits_epochs_and_results() {
assert!(baseline_mode.ends_with(" L1:C1 All ")); assert!(baseline_mode.ends_with(" L1:C1 All "));
let initial = state.statusline_registry.borrow().providers(); let initial = state.statusline_registry.borrow().providers();
assert_eq!(initial.len(), 1, "builtin lsp provider is discoverable"); assert_eq!(
assert_eq!(initial[0].name, "lsp"); initial
.iter()
.map(|provider| provider.name.as_str())
.collect::<Vec<_>>(),
["terminal", "lsp"],
"built-in providers are discoverable"
);
let before_epochs = { let before_epochs = {
let registry = state.statusline_registry.borrow(); let registry = state.statusline_registry.borrow();
(registry.layout_epoch(), registry.face_set_epoch()) (registry.layout_epoch(), registry.face_set_epoch())

View File

@ -92,7 +92,13 @@ fn paint_full_frame(state: &EditorState, rows: u32, cols: u32) -> Vec<Cell> {
stride: cols, stride: cols,
size: CellSize::new(rows, cols), size: CellSize::new(rows, cols),
}; };
let _cursor = pmacs::editor::paint_frame(state, &mut grid, CellSize::new(rows, cols)); let _cursor = pmacs::editor::paint_frame(
state,
FrontendId::LOCAL,
&std::collections::HashMap::new(),
&mut grid,
CellSize::new(rows, cols),
);
backing backing
} }

View File

@ -104,16 +104,6 @@ fn strict_owned_spec_rejects_before_spawn_and_is_mutation_independent() {
lua_processes, 0, lua_processes, 0,
"terminal-owned ProcessId must not be exposed through pmacs.process" "terminal-owned ProcessId must not be exposed through pmacs.process"
); );
let terminal_module_absent: bool = state
.lua_host
.lua()
.load("return pmacs.terminal == nil")
.eval()
.expect("terminal module absence");
assert!(
terminal_module_absent,
"Stage 1 must not publish an unrenderable interactive Lua terminal API"
);
let process_id = state let process_id = state
.terminal_manager .terminal_manager

View File

@ -38,7 +38,7 @@ fn tick_until(
} }
fn lua_string(value: &str) -> String { fn lua_string(value: &str) -> String {
format!("{:?}", value) format!("{value:?}")
} }
fn snapshot_text(snapshot: &pmacs::terminal::TerminalSnapshot) -> String { fn snapshot_text(snapshot: &pmacs::terminal::TerminalSnapshot) -> String {
@ -54,6 +54,10 @@ fn snapshot_text(snapshot: &pmacs::terminal::TerminalSnapshot) -> String {
} }
#[test] #[test]
#[allow(
clippy::too_many_lines,
reason = "cross-surface Lua transaction scenario"
)]
fn lua_surface_is_strict_fresh_transactional_and_context_safe() { fn lua_surface_is_strict_fresh_transactional_and_context_safe() {
let mut state = EditorState::new(); let mut state = EditorState::new();
let command_lua = lua_string("/bin/sh"); let command_lua = lua_string("/bin/sh");
@ -181,7 +185,7 @@ fn lua_surface_is_strict_fresh_transactional_and_context_safe() {
let lua = state.lua_host.lua(); let lua = state.lua_host.lua();
let status: Table = lua let status: Table = lua
.load(format!( .load(format!(
r#" r"
local first = pmacs.terminal.view_state {{ local first = pmacs.terminal.view_state {{
frontend = 1, window = {}, buffer = TERM_BUFFER, active = true frontend = 1, window = {}, buffer = TERM_BUFFER, active = true
}} }}
@ -191,7 +195,7 @@ fn lua_surface_is_strict_fresh_transactional_and_context_safe() {
}} }}
assert(second.injected == nil) assert(second.injected == nil)
return second return second
"#, ",
window_id.raw(), window_id.raw(),
window_id.raw() window_id.raw()
)) ))
@ -201,10 +205,10 @@ fn lua_surface_is_strict_fresh_transactional_and_context_safe() {
let (ok, error): (bool, String) = lua let (ok, error): (bool, String) = lua
.load( .load(
r#" r"
local ok, err = pcall(function() pmacs.terminal.scroll(1) end) local ok, err = pcall(function() pmacs.terminal.scroll(1) end)
return ok, tostring(err) return ok, tostring(err)
"#, ",
) )
.eval() .eval()
.expect("pcall implicit scroll"); .expect("pcall implicit scroll");
@ -393,6 +397,7 @@ fn wait_for_file(path: &Path, timeout: Duration) -> Vec<u8> {
} }
#[test] #[test]
#[allow(clippy::too_many_lines, reason = "one real-host lifecycle scenario")]
fn real_tui_terminal_smoke_restores_host_after_output_input_resize_scroll_copy_and_bell() { fn real_tui_terminal_smoke_restores_host_after_output_input_resize_scroll_copy_and_bell() {
let temp = tempfile::TempDir::new().expect("tempdir"); let temp = tempfile::TempDir::new().expect("tempdir");
let config_root = temp.path().join("config"); let config_root = temp.path().join("config");