From da8f6aeae4ad5189b8f56e175100db8e89b451b7 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 21 Jul 2026 21:38:08 -0400 Subject: [PATCH] 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 --- docs/agent-handoff.md | 50 +++++++++++++++++++------ src/daemon.rs | 5 ++- src/editor.rs | 27 +++++++------ src/lua_bindings/mod.rs | 12 ++++-- src/terminal/view.rs | 14 +++---- tests/statusline_segments_acceptance.rs | 18 +++++++-- tests/theme_faces_acceptance.rs | 8 +++- tests/vterm_stage1_acceptance.rs | 10 ----- tests/vterm_stage2_acceptance.rs | 15 +++++--- 9 files changed, 103 insertions(+), 56 deletions(-) diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index bb2fb8d..4f7fc3a 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -1,9 +1,9 @@ # Agent handoff — cross-machine continuity -**Last updated: 2026-07-21, after the config registry (#127) and Vterm -Stage 1 terminal core (#126) both landed on `main`, atop completed -Themes Arc 4 (#120/#124/#125). Vterm Stages 2 and 3 are not -implemented.** +**Last updated: 2026-07-21, after the config registry (#127), Vterm +Stage 1 terminal core (#126), and handoff refresh (#128) landed on +`main`, atop completed Themes Arc 4 (#120/#124/#125). Vterm Stage 2 is +implemented on `vterm-tui`; Stage 3 is not implemented.** This file is the bridge between development machines. If you are an agent reading 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) -- `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 = `StatuslineSegments`). - **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 cells, preserves the released button code in SGR mouse reports, removes 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, xterm alternate-screen details, legacy non-SGR mouse, printable ASCII and 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 behavioral bite; the original `main`/crate-root bite remains explicitly weaker compile-time API evidence. - - Stage 2 reviews require a durable focus/input resize owner, owning - `FrontendId` for the global `C-c` continuation, and local clipboard/BEL - signal drainage. Stage 3 additionally owns `pmacs-gpu/src/attach.rs`, - authenticated source routing, protocol-owned wire types/limits, and a - deliberate complete-frame limit decision: 16 MiB is insufficient; use a - measured legal-worst cap or aggregate bound, never silent chunking. + - Stage 3 owns `pmacs-gpu/src/attach.rs`, authenticated source routing, + protocol-owned wire types/limits, and a deliberate complete-frame limit + decision: 16 MiB is insufficient; use a measured legal-worst cap or + aggregate bound, never silent chunking. + - **Stage 2 TUI is implemented on `vterm-tui`** (`docs/vterm-framing.md` + Revision 7, criteria 15–27). `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 preserved on branch `kill-ring-browser`, but its `0efb5cd` scout is stale and must be repeated before implementation. No PR or implementation is diff --git a/src/daemon.rs b/src/daemon.rs index e47bff2..390fc11 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -1406,7 +1406,6 @@ fn dispatcher_loop( /// initial-full-grid analogue — it emits nothing until the frontend /// declares a viewport); every other session keeps the M5.3 /// force-full-grid grid path. -#[allow(clippy::too_many_arguments)] fn take_pending_terminal_bell( editor: &EditorState, 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( editor: &mut EditorState, render_states: &mut HashMap, diff --git a/src/editor.rs b/src/editor.rs index 7aa879e..6d1d5f4 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -301,7 +301,7 @@ impl EditorState { let process_supervisor = crate::lua_bindings::make_process_supervisor(lua_host.lua()) .expect("install pmacs.process"); let terminal_manager = - crate::lua_bindings::make_terminal_manager(lua_host.lua(), process_supervisor.clone()) + crate::lua_bindings::make_terminal_manager(lua_host.lua(), &process_supervisor) .expect("install pmacs.terminal"); lua_host .eval( @@ -759,6 +759,10 @@ impl EditorState { /// (`pmacs.frontend.id()`). Sets [`EditorCore::active_frontend`] /// before any command body runs, so observers always see a fresh /// value. + #[allow( + clippy::too_many_lines, + reason = "single input-precedence state machine" + )] pub fn dispatch_key(&mut self, frontend_id: FrontendId, key: KeyEvent) { if !matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) { return; @@ -1579,6 +1583,10 @@ impl EditorState { /// (the click neither activates the window nor positions the /// cursor; that gesture is reserved for future binding to /// "switch to this window" without disturbing buffer state). + #[allow( + clippy::too_many_lines, + reason = "shared document/terminal mouse router" + )] pub fn dispatch_mouse( &mut self, frontend_id: FrontendId, @@ -1722,9 +1730,7 @@ impl EditorState { ) { use crossterm::event::{MouseButton, MouseEventKind}; - let Some(kind) = terminal_mouse_kind(event.kind) else { - return; - }; + let kind = terminal_mouse_kind(event.kind); let modifiers = terminal_modifiers(event.modifiers); let shift = modifiers.contains(TerminalModifiers::SHIFT); 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::FocusLost => state.dispatch_focus(frontend_id, false), - Event::Key(_) => {} - Event::Resize(_, _) => {} + Event::Key(_) | Event::Resize(_, _) => {} } } @@ -2538,10 +2543,10 @@ impl CompletionPopupKey { /// Vec-backed [`crate::cell::CellGrid`] without going through a /// `RenderState`. #[allow(clippy::too_many_lines, reason = "linear paint pipeline")] -pub fn paint_frame( +pub fn paint_frame( state: &EditorState, frontend_id: FrontendId, - terminal_snapshots: &HashMap, + terminal_snapshots: &HashMap, grid: &mut crate::cell::CellGrid<'_>, term_size: CellSize, ) -> Option { @@ -3602,14 +3607,14 @@ fn terminal_modifiers(modifiers: KeyModifiers) -> TerminalModifiers { crate::protocol::crossterm_translate::mods_from_crossterm(modifiers) } -fn terminal_mouse_kind(kind: crossterm::event::MouseEventKind) -> Option { +fn terminal_mouse_kind(kind: crossterm::event::MouseEventKind) -> TerminalMouseKind { use crossterm::event::{MouseButton, MouseEventKind}; let button = |button| match button { MouseButton::Left => TerminalMouseButton::Left, MouseButton::Right => TerminalMouseButton::Right, MouseButton::Middle => TerminalMouseButton::Middle, }; - Some(match kind { + match kind { MouseEventKind::Down(value) => TerminalMouseKind::Down(button(value)), MouseEventKind::Up(value) => TerminalMouseKind::Up(button(value)), MouseEventKind::Drag(value) => TerminalMouseKind::Drag(button(value)), @@ -3618,7 +3623,7 @@ fn terminal_mouse_kind(kind: crossterm::event::MouseEventKind) -> Option TerminalMouseKind::ScrollDown, MouseEventKind::ScrollLeft => TerminalMouseKind::ScrollLeft, MouseEventKind::ScrollRight => TerminalMouseKind::ScrollRight, - }) + } } fn key_event_to_chord(key: KeyEvent) -> Option { diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index e10ab8d..60b8b86 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -8136,10 +8136,10 @@ pub fn make_process_supervisor(lua: &Lua) -> mlua::Result mlua::Result { let manager = Rc::new(RefCell::new(crate::terminal::TerminalManager::new())); - install_terminal(lua, &manager, &supervisor)?; + install_terminal(lua, &manager, supervisor)?; 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( lua: &Lua, manager: &crate::terminal::SharedTerminalManager, @@ -8262,7 +8266,7 @@ fn install_terminal( terminal.set( "_open", lua.create_function(move |lua, spec: Table| -> mlua::Result { - let spec = parse_terminal_spec(spec)?; + let spec = parse_terminal_spec(&spec)?; let core = lua .app_data_ref::() .map(|core| core.clone()) @@ -8467,7 +8471,7 @@ fn install_terminal( pmacs.set("terminal", terminal) } -fn parse_terminal_spec(table: Table) -> mlua::Result { +fn parse_terminal_spec(table: &Table) -> mlua::Result { const FIELDS: &[&str] = &[ "command", "args", diff --git a/src/terminal/view.rs b/src/terminal/view.rs index fb3bedf..1cb5117 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -353,8 +353,7 @@ impl TerminalManager { let Some(state) = self.views.get_mut(&key) else { return false; }; - let changed = state.selection.take().is_some() || state.drag.take().is_some(); - changed + state.selection.take().is_some() || state.drag.take().is_some() } /// Current child input modes for one session. @@ -608,12 +607,11 @@ 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 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) else { continue; }; - let row = rows[retained_row]; let copy_cols = row.cells.len().min(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]); @@ -624,13 +622,12 @@ 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 in start.row..=end.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 { continue; }; - let row = rows[retained_row]; let start_col = if retained_row == start.row { start.col } else { @@ -687,8 +684,7 @@ fn is_default_blank(cell: &Cell) -> bool { fn copy_selection_bytes(rows: &[&TerminalRow], selection: TerminalSelection) -> Option> { let (start, end) = normalized_selection(rows, selection)?; let mut out = Vec::new(); - for row_index in start.row..=end.row { - let row = rows[row_index]; + 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)) @@ -815,7 +811,7 @@ mod tests { #[test] fn copy_joins_soft_wraps_trims_default_blanks_and_separates_hard_rows() { - let rows = vec![ + let rows = [ row(1, 0, "ab ", true), row(1, 3, "cd ", false), row(2, 0, "e ", false), diff --git a/tests/statusline_segments_acceptance.rs b/tests/statusline_segments_acceptance.rs index b2f22aa..1d0d896 100644 --- a/tests/statusline_segments_acceptance.rs +++ b/tests/statusline_segments_acceptance.rs @@ -57,7 +57,13 @@ fn paint(state: &EditorState, rows: u32, cols: u32) -> Vec { stride: 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 } @@ -124,8 +130,14 @@ fn a01_04_registry_contract_limits_epochs_and_results() { assert!(baseline_mode.ends_with(" L1:C1 All ")); let initial = state.statusline_registry.borrow().providers(); - assert_eq!(initial.len(), 1, "builtin lsp provider is discoverable"); - assert_eq!(initial[0].name, "lsp"); + assert_eq!( + initial + .iter() + .map(|provider| provider.name.as_str()) + .collect::>(), + ["terminal", "lsp"], + "built-in providers are discoverable" + ); let before_epochs = { let registry = state.statusline_registry.borrow(); (registry.layout_epoch(), registry.face_set_epoch()) diff --git a/tests/theme_faces_acceptance.rs b/tests/theme_faces_acceptance.rs index c591839..c81ace7 100644 --- a/tests/theme_faces_acceptance.rs +++ b/tests/theme_faces_acceptance.rs @@ -92,7 +92,13 @@ fn paint_full_frame(state: &EditorState, rows: u32, cols: u32) -> Vec { stride: 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 } diff --git a/tests/vterm_stage1_acceptance.rs b/tests/vterm_stage1_acceptance.rs index b01d5b5..d1a4a63 100644 --- a/tests/vterm_stage1_acceptance.rs +++ b/tests/vterm_stage1_acceptance.rs @@ -104,16 +104,6 @@ fn strict_owned_spec_rejects_before_spawn_and_is_mutation_independent() { lua_processes, 0, "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 .terminal_manager diff --git a/tests/vterm_stage2_acceptance.rs b/tests/vterm_stage2_acceptance.rs index 1ead710..c777010 100644 --- a/tests/vterm_stage2_acceptance.rs +++ b/tests/vterm_stage2_acceptance.rs @@ -38,7 +38,7 @@ fn tick_until( } fn lua_string(value: &str) -> String { - format!("{:?}", value) + format!("{value:?}") } fn snapshot_text(snapshot: &pmacs::terminal::TerminalSnapshot) -> String { @@ -54,6 +54,10 @@ fn snapshot_text(snapshot: &pmacs::terminal::TerminalSnapshot) -> String { } #[test] +#[allow( + clippy::too_many_lines, + reason = "cross-surface Lua transaction scenario" +)] fn lua_surface_is_strict_fresh_transactional_and_context_safe() { let mut state = EditorState::new(); 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 status: Table = lua .load(format!( - r#" + r" local first = pmacs.terminal.view_state {{ 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) return second - "#, + ", 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 .load( - r#" + r" local ok, err = pcall(function() pmacs.terminal.scroll(1) end) return ok, tostring(err) - "#, + ", ) .eval() .expect("pcall implicit scroll"); @@ -393,6 +397,7 @@ fn wait_for_file(path: &Path, timeout: Duration) -> Vec { } #[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() { let temp = tempfile::TempDir::new().expect("tempdir"); let config_root = temp.path().join("config");