From 7c3953563ccbc1b6a098a41bc432b6d80a07988e Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 21 Jul 2026 19:10:45 -0400 Subject: [PATCH] feat(vterm): add strict Lua and daemon foundations Install strict owned terminal specification parsing, fresh global state tables, default-name uniquification, durable view/controller lifecycle, and the builtin terminal command/statusline surface. Route daemon key and mouse input by the authenticated source and add non-replaying per-frontend BEL baselines. Co-Authored-By: Claude --- builtin/runtime/terminal.lua | 95 +++++++++ src/daemon.rs | 88 ++++++++- src/editor_core.rs | 6 + src/lua_bindings/mod.rs | 360 +++++++++++++++++++++++++++++++++++ src/terminal/session.rs | 8 + 5 files changed, 550 insertions(+), 7 deletions(-) create mode 100644 builtin/runtime/terminal.lua diff --git a/builtin/runtime/terminal.lua b/builtin/runtime/terminal.lua new file mode 100644 index 0000000..6be0987 --- /dev/null +++ b/builtin/runtime/terminal.lua @@ -0,0 +1,95 @@ +-- terminal.lua --- Friendly Vterm Stage 2 command and modeline surface. + +local terminal = assert(pmacs.terminal, "pmacs.terminal raw bindings are required") +local raw_open = assert(terminal._open, "pmacs.terminal._open is required") + +local function bind_terminal_keys(buffer) + local function bind(sequence, command) + pmacs.keymap.bind { + scope = "buffer", + buffer = buffer, + sequence = sequence, + command = command, + } + end + bind("M-w", "terminal.copy-selection") + bind("M-v", "terminal.page-up") + bind("C-v", "terminal.page-down") + bind("M-<", "terminal.scroll-oldest") + bind("M->", "terminal.scroll-bottom") +end + +function terminal.open(spec) + local buffer = raw_open(spec) + bind_terminal_keys(buffer) + return buffer +end + +pmacs.command.define { + name = "terminal", + description = "Open a terminal running $SHELL (or /bin/sh).", + fn = function() + return terminal.open { + command = os.getenv("SHELL") or "/bin/sh", + } + end, +} + +pmacs.command.define { + name = "terminal.copy-selection", + description = "Copy the active terminal selection.", + fn = function() return terminal.copy_selection() end, +} + +pmacs.command.define { + name = "terminal.page-up", + description = "Scroll the active terminal viewport up one page.", + fn = function() return terminal._scroll_page(1) end, +} + +pmacs.command.define { + name = "terminal.page-down", + description = "Scroll the active terminal viewport down one page.", + fn = function() return terminal._scroll_page(-1) end, +} + +pmacs.command.define { + name = "terminal.scroll-oldest", + description = "Scroll the active terminal viewport to the oldest retained row.", + fn = function() return terminal.scroll(math.maxinteger) end, +} + +pmacs.command.define { + name = "terminal.scroll-bottom", + description = "Return the active terminal viewport to the live tail.", + fn = function() return terminal.scroll_to_bottom() end, +} + +pmacs.statusline.register { + name = "terminal", + side = "right", + priority = 10, + face = "ui.modeline.terminal", + fn = function(ctx) + if not terminal.is_terminal(ctx.buffer) then return nil end + local state = terminal.state(ctx.buffer) + local view = terminal.view_state(ctx) + if not view then return nil end + + local process = state.process + local text + if process.kind == "running" then + text = "TERM" + elseif process.kind == "exited" then + text = "TERM:" .. tostring(process.code) + elseif process.kind == "signaled" then + text = "TERM:" .. process.signal + else + text = "TERM:ERR" + end + if view.scroll_offset > 0 then + text = text .. " ↑" .. tostring(view.scroll_offset) + end + return text + end, +} diff --git a/src/daemon.rs b/src/daemon.rs index 3587622..5178d32 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -869,6 +869,12 @@ fn dispatcher_loop( // Declared for both flavors (the follow path is crdt-gated; the // detach cleanup isn't). let mut last_active_buffer_sent: HashMap = HashMap::new(); + // Active-terminal BEL delivery baseline. Switching away forgets the + // terminal so historical bells are never replayed on later activation. + let mut terminal_bell_baselines: HashMap< + FrontendId, + (crate::buffer::BufferId, u64), + > = HashMap::new(); let mut session_registry = SessionRegistry::new(); // T M10.11 Q8 — jitter PRNG, seeded once so the // convergence-under-jitter scenario is deterministically @@ -1082,6 +1088,14 @@ fn dispatcher_loop( // — initial-after-attach (`last_dispatch_idle_sent` absent) // and value-change emissions only. let mut write_failed = false; + if take_pending_terminal_bell(editor, *fid, &mut terminal_bell_baselines) + && let Some(stream) = streams.get_mut(fid) + && let Err(error) = + write_message(stream, &InstanceMessage::Signal(InstanceSignal::Bell)) + { + eprintln!("pmacs: write terminal Bell for {fid:?} failed: {error}"); + write_failed = true; + } if session_registry.session_state(*fid).is_some_and(|s| { // Filter on both the `crdt_replica` capability (only // optimistic-apply frontends care) and the negotiated @@ -1283,6 +1297,8 @@ fn dispatcher_loop( term_sizes.remove(fid); last_dispatch_idle_sent.remove(fid); last_active_buffer_sent.remove(fid); + terminal_bell_baselines.remove(fid); + editor.terminal_manager.borrow_mut().detach_frontend(*fid); session_registry.unregister_session(*fid); editor .statusline_registry @@ -1328,6 +1344,7 @@ fn dispatcher_loop( &mut term_sizes, &mut last_dispatch_idle_sent, &mut last_active_buffer_sent, + &mut terminal_bell_baselines, &mut session_registry, ); // Drain a burst of immediately-available events to @@ -1344,6 +1361,7 @@ fn dispatcher_loop( &mut term_sizes, &mut last_dispatch_idle_sent, &mut last_active_buffer_sent, + &mut terminal_bell_baselines, &mut session_registry, ); } @@ -1376,6 +1394,45 @@ fn dispatcher_loop( /// 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, + baselines: &mut HashMap, +) -> bool { + let buffer_id = editor + .core + .borrow() + .active_window_for(frontend_id) + .map(|window| window.buffer_id); + let Some((buffer_id, count)) = buffer_id.and_then(|buffer_id| { + editor + .terminal_manager + .borrow() + .bell_count(buffer_id) + .map(|count| (buffer_id, count)) + }) else { + baselines.remove(&frontend_id); + return false; + }; + + match baselines.get_mut(&frontend_id) { + Some((baseline_buffer, delivered)) + if *baseline_buffer == buffer_id && count > *delivered => + { + *delivered += 1; + true + } + Some((baseline_buffer, delivered)) if *baseline_buffer == buffer_id => { + *delivered = count; + false + } + _ => { + baselines.insert(frontend_id, (buffer_id, count)); + false + } + } +} + fn handle_session_established( editor: &mut EditorState, render_states: &mut HashMap, @@ -1460,6 +1517,10 @@ fn handle_dispatcher_event( term_sizes: &mut HashMap, last_dispatch_idle_sent: &mut HashMap, last_active_buffer_sent: &mut HashMap, + terminal_bell_baselines: &mut HashMap< + FrontendId, + (crate::buffer::BufferId, u64), + >, session_registry: &mut SessionRegistry, ) { match event { @@ -1627,7 +1688,7 @@ fn handle_dispatcher_event( .expect("term_size present for source"); let mut term_size = term_size; if let Some(render_state) = render_states.get_mut(&source) { - apply_event(editor, event, &mut term_size, render_state); + apply_event(editor, source, event, &mut term_size, render_state); term_sizes.insert(source, term_size); } else if semantic_states.contains_key(&source) { // Phase B (session B1) — a semantic (grid-less) @@ -1641,7 +1702,7 @@ fn handle_dispatcher_event( // arm dropped these events — the "M11.5 scope" // posture — which is why typing in pmacs-gpu did // nothing before B1.) - apply_semantic_input_event(editor, event, term_size); + apply_semantic_input_event(editor, source, event, term_size); } else { debug_assert!( false, @@ -1659,6 +1720,11 @@ fn handle_dispatcher_event( term_sizes.remove(&frontend_id); last_dispatch_idle_sent.remove(&frontend_id); last_active_buffer_sent.remove(&frontend_id); + terminal_bell_baselines.remove(&frontend_id); + editor + .terminal_manager + .borrow_mut() + .detach_frontend(frontend_id); session_registry.unregister_session(frontend_id); editor .statusline_registry @@ -2461,16 +2527,21 @@ fn build_presence_snapshot(editor: &EditorState, frontend_id: FrontendId) -> Pre /// `Paste` (Q#KR10a) are handled in their own dispatcher arms and /// never reach here. #[allow(clippy::needless_pass_by_value)] // consumes the event, mirroring `apply_event`. -fn apply_semantic_input_event(editor: &mut EditorState, ev: FrontendEvent, term_size: CellSize) { +fn apply_semantic_input_event( + editor: &mut EditorState, + source: FrontendId, + ev: FrontendEvent, + term_size: CellSize, +) { match ev { FrontendEvent::Key(pmacs_key) => { if let Some(ct_key) = key_to_crossterm(&pmacs_key) { - editor.dispatch_key(pmacs_key.frontend_id, ct_key); + editor.dispatch_key(source, ct_key); } } FrontendEvent::Mouse(pmacs_mouse) => { let ct_mouse = mouse_to_crossterm(&pmacs_mouse); - editor.dispatch_mouse(pmacs_mouse.frontend_id, ct_mouse, term_size); + editor.dispatch_mouse(source, ct_mouse, term_size); } _ => {} } @@ -2482,6 +2553,7 @@ fn apply_semantic_input_event(editor: &mut EditorState, ev: FrontendEvent, term_ #[allow(clippy::needless_pass_by_value)] fn apply_event( editor: &mut EditorState, + source: FrontendId, ev: FrontendEvent, term_size: &mut CellSize, render_state: &mut RenderState, @@ -2489,14 +2561,14 @@ fn apply_event( match ev { FrontendEvent::Key(pmacs_key) => { if let Some(ct_key) = key_to_crossterm(&pmacs_key) { - editor.dispatch_key(pmacs_key.frontend_id, ct_key); + editor.dispatch_key(source, ct_key); } // `Key::Unknown` keys (media buttons etc.) have no // crossterm equivalent and do not actuate commands; drop. } FrontendEvent::Mouse(pmacs_mouse) => { let ct_mouse = mouse_to_crossterm(&pmacs_mouse); - editor.dispatch_mouse(pmacs_mouse.frontend_id, ct_mouse, *term_size); + editor.dispatch_mouse(source, ct_mouse, *term_size); } FrontendEvent::Resize { size, .. } => { render_state.resize(size); @@ -3160,6 +3232,7 @@ mod tests { apply_semantic_input_event( &mut editor, + fid, FrontendEvent::Key(KeyEvent { frontend_id: fid, key: Key::Char('X'), @@ -3236,6 +3309,7 @@ mod tests { // A key now edits the *displayed* buffer, advancing its cursor. apply_semantic_input_event( &mut editor, + fid, FrontendEvent::Key(KeyEvent { frontend_id: fid, key: Key::Char('Z'), diff --git a/src/editor_core.rs b/src/editor_core.rs index fea6261..0fcbf2b 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -2821,6 +2821,12 @@ impl EditorCore { self.round_trip_buffers.contains(&self.active_buffer_id()) } + /// Whether an explicit buffer requires daemon-owned round-trip input. + #[must_use] + pub fn active_buffer_round_trips_for(&self, buffer_id: BufferId) -> bool { + self.round_trip_buffers.contains(&buffer_id) + } + /// Ensure the active window carries a /// [`crate::completion::CompletionView`] overlay (deduped by kind). /// The view reads the shared popup, so one instance suffices; it diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index 7821d4b..38fb010 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -8120,6 +8120,366 @@ 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)?; + Ok(manager) +} + +fn install_terminal( + lua: &Lua, + manager: &crate::terminal::SharedTerminalManager, + supervisor: &SharedProcessSupervisor, +) -> mlua::Result<()> { + let pmacs: Table = lua.globals().get("pmacs")?; + let terminal = lua.create_table()?; + + { + let manager = manager.clone(); + let supervisor = supervisor.clone(); + terminal.set( + "_open", + lua.create_function(move |lua, spec: Table| -> mlua::Result { + let spec = parse_terminal_spec(spec)?; + let core = lua + .app_data_ref::() + .map(|core| core.clone()) + .ok_or_else(|| { + mlua::Error::external("pmacs.terminal.open: editor core unavailable") + })?; + let buffer_id = { + let mut manager = manager.borrow_mut(); + manager + .open( + spec, + &mut core.borrow_mut(), + &mut supervisor.borrow_mut(), + ) + .map_err(mlua::Error::external)? + }; + let key = { + let mut core = core.borrow_mut(); + if let Err(error) = core.switch_active_buffer(buffer_id) { + let _ = core.registry.borrow_mut().remove(buffer_id); + manager + .borrow_mut() + .prune(&mut core, &mut supervisor.borrow_mut()); + return Err(mlua::Error::external(format!( + "pmacs.terminal.open: active-window switch failed: {error}" + ))); + } + crate::terminal::TerminalViewKey::new( + core.active_frontend, + core.active_window_id(), + buffer_id, + ) + }; + { + let mut manager = manager.borrow_mut(); + if !manager.register_view(key) || !manager.claim_controller(key) { + return Err(mlua::Error::external( + "pmacs.terminal.open: failed to claim the new terminal view", + )); + } + } + run_hook_if_defined(lua, "buffer.after-switch", mlua::MultiValue::new()); + Ok(BufferIdLua(buffer_id)) + })?, + )?; + } + + { + let manager = manager.clone(); + terminal.set( + "is_terminal", + lua.create_function(move |_, buffer: BufferIdLua| { + Ok(manager.borrow().is_terminal(buffer.0)) + })?, + )?; + } + + { + let manager = manager.clone(); + terminal.set( + "state", + lua.create_function(move |lua, buffer: BufferIdLua| { + let snapshot = manager.borrow().snapshot(buffer.0).ok_or_else(|| { + mlua::Error::external(format!( + "pmacs.terminal.state: buffer {:?} is not a terminal", + buffer.0 + )) + })?; + terminal_state_table(lua, snapshot) + })?, + )?; + } + + { + let manager = manager.clone(); + let supervisor = supervisor.clone(); + terminal.set( + "send", + lua.create_function(move |_, (buffer, bytes): (BufferIdLua, mlua::String)| { + manager + .borrow() + .send(buffer.0, bytes.as_bytes().as_ref(), &mut supervisor.borrow_mut()) + .map_err(mlua::Error::external) + })?, + )?; + } + + { + let manager = manager.clone(); + let supervisor = supervisor.clone(); + terminal.set( + "terminate", + lua.create_function(move |_, buffer: BufferIdLua| { + manager + .borrow_mut() + .terminate(buffer.0, &mut supervisor.borrow_mut()) + .map_err(mlua::Error::external) + })?, + )?; + } + + pmacs.set("terminal", terminal) +} + +fn parse_terminal_spec(table: Table) -> mlua::Result { + const FIELDS: &[&str] = &[ + "command", + "args", + "cwd", + "env", + "name", + "rows", + "cols", + "scrollback_rows", + ]; + let mut unknown = None; + table.clone().for_each(|key: Value, _: Value| { + let key = match key { + Value::String(key) => key.to_str()?.to_owned(), + other => { + unknown = Some(format!("<{} key>", other.type_name())); + return Ok(()); + } + }; + if !FIELDS.contains(&key.as_str()) { + unknown = Some(key); + } + Ok(()) + })?; + if let Some(field) = unknown { + return Err(mlua::Error::external(format!( + "pmacs.terminal.open: unknown field `{field}`" + ))); + } + + let command = strict_terminal_string(table.raw_get("command")?, "command", false)? + .ok_or_else(|| mlua::Error::external("pmacs.terminal.open: missing field `command`"))?; + let args = strict_terminal_args(table.raw_get("args")?)?; + let cwd = strict_terminal_string(table.raw_get("cwd")?, "cwd", true)? + .map(std::path::PathBuf::from); + let env = strict_terminal_env(table.raw_get("env")?)?; + let name = strict_terminal_string(table.raw_get("name")?, "name", true)?; + let rows = strict_terminal_u16(table.raw_get("rows")?, "rows", 24)?; + let cols = strict_terminal_u16(table.raw_get("cols")?, "cols", 80)?; + let scrollback_rows = strict_terminal_usize( + table.raw_get("scrollback_rows")?, + "scrollback_rows", + crate::terminal::DEFAULT_TERMINAL_SCROLLBACK_ROWS, + )?; + + Ok(crate::terminal::TerminalSpec { + command, + args, + cwd, + env, + name, + rows, + cols, + scrollback_rows, + }) +} + +fn strict_terminal_string( + value: Value, + field: &'static str, + optional: bool, +) -> mlua::Result> { + match value { + Value::Nil if optional => Ok(None), + Value::String(value) => Ok(Some(value.to_str()?.to_owned())), + Value::Nil => Err(mlua::Error::external(format!( + "pmacs.terminal.open: missing field `{field}`" + ))), + other => Err(mlua::Error::external(format!( + "pmacs.terminal.open: `{field}` must be a string, got {}", + other.type_name() + ))), + } +} + +fn strict_terminal_args(value: Value) -> mlua::Result> { + let Value::Table(table) = value else { + return match value { + Value::Nil => Ok(Vec::new()), + other => Err(mlua::Error::external(format!( + "pmacs.terminal.open: `args` must be a dense string array, got {}", + other.type_name() + ))), + }; + }; + let mut entries = std::collections::BTreeMap::new(); + table.for_each(|key: Value, value: Value| { + let Value::Integer(index) = key else { + return Err(mlua::Error::external( + "pmacs.terminal.open: `args` keys must be positive integers", + )); + }; + let index = usize::try_from(index).map_err(|_| { + mlua::Error::external("pmacs.terminal.open: `args` keys must be positive integers") + })?; + if index == 0 { + return Err(mlua::Error::external( + "pmacs.terminal.open: `args` keys must be positive integers", + )); + } + let Value::String(value) = value else { + return Err(mlua::Error::external(format!( + "pmacs.terminal.open: `args[{index}]` must be a string" + ))); + }; + entries.insert(index, value.to_str()?.to_owned()); + Ok(()) + })?; + let mut args = Vec::with_capacity(entries.len()); + for expected in 1..=entries.len() { + let value = entries.remove(&expected).ok_or_else(|| { + mlua::Error::external(format!( + "pmacs.terminal.open: `args` has a hole at index {expected}" + )) + })?; + args.push(value); + } + if let Some((&index, _)) = entries.first_key_value() { + return Err(mlua::Error::external(format!( + "pmacs.terminal.open: `args` has a hole before index {index}" + ))); + } + Ok(args) +} + +fn strict_terminal_env(value: Value) -> mlua::Result> { + let Value::Table(table) = value else { + return match value { + Value::Nil => Ok(Vec::new()), + other => Err(mlua::Error::external(format!( + "pmacs.terminal.open: `env` must be a string-to-string table, got {}", + other.type_name() + ))), + }; + }; + let mut env = Vec::new(); + table.for_each(|key: Value, value: Value| { + let Value::String(key) = key else { + return Err(mlua::Error::external( + "pmacs.terminal.open: `env` keys must be strings", + )); + }; + let key = key.to_str()?.to_owned(); + let Value::String(value) = value else { + return Err(mlua::Error::external(format!( + "pmacs.terminal.open: `env[{key}]` must be a string" + ))); + }; + env.push((key, value.to_str()?.to_owned())); + Ok(()) + })?; + env.sort_unstable_by(|left, right| left.0.cmp(&right.0)); + Ok(env) +} + +fn strict_terminal_u16(value: Value, field: &'static str, default: u16) -> mlua::Result { + match value { + Value::Nil => Ok(default), + Value::Integer(value) => u16::try_from(value).map_err(|_| { + mlua::Error::external(format!( + "pmacs.terminal.open: `{field}` must be an integer in 0..={}", + u16::MAX + )) + }), + other => Err(mlua::Error::external(format!( + "pmacs.terminal.open: `{field}` must be an integer, got {}", + other.type_name() + ))), + } +} + +fn strict_terminal_usize( + value: Value, + field: &'static str, + default: usize, +) -> mlua::Result { + match value { + Value::Nil => Ok(default), + Value::Integer(value) => usize::try_from(value).map_err(|_| { + mlua::Error::external(format!( + "pmacs.terminal.open: `{field}` must be a non-negative integer" + )) + }), + other => Err(mlua::Error::external(format!( + "pmacs.terminal.open: `{field}` must be an integer, got {}", + other.type_name() + ))), + } +} + +fn terminal_state_table( + lua: &Lua, + snapshot: crate::terminal::TerminalSnapshot, +) -> mlua::Result { + let state = lua.create_table()?; + state.set("buffer", BufferIdLua(snapshot.buffer_id))?; + state.set("pid", i64::from(snapshot.pid))?; + state.set("rows", i64::from(snapshot.size.rows))?; + state.set("cols", i64::from(snapshot.size.cols))?; + if let Some(title) = snapshot.title { + state.set("title", title)?; + } + state.set( + "screen_generation", + i64::try_from(snapshot.screen_generation).unwrap_or(i64::MAX), + )?; + let process = lua.create_table()?; + match snapshot.process { + crate::terminal::TerminalProcessState::Running => process.set("kind", "running")?, + crate::terminal::TerminalProcessState::Exited(code) => { + process.set("kind", "exited")?; + process.set("code", code)?; + } + crate::terminal::TerminalProcessState::Signaled(signal) => { + process.set("kind", "signaled")?; + process.set("signal", signal)?; + } + crate::terminal::TerminalProcessState::Crashed(message) => { + process.set("kind", "crashed")?; + process.set("message", message)?; + } + } + state.set("process", process)?; + Ok(state) +} + // --------------------------------------------------------------------------- // pmacs.lsp: LSP client surface (T M4.5) // --------------------------------------------------------------------------- diff --git a/src/terminal/session.rs b/src/terminal/session.rs index 78f62ba..c7761db 100644 --- a/src/terminal/session.rs +++ b/src/terminal/session.rs @@ -348,6 +348,14 @@ impl TerminalManager { .map(|session| session.process_id) } + /// Monotonic terminal BEL count used for per-frontend delivery baselines. + #[must_use] + pub fn bell_count(&self, buffer_id: BufferId) -> Option { + self.sessions + .get(&buffer_id) + .map(|session| session.screen.bell_count()) + } + /// Ensure an exact terminal view exists without changing its controller. /// /// Returns `false` when the key's buffer is not a published terminal.