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:
parent
39e07cbf9b
commit
7c3953563c
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue