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:
parent
0ddff24589
commit
da8f6aeae4
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in New Issue