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 <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-07-21 19:10:45 -04:00
parent 39e07cbf9b
commit 7c3953563c
5 changed files with 550 additions and 7 deletions

View File

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

View File

@ -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<FrontendId, crate::buffer::BufferId> = 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<FrontendId, (crate::buffer::BufferId, u64)>,
) -> 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<FrontendId, RenderState>,
@ -1460,6 +1517,10 @@ fn handle_dispatcher_event(
term_sizes: &mut HashMap<FrontendId, CellSize>,
last_dispatch_idle_sent: &mut HashMap<FrontendId, bool>,
last_active_buffer_sent: &mut HashMap<FrontendId, crate::buffer::BufferId>,
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'),

View File

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

View File

@ -8120,6 +8120,366 @@ pub fn make_process_supervisor(lua: &Lua) -> mlua::Result<SharedProcessSuperviso
Ok(supervisor)
}
// ---------------------------------------------------------------------------
// pmacs.terminal: owned terminal session surface (Arc 5 Stage 2)
// ---------------------------------------------------------------------------
/// Build the shared terminal registry and install strict raw Lua primitives.
pub fn make_terminal_manager(
lua: &Lua,
supervisor: SharedProcessSupervisor,
) -> mlua::Result<crate::terminal::SharedTerminalManager> {
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<BufferIdLua> {
let spec = parse_terminal_spec(spec)?;
let core = lua
.app_data_ref::<SharedCore>()
.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<crate::terminal::TerminalSpec> {
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<Option<String>> {
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<Vec<String>> {
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<Vec<(String, String)>> {
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<u16> {
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<usize> {
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<Table> {
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)
// ---------------------------------------------------------------------------

View File

@ -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<u64> {
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.