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
**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 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
preserved on branch `kill-ring-browser`, but its `0efb5cd` scout is stale
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
/// 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<FrontendId, RenderState>,

View File

@ -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<S: std::hash::BuildHasher>(
state: &EditorState,
frontend_id: FrontendId,
terminal_snapshots: &HashMap<WindowId, TerminalSnapshot>,
terminal_snapshots: &HashMap<WindowId, TerminalSnapshot, S>,
grid: &mut crate::cell::CellGrid<'_>,
term_size: CellSize,
) -> Option<CellCoord> {
@ -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<TerminalMouseKind> {
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<Termina
MouseEventKind::ScrollDown => TerminalMouseKind::ScrollDown,
MouseEventKind::ScrollLeft => TerminalMouseKind::ScrollLeft,
MouseEventKind::ScrollRight => TerminalMouseKind::ScrollRight,
})
}
}
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.
pub fn make_terminal_manager(
lua: &Lua,
supervisor: SharedProcessSupervisor,
supervisor: &SharedProcessSupervisor,
) -> mlua::Result<crate::terminal::SharedTerminalManager> {
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<BufferIdLua> {
let spec = parse_terminal_spec(spec)?;
let spec = parse_terminal_spec(&spec)?;
let core = lua
.app_data_ref::<SharedCore>()
.map(|core| core.clone())
@ -8467,7 +8471,7 @@ fn install_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] = &[
"command",
"args",

View File

@ -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<Vec<u8>> {
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),

View File

@ -57,7 +57,13 @@ fn paint(state: &EditorState, rows: u32, cols: u32) -> Vec<Cell> {
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::<Vec<_>>(),
["terminal", "lsp"],
"built-in providers are discoverable"
);
let before_epochs = {
let registry = state.statusline_registry.borrow();
(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,
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
}

View File

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

View File

@ -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<u8> {
}
#[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");