feat(vterm): bind exact frontend terminal contexts

Resolve terminal commands and buffer switches against the authenticated invoking
frontend, publish copied selections to that frontend, and wire daemon rendering,
resize, focus, paste, bell, and detach lifecycle through exact view identities.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-07-21 19:20:00 -04:00
parent 7c3953563c
commit 0a846d91ce
4 changed files with 271 additions and 18 deletions

View File

@ -1069,10 +1069,20 @@ fn dispatcher_loop(
// the sweep below); other-frontend snapshots lag by
// at most one tick. Imperceptible at frame cadence.
let other_presences = session_registry.other_presences_for(*fid);
let render_size = render_states
.get(fid)
.expect("render_state present for attached grid fid")
.size();
let terminal_snapshots = editor.prepare_terminal_views(*fid, render_size);
let render_state = render_states
.get_mut(fid)
.expect("render_state present for attached grid fid");
render_state.render_frame(editor, &other_presences)
render_state.render_frame(
editor,
*fid,
&terminal_snapshots,
&other_presences,
)
};
// T M10.6 per-frontend presence sweep. The snapshot is
@ -1103,7 +1113,7 @@ fn dispatcher_loop(
s.negotiated_capabilities.crdt_replica && s.negotiated_protocol_version >= 4
}) && let Some(stream) = streams.get_mut(fid)
{
let idle_now = editor.dispatch_idle();
let idle_now = editor.dispatch_idle_for(*fid);
if last_dispatch_idle_sent.get(fid) != Some(&idle_now) {
if let Err(e) =
write_message(stream, &InstanceMessage::DispatchIdle { idle: idle_now })
@ -1298,6 +1308,7 @@ fn dispatcher_loop(
last_dispatch_idle_sent.remove(fid);
last_active_buffer_sent.remove(fid);
terminal_bell_baselines.remove(fid);
editor.detach_frontend_input(*fid);
editor.terminal_manager.borrow_mut().detach_frontend(*fid);
session_registry.unregister_session(*fid);
editor
@ -1370,6 +1381,15 @@ fn dispatcher_loop(
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
// Accepted terminal context controls PTY size. Apply any focus,
// window, or resize changes before consuming another child-output
// batch so screen reflow and subsequent bytes share one geometry.
for frontend_id in &attached_fids {
if let Some(size) = term_sizes.get(frontend_id).copied() {
editor.sync_terminal_layout(*frontend_id, size);
}
}
// `tick_async` last: the M4.5 async bridge settles awaiters
// inside `tick_lsp` (via the message bus); draining + resuming
// in the same frame keeps LSP `:await()` latency at one frame
@ -1658,6 +1678,12 @@ fn handle_dispatcher_event(
editor.dispatch_menu_pointer(source, index, invoke);
}
}
FrontendEvent::FocusGained(_) => {
editor.dispatch_focus(source, true);
}
FrontendEvent::FocusLost(_) => {
editor.dispatch_focus(source, false);
}
FrontendEvent::Paste {
frontend_id: claimed_fid,
data,
@ -1680,7 +1706,9 @@ fn handle_dispatcher_event(
// source's command chain (Q#KR2), and it fires
// `buffer.after-edit` like any other edit (Q#KR10b)
// — previously it never did, so LSP missed pastes.
handle_inbound_paste(editor, source, claimed_fid, &data);
if !editor.dispatch_paste(source, &data) {
handle_inbound_paste(editor, source, claimed_fid, &data);
}
}
_ => {
let term_size = *term_sizes
@ -1721,6 +1749,7 @@ fn handle_dispatcher_event(
last_dispatch_idle_sent.remove(&frontend_id);
last_active_buffer_sent.remove(&frontend_id);
terminal_bell_baselines.remove(&frontend_id);
editor.detach_frontend_input(frontend_id);
editor
.terminal_manager
.borrow_mut()

View File

@ -2484,8 +2484,13 @@ impl EditorCore {
/// read (`C-k`'s killed line, an appended chain), so the Lua ring
/// pushes the exact bytes here.
pub fn clipboard_set(&mut self, bytes: Vec<u8>) {
self.clipboard_set_for(self.active_frontend, bytes);
}
/// Set and publish clipboard bytes to one authenticated frontend.
pub fn clipboard_set_for(&mut self, frontend_id: FrontendId, bytes: Vec<u8>) {
self.clipboard_slot.clone_from(&bytes);
self.pending_clipboard = Some((self.active_frontend, bytes));
self.pending_clipboard = Some((frontend_id, bytes));
}
/// The clipboard slot's current bytes, or `None` when empty (kill
@ -2895,15 +2900,21 @@ impl EditorCore {
.map_err(|e| e.to_string())
}
/// Switch the active window to a different buffer, allocating a
/// fresh [`TextView`] for it.
pub fn switch_active_buffer(&mut self, buffer_id: BufferId) -> Result<(), String> {
/// Switch one frontend's active window to a different buffer, allocating
/// a fresh [`TextView`] for it without changing global active state.
pub fn switch_active_buffer_for(
&mut self,
frontend_id: FrontendId,
buffer_id: BufferId,
) -> Result<(), String> {
let text_view = {
let reg = self.registry.borrow();
let buf = reg.get(buffer_id).map_err(|e| e.to_string())?;
TextView::new(buf)
};
let aw = self.active_window_mut();
let aw = self
.active_window_mut_for(frontend_id)
.ok_or_else(|| format!("frontend {frontend_id:?} has no active window"))?;
aw.buffer_id = buffer_id;
aw.text_view = text_view;
// Overlays were keyed to the previous buffer's coordinates;
@ -2917,6 +2928,11 @@ impl EditorCore {
aw.goal_col = None;
Ok(())
}
/// Switch the globally active frontend's active window.
pub fn switch_active_buffer(&mut self, buffer_id: BufferId) -> Result<(), String> {
self.switch_active_buffer_for(self.active_frontend, buffer_id)
}
}
// ---------------------------------------------------------------------------

View File

@ -55,6 +55,7 @@ use crate::buffer_registry::BufferRegistry;
use crate::cell::{Color, Style, UnderlineStyle};
use crate::command::{Command, CommandError, CommandRegistry, SourceLocation};
use crate::editor_core::EditorCore;
use crate::editor::InteractiveCommandOrigin;
use crate::highlight::SyntaxHighlightView;
use crate::hook::{Hook, HookRegistry};
use crate::key::{display_sequence, parse_sequence};
@ -8134,6 +8135,111 @@ pub fn make_terminal_manager(
Ok(manager)
}
fn terminal_shared_core(lua: &Lua, operation: &str) -> mlua::Result<SharedCore> {
lua.app_data_ref::<SharedCore>()
.map(|core| core.clone())
.ok_or_else(|| {
mlua::Error::external(format!(
"pmacs.terminal.{operation}: editor core unavailable"
))
})
}
fn terminal_command_frontend(lua: &Lua, core: &SharedCore) -> crate::protocol::FrontendId {
lua.app_data_ref::<InteractiveCommandOrigin>()
.and_then(|origin| origin.current())
.unwrap_or_else(|| core.borrow().active_frontend)
}
fn active_terminal_view_key(
lua: &Lua,
core: &SharedCore,
operation: &str,
) -> mlua::Result<crate::terminal::TerminalViewKey> {
let frontend_id = lua
.app_data_ref::<InteractiveCommandOrigin>()
.and_then(|origin| origin.current())
.ok_or_else(|| {
mlua::Error::external(format!(
"pmacs.terminal.{operation}: requires an interactive frontend context"
))
})?;
let core = core.borrow();
let window = core.active_window_for(frontend_id).ok_or_else(|| {
mlua::Error::external(format!(
"pmacs.terminal.{operation}: invoking frontend has no active window"
))
})?;
Ok(crate::terminal::TerminalViewKey::new(
frontend_id,
core.views
.get(&frontend_id)
.expect("active window implies registered frontend view")
.active,
window.buffer_id,
))
}
fn terminal_view_key_from_context(
core: &SharedCore,
context: &Table,
) -> mlua::Result<Option<crate::terminal::TerminalViewKey>> {
const FIELDS: &[&str] = &["frontend", "window", "buffer", "active"];
let mut unknown = None;
context.clone().for_each(|key: Value, _: Value| {
if unknown.is_none() {
match key {
Value::String(value) => {
let value = value.to_str()?;
if !FIELDS.contains(&value.as_ref()) {
unknown = Some(value.to_owned());
}
}
other => unknown = Some(format!("{other:?}")),
}
}
Ok(())
})?;
if let Some(field) = unknown {
return Err(mlua::Error::external(format!(
"pmacs.terminal.view_state: unknown field `{field}`"
)));
}
let frontend_raw = context.get::<i64>("frontend")?;
let frontend_id = crate::protocol::FrontendId(u64::try_from(frontend_raw).map_err(|_| {
mlua::Error::external("pmacs.terminal.view_state: `frontend` must be nonnegative")
})?);
let window_raw = u64::try_from(context.get::<i64>("window")?).map_err(|_| {
mlua::Error::external("pmacs.terminal.view_state: `window` must be nonnegative")
})?;
let buffer_id = context.get::<BufferIdLua>("buffer")?.0;
let core = core.borrow();
let Some(view) = core.views.get(&frontend_id) else {
return Ok(None);
};
let Some(window_id) = view
.layout
.iter_ids()
.into_iter()
.find(|window_id| window_id.raw() == window_raw)
else {
return Ok(None);
};
if core
.windows
.get(&window_id)
.is_none_or(|window| window.buffer_id != buffer_id)
{
return Ok(None);
}
Ok(Some(crate::terminal::TerminalViewKey::new(
frontend_id,
window_id,
buffer_id,
)))
}
fn install_terminal(
lua: &Lua,
manager: &crate::terminal::SharedTerminalManager,
@ -8155,6 +8261,12 @@ fn install_terminal(
.ok_or_else(|| {
mlua::Error::external("pmacs.terminal.open: editor core unavailable")
})?;
let frontend_id = terminal_command_frontend(lua, &core);
if core.borrow().active_window_for(frontend_id).is_none() {
return Err(mlua::Error::external(
"pmacs.terminal.open: target frontend has no active window",
));
}
let buffer_id = {
let mut manager = manager.borrow_mut();
manager
@ -8167,7 +8279,7 @@ fn install_terminal(
};
let key = {
let mut core = core.borrow_mut();
if let Err(error) = core.switch_active_buffer(buffer_id) {
if let Err(error) = core.switch_active_buffer_for(frontend_id, buffer_id) {
let _ = core.registry.borrow_mut().remove(buffer_id);
manager
.borrow_mut()
@ -8177,18 +8289,27 @@ fn install_terminal(
)));
}
crate::terminal::TerminalViewKey::new(
core.active_frontend,
core.active_window_id(),
frontend_id,
core.views
.get(&frontend_id)
.expect("checked frontend has active view")
.active,
buffer_id,
)
};
{
let claimed = {
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",
));
}
manager.register_view(key) && manager.claim_controller(key)
};
if !claimed {
let mut core = core.borrow_mut();
let _ = core.registry.borrow_mut().remove(buffer_id);
manager
.borrow_mut()
.prune(&mut core, &mut supervisor.borrow_mut());
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))
@ -8250,6 +8371,88 @@ fn install_terminal(
)?;
}
{
let manager = manager.clone();
terminal.set(
"view_state",
lua.create_function(move |lua, context: Table| -> mlua::Result<Option<Table>> {
let core = terminal_shared_core(lua, "view_state")?;
let Some(key) = terminal_view_key_from_context(&core, &context)? else {
return Ok(None);
};
let Some(status) = manager.borrow_mut().view_status(key) else {
return Ok(None);
};
let table = lua.create_table()?;
table.set("at_bottom", status.at_bottom)?;
table.set("scroll_offset", status.scroll_offset)?;
table.set("selection", status.selection)?;
Ok(Some(table))
})?,
)?;
}
{
let manager = manager.clone();
terminal.set(
"scroll",
lua.create_function(move |lua, lines: i64| {
let lines = i32::try_from(lines).map_err(|_| {
mlua::Error::external("pmacs.terminal.scroll: `lines` exceeds i32 range")
})?;
let core = terminal_shared_core(lua, "scroll")?;
let key = active_terminal_view_key(lua, &core, "scroll")?;
Ok(manager.borrow_mut().scroll_lines(key, lines))
})?,
)?;
}
{
let manager = manager.clone();
terminal.set(
"_scroll_page",
lua.create_function(move |lua, direction: i64| {
let direction = i32::try_from(direction).map_err(|_| {
mlua::Error::external(
"pmacs.terminal._scroll_page: `direction` exceeds i32 range",
)
})?;
let core = terminal_shared_core(lua, "_scroll_page")?;
let key = active_terminal_view_key(lua, &core, "_scroll_page")?;
Ok(manager.borrow_mut().scroll_page(key, direction))
})?,
)?;
}
{
let manager = manager.clone();
terminal.set(
"scroll_to_bottom",
lua.create_function(move |lua, ()| {
let core = terminal_shared_core(lua, "scroll_to_bottom")?;
let key = active_terminal_view_key(lua, &core, "scroll_to_bottom")?;
Ok(manager.borrow_mut().scroll_to_bottom(key))
})?,
)?;
}
{
let manager = manager.clone();
terminal.set(
"copy_selection",
lua.create_function(move |lua, ()| {
let core = terminal_shared_core(lua, "copy_selection")?;
let key = active_terminal_view_key(lua, &core, "copy_selection")?;
let Some(bytes) = manager.borrow_mut().copy_selection(key) else {
return Ok(false);
};
core.borrow_mut()
.clipboard_set_for(key.frontend_id, bytes);
Ok(true)
})?,
)?;
}
pmacs.set("terminal", terminal)
}

View File

@ -3824,7 +3824,12 @@ mod tests {
let buffer_id = active_buffer(&state);
sem.set_viewport(buffer_id, ByteRange { start: 0, end: 80 }, 0);
let grid_msgs = grid.render_frame(&state, &[]);
let grid_msgs = grid.render_frame(
&state,
FrontendId::LOCAL,
&HashMap::new(),
&[],
);
let sem_msgs = sem.render_frame(&state);
assert!(
matches!(grid_msgs[0], InstanceMessage::CellDelta { .. }),