feat(vterm): establish Stage 2 view authority contracts

Add exact frontend/window/buffer view identities, logical-cell selections,
durable controller ownership, lifecycle cleanup, default-name uniquification,
and a nested authenticated interactive-command origin shared with Lua. These
contracts let the terminal core and TUI lanes proceed without inventing
parallel state.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-07-21 18:56:15 -04:00
parent 9dafdd4c89
commit 1b32f81033
5 changed files with 260 additions and 13 deletions

View File

@ -12,7 +12,7 @@
//! them through the dispatcher, and invokes the resulting Lua commands
//! until the user quits.
use std::cell::RefCell;
use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::io;
use std::path::PathBuf;
@ -37,6 +37,43 @@ use crate::protocol::FrontendId;
use crate::view::{View, Viewport};
use crate::window::{Rect, WindowId};
/// Ephemeral authenticated origin for one interactive command invocation.
///
/// The shared slot is installed as Lua app data so Rust dispatch and nested
/// `pmacs.command.invoke_interactive` calls use the same authority. Guards
/// restore the prior value, which makes nesting safe and clears the outermost
/// origin even when a Lua command errors.
#[derive(Clone, Default)]
pub(crate) struct InteractiveCommandOrigin(Rc<Cell<Option<FrontendId>>>);
impl InteractiveCommandOrigin {
/// Current authenticated frontend while an interactive command runs.
#[must_use]
pub(crate) fn current(&self) -> Option<FrontendId> {
self.0.get()
}
/// Enter an interactive command scope for `frontend_id`.
pub(crate) fn enter(&self, frontend_id: FrontendId) -> InteractiveCommandOriginGuard {
let previous = self.0.replace(Some(frontend_id));
InteractiveCommandOriginGuard {
origin: self.clone(),
previous,
}
}
}
pub(crate) struct InteractiveCommandOriginGuard {
origin: InteractiveCommandOrigin,
previous: Option<FrontendId>,
}
impl Drop for InteractiveCommandOriginGuard {
fn drop(&mut self) {
self.origin.0.set(self.previous);
}
}
// ---------------------------------------------------------------------------
// EditorState
// ---------------------------------------------------------------------------
@ -50,6 +87,8 @@ pub struct EditorState {
pub lua_host: LuaHost,
/// Multi-key prefix state machine. Driven by the run loop.
pub dispatcher: KeyDispatcher,
/// Authenticated frontend scoped to the current interactive invocation.
pub(crate) interactive_origin: InteractiveCommandOrigin,
/// Main-thread async runtime (T M3.3). Owns the worker pool and
/// the message bus pair; [`Self::tick_async`] drives one
/// drain-and-resume pass per run-loop iteration.
@ -177,6 +216,8 @@ impl EditorState {
Rc::new(RefCell::new(crate::buffer_registry::BufferRegistry::new()));
let core = Rc::new(RefCell::new(EditorCore::new(registry.clone())));
let mut lua_host = LuaHost::with_registry(registry).expect("Lua runtime initialization");
let interactive_origin = InteractiveCommandOrigin::default();
lua_host.lua().set_app_data(interactive_origin.clone());
lua_host
.attach_editor(&core)
.expect("editor bindings + builtin chunks");
@ -487,6 +528,7 @@ impl EditorState {
core,
lua_host,
dispatcher: KeyDispatcher::new(),
interactive_origin,
async_runtime,
syntax_registry,
process_supervisor,

View File

@ -5239,11 +5239,18 @@ fn install_command_module(lua: &Lua, commands: &SharedCommandRegistry) -> mlua::
command.set(
"invoke_interactive",
lua.create_function(move |lua, (name, args): (String, Variadic<Value>)| {
if let Some(core) = lua.app_data_ref::<SharedCore>() {
let frontend_id = lua.app_data_ref::<SharedCore>().map(|core| {
let mut core = core.borrow_mut();
let fid = core.active_frontend;
core.rotate_command(fid, &name);
}
let frontend_id = core.active_frontend;
core.rotate_command(frontend_id, &name);
frontend_id
});
let origin = lua
.app_data_ref::<crate::editor::InteractiveCommandOrigin>()
.map(|origin| origin.clone());
let _origin_guard = frontend_id
.zip(origin.as_ref())
.map(|(frontend_id, origin)| origin.enter(frontend_id));
let body = {
let r = cmds.borrow();
r.get(&name)

View File

@ -8,11 +8,16 @@ pub mod input;
/// Stateful terminal screen model.
pub mod screen;
pub mod session;
/// Per-context terminal viewport, selection, and controller identities.
pub mod view;
pub use session::{
SharedTerminalManager, TerminalError, TerminalManager, TerminalProcessState,
TerminalSelectionSpan, TerminalSnapshot, TerminalSpec,
};
pub use view::{
LogicalCellAnchor, TerminalController, TerminalSelection, TerminalViewKey, TerminalViewState,
};
/// Maximum terminal rows accepted at creation or resize.
pub const MAX_TERMINAL_ROWS: u16 = 512;

View File

@ -17,6 +17,7 @@ use crate::process::{
RestartPolicy, StdinMode, TerminalMode,
};
use crate::terminal::screen::TerminalScreen;
use crate::terminal::view::{TerminalController, TerminalViewKey, TerminalViewState};
use crate::terminal::{
MAX_TERMINAL_COLS, MAX_TERMINAL_HISTORY_CELLS, MAX_TERMINAL_METADATA_BYTES, MAX_TERMINAL_ROWS,
MAX_TERMINAL_VISIBLE_CELLS,
@ -206,22 +207,26 @@ pub enum TerminalError {
Process(String),
}
struct TerminalSession {
process_id: ProcessId,
pid: u32,
screen: TerminalScreen,
process: TerminalProcessState,
annotated: bool,
pub(super) struct TerminalSession {
pub(super) process_id: ProcessId,
pub(super) pid: u32,
pub(super) screen: TerminalScreen,
pub(super) process: TerminalProcessState,
pub(super) annotated: bool,
}
/// Owns the one-buffer/one-process/one-screen terminal registry.
#[derive(Default)]
pub struct TerminalManager {
sessions: HashMap<BufferId, TerminalSession>,
pub(super) sessions: HashMap<BufferId, TerminalSession>,
process_to_buffer: HashMap<ProcessId, BufferId>,
/// Removed buffers whose children are still being reaped. Their events
/// remain manager-owned so Lua/LSP/MCP consumers cannot steal a batch.
closing: HashSet<ProcessId>,
/// Per-frontend/window projections over the one session screen.
pub(super) views: HashMap<TerminalViewKey, TerminalViewState>,
/// At most one authenticated frontend/window controls each session PTY.
pub(super) controllers: HashMap<BufferId, TerminalController>,
}
impl TerminalManager {
@ -255,7 +260,12 @@ impl TerminalManager {
let screen = TerminalScreen::new(size, spec.scrollback_rows)
.map_err(|error| TerminalError::Screen(error.to_string()))?;
let buffer_name = spec.buffer_name();
let base_name = spec.buffer_name();
let buffer_name = if spec.name.is_some() {
base_name
} else {
unique_terminal_name(core, &base_name)
};
let buffer_id = BufferId::next();
let mut buffer = Buffer::new(buffer_id, buffer_name.clone());
buffer.set_read_only(true);
@ -338,6 +348,85 @@ impl TerminalManager {
.map(|session| session.process_id)
}
/// Ensure an exact terminal view exists without changing its controller.
///
/// Returns `false` when the key's buffer is not a published terminal.
pub fn register_view(&mut self, key: TerminalViewKey) -> bool {
if !self.sessions.contains_key(&key.buffer_id) {
return false;
}
self.views.entry(key).or_default();
true
}
/// Borrow fresh mutable state for an already registered exact view.
pub fn view_state_mut(&mut self, key: TerminalViewKey) -> Option<&mut TerminalViewState> {
self.views.get_mut(&key)
}
/// Borrow fresh state for an already registered exact view.
#[must_use]
pub fn view_state(&self, key: TerminalViewKey) -> Option<&TerminalViewState> {
self.views.get(&key)
}
/// Retain only `live` views belonging to one authenticated frontend.
pub fn retain_frontend_views(
&mut self,
frontend_id: crate::protocol::FrontendId,
live: &HashSet<TerminalViewKey>,
) {
self.views.retain(|key, _| {
key.frontend_id != frontend_id
|| (live.contains(key) && self.sessions.contains_key(&key.buffer_id))
});
self.controllers.retain(|buffer_id, controller| {
controller.frontend_id != frontend_id
|| live.contains(&TerminalViewKey::new(
frontend_id,
controller.window_id,
*buffer_id,
))
});
}
/// Drop all view and controller state owned by a detached frontend.
pub fn detach_frontend(&mut self, frontend_id: crate::protocol::FrontendId) {
self.views.retain(|key, _| key.frontend_id != frontend_id);
self.controllers
.retain(|_, controller| controller.frontend_id != frontend_id);
}
/// Give an exact registered view durable PTY control for its session.
pub fn claim_controller(&mut self, key: TerminalViewKey) -> bool {
if !self.views.contains_key(&key) || !self.sessions.contains_key(&key.buffer_id) {
return false;
}
self.controllers
.insert(key.buffer_id, TerminalController::from_view(key));
true
}
/// Release control only when `key` is the current controller.
pub fn release_controller(&mut self, key: TerminalViewKey) -> bool {
if self
.controllers
.get(&key.buffer_id)
.is_some_and(|controller| controller.matches(key))
{
self.controllers.remove(&key.buffer_id);
true
} else {
false
}
}
/// Current durable controller for one terminal session.
#[must_use]
pub fn controller(&self, buffer_id: BufferId) -> Option<TerminalController> {
self.controllers.get(&buffer_id).copied()
}
/// Capture context-free owned visible state after the latest tick.
#[must_use]
pub fn snapshot(&self, buffer_id: BufferId) -> Option<TerminalSnapshot> {
@ -488,6 +577,8 @@ impl TerminalManager {
continue;
};
self.process_to_buffer.remove(&session.process_id);
self.views.retain(|key, _| key.buffer_id != buffer_id);
self.controllers.remove(&buffer_id);
match supervisor.state(session.process_id) {
Some(
ProcessState::Starting
@ -528,9 +619,25 @@ impl TerminalManager {
}
self.sessions.clear();
self.process_to_buffer.clear();
self.views.clear();
self.controllers.clear();
}
}
fn unique_terminal_name(core: &EditorCore, base: &str) -> String {
let registry = core.registry.borrow();
if registry.find_by_name(base).is_none() {
return base.to_owned();
}
for suffix in 2usize.. {
let candidate = format!("{base}<{suffix}>");
if registry.find_by_name(&candidate).is_none() {
return candidate;
}
}
unreachable!("unbounded terminal suffix search must find a free name")
}
fn finish_session(session: &mut TerminalSession, outcome: TerminalProcessState) {
if session.annotated {
session.process = outcome;

86
src/terminal/view.rs Normal file
View File

@ -0,0 +1,86 @@
//! Per-frontend terminal viewport and selection identities.
//!
//! These types identify projections over one [`super::screen::TerminalScreen`].
//! They never own or mirror terminal cells.
use crate::buffer::BufferId;
use crate::protocol::FrontendId;
use crate::window::WindowId;
/// One frontend/window projection of a terminal session.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct TerminalViewKey {
/// Authenticated frontend that owns this view state.
pub frontend_id: FrontendId,
/// Stable editor window showing the terminal.
pub window_id: WindowId,
/// Identity buffer whose session is projected.
pub buffer_id: BufferId,
}
impl TerminalViewKey {
/// Construct an exact terminal view identity.
#[must_use]
pub const fn new(frontend_id: FrontendId, window_id: WindowId, buffer_id: BufferId) -> Self {
Self {
frontend_id,
window_id,
buffer_id,
}
}
}
/// Leading display-cell offset within one retained logical line.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct LogicalCellAnchor {
/// Stable logical line identity preserved by main-screen reflow.
pub logical_line_id: u64,
/// Leading display-cell offset within that logical line.
pub cell_offset: u32,
}
/// Inclusive terminal selection endpoints.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct TerminalSelection {
/// Fixed endpoint where the drag began.
pub anchor: LogicalCellAnchor,
/// Moving endpoint under the pointer.
pub head: LogicalCellAnchor,
}
/// Mutable state for one [`TerminalViewKey`].
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct TerminalViewState {
/// First visible retained logical cell, or live-tail following when absent.
pub top: Option<LogicalCellAnchor>,
/// Inclusive logical-cell selection.
pub selection: Option<TerminalSelection>,
/// Current editor-owned drag endpoint; cleared on release.
pub drag: Option<LogicalCellAnchor>,
}
/// The one authenticated frontend/window allowed to control a session's PTY.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct TerminalController {
/// Authenticated controlling frontend.
pub frontend_id: FrontendId,
/// Active terminal window on that frontend.
pub window_id: WindowId,
}
impl TerminalController {
/// Construct a controller from an exact view identity.
#[must_use]
pub const fn from_view(key: TerminalViewKey) -> Self {
Self {
frontend_id: key.frontend_id,
window_id: key.window_id,
}
}
/// Whether this controller names `key`'s frontend and window.
#[must_use]
pub fn matches(self, key: TerminalViewKey) -> bool {
self.frontend_id == key.frontend_id && self.window_id == key.window_id
}
}