feat: index runtime sessions by pane
CI / rust (push) Successful in 4m19s

This commit is contained in:
2026-08-31 18:29:28 -07:00
parent abcf664ab8
commit 4ed7613b22
9 changed files with 679 additions and 205 deletions
+261
View File
@@ -8,6 +8,7 @@
pub use lumbridge_pty::{CommandConfig, PtyOptions, TerminalSize}; pub use lumbridge_pty::{CommandConfig, PtyOptions, TerminalSize};
use lumbridge_pty::{ExitStatus, OutputEvent, PtyError, PtySession}; use lumbridge_pty::{ExitStatus, OutputEvent, PtyError, PtySession};
use std::collections::BTreeMap;
use std::num::NonZeroUsize; use std::num::NonZeroUsize;
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc::{self, Receiver, RecvTimeoutError, SyncSender, TryRecvError, TrySendError}; use std::sync::mpsc::{self, Receiver, RecvTimeoutError, SyncSender, TryRecvError, TrySendError};
@@ -113,6 +114,142 @@ pub enum RuntimeActorError {
InvalidPollInterval, InvalidPollInterval,
} }
#[derive(Debug, Error)]
pub enum RuntimeRegistryError {
#[error("a runtime session already exists for this pane")]
SessionAlreadyExists,
#[error("no runtime session exists for this pane")]
SessionNotFound,
#[error(transparent)]
Actor(#[from] RuntimeActorError),
}
/// Owns independent runtime actors by stable pane identity.
///
/// Layout attachment is deliberately absent from this type. Detaching a panel
/// must not remove its actor; only an explicit shutdown terminates the session.
pub struct RuntimeRegistry<K> {
actors: BTreeMap<K, RuntimeActor>,
}
impl<K> Default for RuntimeRegistry<K> {
fn default() -> Self {
Self {
actors: BTreeMap::new(),
}
}
}
impl<K: Ord> RuntimeRegistry<K> {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn len(&self) -> usize {
self.actors.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.actors.is_empty()
}
#[must_use]
pub fn contains(&self, pane: &K) -> bool {
self.actors.contains_key(pane)
}
#[must_use]
pub fn session_id(&self, pane: &K) -> Option<RuntimeSessionId> {
self.actors.get(pane).map(RuntimeActor::session_id)
}
#[must_use]
pub fn process_id(&self, pane: &K) -> Option<u32> {
self.actors.get(pane).and_then(RuntimeActor::process_id)
}
/// Starts and registers one pane-owned actor.
///
/// # Errors
///
/// Rejects duplicate pane ownership or propagates PTY/actor startup errors.
pub fn spawn(
&mut self,
pane: K,
command: CommandConfig,
pty_options: PtyOptions,
actor_options: RuntimeActorOptions,
) -> Result<RuntimeSessionId, RuntimeRegistryError> {
if self.actors.contains_key(&pane) {
return Err(RuntimeRegistryError::SessionAlreadyExists);
}
let actor = RuntimeActor::spawn(command, pty_options, actor_options)?;
let session_id = actor.session_id();
self.actors.insert(pane, actor);
Ok(session_id)
}
/// Enqueues a command for one pane without blocking.
///
/// # Errors
///
/// Reports a missing pane, actor disconnection, or bounded backpressure.
pub fn try_send(&self, pane: &K, command: RuntimeCommand) -> Result<(), RuntimeRegistryError> {
self.actors
.get(pane)
.ok_or(RuntimeRegistryError::SessionNotFound)?
.try_send(command)?;
Ok(())
}
/// Polls one pane's next ordered event without blocking.
///
/// # Errors
///
/// Reports a missing pane or actor disconnection.
pub fn try_recv(&self, pane: &K) -> Result<Option<RuntimeEvent>, RuntimeRegistryError> {
Ok(self
.actors
.get(pane)
.ok_or(RuntimeRegistryError::SessionNotFound)?
.try_recv()?)
}
/// Waits for one pane's next ordered event up to `timeout`.
///
/// # Errors
///
/// Reports a missing pane, timeout, or actor disconnection.
pub fn recv_timeout(
&self,
pane: &K,
timeout: Duration,
) -> Result<RuntimeEvent, RuntimeRegistryError> {
Ok(self
.actors
.get(pane)
.ok_or(RuntimeRegistryError::SessionNotFound)?
.recv_timeout(timeout)?)
}
/// Explicitly terminates and removes one pane-owned actor.
///
/// # Errors
///
/// Reports a missing pane, actor disconnection, or actor-thread panic.
pub fn shutdown(&mut self, pane: &K) -> Result<(), RuntimeRegistryError> {
let actor = self
.actors
.remove(pane)
.ok_or(RuntimeRegistryError::SessionNotFound)?;
actor.shutdown()?;
Ok(())
}
}
/// UI-side owner of one runtime actor and its bounded channels. /// UI-side owner of one runtime actor and its bounded channels.
pub struct RuntimeActor { pub struct RuntimeActor {
session_id: RuntimeSessionId, session_id: RuntimeSessionId,
@@ -411,6 +548,7 @@ fn send_fault(
mod tests { mod tests {
use super::{ use super::{
RuntimeActor, RuntimeActorError, RuntimeActorOptions, RuntimeCommand, RuntimeEvent, RuntimeActor, RuntimeActorError, RuntimeActorOptions, RuntimeCommand, RuntimeEvent,
RuntimeRegistry, RuntimeRegistryError,
}; };
use lumbridge_pty::{CommandConfig, PtyOptions, TerminalSize}; use lumbridge_pty::{CommandConfig, PtyOptions, TerminalSize};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -447,6 +585,36 @@ mod tests {
} }
} }
fn registry_events_until_exit(registry: &RuntimeRegistry<u8>, pane: u8) -> Vec<RuntimeEvent> {
let deadline = Instant::now() + TEST_TIMEOUT;
let mut events = Vec::new();
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
assert!(!remaining.is_zero(), "timed out waiting for registry exit");
let event = registry
.recv_timeout(&pane, remaining)
.expect("runtime registry event");
let exited = matches!(event, RuntimeEvent::Exited { .. });
events.push(event);
if exited {
return events;
}
}
}
fn output_text(events: &[RuntimeEvent]) -> String {
let bytes = events
.iter()
.filter_map(|event| match event {
RuntimeEvent::Output { bytes, .. } => Some(bytes.as_slice()),
_ => None,
})
.flatten()
.copied()
.collect::<Vec<_>>();
String::from_utf8_lossy(&bytes).replace('\r', "")
}
#[test] #[test]
fn emits_started_ordered_output_and_exit() { fn emits_started_ordered_output_and_exit() {
let actor = actor("printf 'first\\n'; printf 'second\\n'; exit 7"); let actor = actor("printf 'first\\n'; printf 'second\\n'; exit 7");
@@ -555,4 +723,97 @@ mod tests {
Err(RuntimeActorError::InvalidPollInterval) Err(RuntimeActorError::InvalidPollInterval)
)); ));
} }
#[test]
fn registry_routes_input_and_output_by_pane() {
let mut registry = RuntimeRegistry::new();
let options = RuntimeActorOptions::default();
let left_id = registry
.spawn(
1,
shell("IFS= read -r line; printf 'left:%s\\n' \"$line\""),
PtyOptions::default(),
options,
)
.unwrap();
let right_id = registry
.spawn(
2,
shell("IFS= read -r line; printf 'right:%s\\n' \"$line\""),
PtyOptions::default(),
options,
)
.unwrap();
assert_ne!(left_id, right_id);
assert_eq!(registry.len(), 2);
registry
.try_send(&1, RuntimeCommand::Input(b"alpha\n".to_vec()))
.unwrap();
registry
.try_send(&2, RuntimeCommand::Input(b"beta\n".to_vec()))
.unwrap();
let left = output_text(&registry_events_until_exit(&registry, 1));
let right = output_text(&registry_events_until_exit(&registry, 2));
assert!(left.contains("left:alpha"), "unexpected output: {left:?}");
assert!(!left.contains("beta"), "cross-pane output: {left:?}");
assert!(right.contains("right:beta"), "unexpected output: {right:?}");
assert!(!right.contains("alpha"), "cross-pane output: {right:?}");
}
#[test]
fn registry_rejects_duplicates_and_missing_panes() {
let mut registry = RuntimeRegistry::new();
registry
.spawn(
1,
shell("sleep 30"),
PtyOptions::default(),
RuntimeActorOptions::default(),
)
.unwrap();
assert!(matches!(
registry.spawn(
1,
shell("exit 0"),
PtyOptions::default(),
RuntimeActorOptions::default(),
),
Err(RuntimeRegistryError::SessionAlreadyExists)
));
assert!(matches!(
registry.try_send(&2, RuntimeCommand::CloseInput),
Err(RuntimeRegistryError::SessionNotFound)
));
assert!(matches!(
registry.try_recv(&2),
Err(RuntimeRegistryError::SessionNotFound)
));
registry.shutdown(&1).unwrap();
assert!(registry.is_empty());
}
#[test]
fn shutting_down_one_registry_entry_leaves_its_sibling_running() {
let mut registry = RuntimeRegistry::new();
for pane in [1, 2] {
registry
.spawn(
pane,
shell("IFS= read -r line; printf 'got:%s\\n' \"$line\""),
PtyOptions::default(),
RuntimeActorOptions::default(),
)
.unwrap();
}
registry.shutdown(&1).unwrap();
assert!(!registry.contains(&1));
assert!(registry.contains(&2));
registry
.try_send(&2, RuntimeCommand::Input(b"still-running\n".to_vec()))
.unwrap();
let events = registry_events_until_exit(&registry, 2);
assert!(output_text(&events).contains("got:still-running"));
}
} }
+8 -6
View File
@@ -65,16 +65,18 @@ clipboard writes are disabled by default. The renderer never imports the
upstream terminal type, and protocol replies return through the same actor input upstream terminal type, and protocol replies return through the same actor input
queue as human input. See decision 0005. queue as human input. See decision 0005.
The first `lumbridge-runtime` actor now owns one `PtySession` on a dedicated Each `lumbridge-runtime` actor owns one `PtySession` on a dedicated thread.
thread. Bounded command and event queues serialize input, resize, close, and Bounded command and event queues serialize input, resize, close, and shutdown
shutdown against ordered raw-byte output. The GPUI slice feeds one actor session against ordered raw-byte output. A generic pane-indexed registry now owns and
through the terminal engine while five surfaces retain deterministic comparison routes multiple actors without knowing whether their panels are attached. The
output. Its adapter groups adjacent cells into native GPUI paint runs and renders GPUI slice feeds three independent actor sessions through three terminal engines
while three non-terminal surfaces retain deterministic comparison output. Its
adapter groups adjacent cells into native GPUI paint runs and renders
ANSI/indexed/RGB colors, emphasis, hyperlinks, and cursor shapes. Window geometry ANSI/indexed/RGB colors, emphasis, hyperlinks, and cursor shapes. Window geometry
drives terminal rows and columns and resizes both the engine and PTY. The engine drives terminal rows and columns and resizes both the engine and PTY. The engine
now exposes retained-history offsets and page/top/bottom viewport movement now exposes retained-history offsets and page/top/bottom viewport movement
without writing scroll keys to the PTY. Text selection, mouse reporting, and a without writing scroll keys to the PTY. Text selection, mouse reporting, and a
lower-level terminal canvas remain. This actor still runs in-process; moving the lower-level terminal canvas remain. These actors still run in-process; moving the
same framework-neutral contract behind local authenticated IPC is the next same framework-neutral contract behind local authenticated IPC is the next
durability step. See decisions 0004 and 0005. durability step. See decisions 0004 and 0005.
+7 -5
View File
@@ -111,9 +111,10 @@ who already have the final cargo-watch release installed.
input, resize, one-chunk backpressure, hung-process termination, invalid input, resize, one-chunk backpressure, hung-process termination, invalid
dimensions, and exclusion of provider credentials from the child environment. dimensions, and exclusion of provider credentials from the child environment.
- `lumbridge-runtime` tests ordered actor output, serialized input and resize, - `lumbridge-runtime` tests ordered actor output, serialized input and resize,
event polling, invalid configuration, and bounded-time cleanup of a hung PTY. event polling, invalid configuration, bounded-time cleanup of a hung PTY,
A cross-crate integration test drives a real synthetic shell through actor pane-keyed input/output isolation, duplicate ownership, and independent
output, VT parsing, terminal key encoding, and a 41×101 resize. shutdown. A cross-crate integration test drives a real synthetic shell through
actor output, VT parsing, terminal key encoding, and a 41×101 resize.
- `lumbridge-terminal` fixtures cover styled Unicode, cursor/title state, - `lumbridge-terminal` fixtures cover styled Unicode, cursor/title state,
alternate screen, bracketed paste, protocol replies, sanitized title and alternate screen, bracketed paste, protocol replies, sanitized title and
blocked OSC 52 behavior, key encoding, application-cursor mode, and resize. blocked OSC 52 behavior, key encoding, application-cursor mode, and resize.
@@ -123,8 +124,9 @@ who already have the final cargo-watch release installed.
- The GPUI slice tests its key-event adapter, responsive one/three/five-panel - The GPUI slice tests its key-event adapter, responsive one/three/five-panel
geometry, selected-panel windowing, per-panel 20/60/20 PTY sizing, xterm geometry, selected-panel windowing, per-panel 20/60/20 PTY sizing, xterm
256-color conversion, styled-run coalescing, cursor-run boundaries, and 256-color conversion, styled-run coalescing, cursor-run boundaries, and
scrollback shortcut routing. It renders one real actor-owned VT session while scrollback shortcut routing. It renders three independently actor-owned VT
five surfaces continue their deterministic background workload. sessions while three non-terminal surfaces continue their deterministic
background workload.
- `lumbridge-terminal` retains 10,000 history lines by default and tests - `lumbridge-terminal` retains 10,000 history lines by default and tests
framework-neutral page/top/bottom viewport movement, revision changes, and framework-neutral page/top/bottom viewport movement, revision changes, and
live-bottom no-ops without sending history-navigation bytes to the child PTY. live-bottom no-ops without sending history-navigation bytes to the child PTY.
+3 -3
View File
@@ -91,9 +91,9 @@ fallback.
Both renderers retain the same all-deterministic six-surface action stream for Both renderers retain the same all-deterministic six-surface action stream for
comparison. GPUI now presents one, three, or five vertical panels, each with its comparison. GPUI now presents one, three, or five vertical panels, each with its
own 20/60/20 context/work/decision composition, while one terminal fixture is own 20/60/20 context/work/decision composition, while all three terminal
replaced with a real actor-owned VT session and five deterministic surfaces keep fixtures are independent actor-owned VT sessions and the three non-terminal
running. Counters surfaces keep deterministic updates running. Counters
separate external PTY batches/lines from total model updates. The GPUI footer separate external PTY batches/lines from total model updates. The GPUI footer
reports dispatch-to-element-build p50/p95 over a bounded 256-sample window. It reports dispatch-to-element-build p50/p95 over a bounded 256-sample window. It
is deliberately not called key-to-present or frame-present latency: neither is deliberately not called key-to-present or frame-present latency: neither
+5 -5
View File
@@ -103,11 +103,11 @@ receive the same state transitions and tests.
## Decisive workload ## Decisive workload
- In the first actor integration, one terminal pane consumes ordered output from - Three terminal panes consume ordered output from independent local PTYs in a
a real local PTY while each deterministic tick updates the other five pane-indexed runtime registry while each deterministic tick updates the three
surfaces. One, three, or five are visible according to available width; the non-terminal surfaces. One, three, or five are visible according to available
all-deterministic constructor remains available for framework comparison and width; the all-deterministic constructor remains available for framework
replay tests. comparison and replay tests.
- One pane enters and leaves `needs input` through a deterministic event. - One pane enters and leaves `needs input` through a deterministic event.
- Markdown, browser-boundary, and review panes update counters without using a - Markdown, browser-boundary, and review panes update counters without using a
web application shell. web application shell.
@@ -25,3 +25,9 @@ Dropping the UI-side actor disconnects its bounded channels, releases an actor
blocked by event backpressure, terminates the process group through blocked by event backpressure, terminates the process group through
`lumbridge-pty`, and joins the actor thread. Tests use only synthetic shell `lumbridge-pty`, and joins the actor thread. Tests use only synthetic shell
fixtures and never provider credentials or real transcripts. fixtures and never provider credentials or real transcripts.
A pane-indexed registry now owns multiple actors and routes commands and events
without exposing pane identity to the actor itself. Duplicate ownership is
rejected before spawning. Detaching a panel does not touch this registry;
explicit registry shutdown is the process-terminating operation. The GPUI slice
currently owns three registry entries, one for each terminal fixture.
@@ -20,7 +20,8 @@ resized through the runtime actor when the responsive panel count or window
bounds change. On the 3440×1440 reference display the current fixed-cell spike bounds change. On the 3440×1440 reference display the current fixed-cell spike
derives approximately 71 columns by 42 rows per panel. derives approximately 71 columns by 42 rows per panel.
The first actor slice still owns one real PTY and five deterministic comparison The runtime slice now owns three independent real PTYs through a pane-indexed
surfaces. Promoting the runtime boundary from one actor to a pane-indexed session session registry, one for every terminal fixture. The Markdown, browser, and
registry is required before all five visible terminal panels can own independent review fixtures remain deterministic comparison surfaces. Adding arbitrary new
real processes. terminal panels requires replacing the fixed fixture identity pool with dynamic
pane creation; it does not require another runtime ownership design.
+292 -130
View File
@@ -1,4 +1,4 @@
use std::collections::VecDeque; use std::collections::{BTreeMap, VecDeque};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use gpui::{ use gpui::{
@@ -6,8 +6,8 @@ use gpui::{
Size, Window, WindowBounds, WindowOptions, actions, div, prelude::*, px, relative, rgb, size, Size, Window, WindowBounds, WindowOptions, actions, div, prelude::*, px, relative, rgb, size,
}; };
use lumbridge_runtime::{ use lumbridge_runtime::{
CommandConfig, PtyOptions, RuntimeActor, RuntimeActorError, RuntimeActorOptions, CommandConfig, PtyOptions, RuntimeActorError, RuntimeActorOptions, RuntimeCommand,
RuntimeCommand, RuntimeEvent, TerminalSize, RuntimeEvent, RuntimeRegistry, RuntimeRegistryError, TerminalSize,
}; };
use lumbridge_spike_model::{ use lumbridge_spike_model::{
ActionOutcome, FOOTER_RIGHT, FocusDirection, INITIAL_ATTACHED_PANEL_COUNT, OutputSource, ActionOutcome, FOOTER_RIGHT, FocusDirection, INITIAL_ATTACHED_PANEL_COUNT, OutputSource,
@@ -33,8 +33,7 @@ const SUCCESS: u32 = 0x70d6a8;
const TIMING_SAMPLE_LIMIT: usize = 256; const TIMING_SAMPLE_LIMIT: usize = 256;
const RUNTIME_POLL_INTERVAL: Duration = Duration::from_millis(16); const RUNTIME_POLL_INTERVAL: Duration = Duration::from_millis(16);
const RUNTIME_DRAIN_LIMIT: usize = 64; const RUNTIME_DRAIN_LIMIT: usize = 64;
const LIVE_PANE: PaneId = PaneId::CodexRuntime; const LIVE_PANES: [PaneId; 3] = [PaneId::CodexRuntime, PaneId::ClaudeUi, PaneId::PiDocs];
const LIVE_PTY_SCRIPT: &str = "printf 'Lumbridge interactive PTY · type here\\n'; exec /bin/sh -i";
const SIDEBAR_WIDTH: f32 = 248.0; const SIDEBAR_WIDTH: f32 = 248.0;
const APP_HEADER_HEIGHT: f32 = 48.0; const APP_HEADER_HEIGHT: f32 = 48.0;
const TAB_BAR_HEIGHT: f32 = 38.0; const TAB_BAR_HEIGHT: f32 = 38.0;
@@ -74,14 +73,34 @@ actions!(
struct LumbridgeShell { struct LumbridgeShell {
model: ShellModel, model: ShellModel,
timing: RenderTiming, timing: RenderTiming,
runtime: Option<RuntimeActor>, runtimes: RuntimeRegistry<PaneId>,
runtime_status: LiveRuntimeStatus, live_terminals: BTreeMap<PaneId, LiveTerminalState>,
terminal: TerminalEngine,
terminal_snapshot: TerminalSnapshot,
last_runtime_sequence: u64,
root_focus: FocusHandle, root_focus: FocusHandle,
} }
struct LiveTerminalState {
status: LiveRuntimeStatus,
terminal: TerminalEngine,
snapshot: TerminalSnapshot,
last_runtime_sequence: u64,
}
impl LiveTerminalState {
fn new(dimensions: TerminalDimensions) -> Self {
let terminal = TerminalEngine::new(TerminalEngineOptions {
dimensions,
..TerminalEngineOptions::default()
});
let snapshot = terminal.snapshot();
Self {
status: LiveRuntimeStatus::Starting,
terminal,
snapshot,
last_runtime_sequence: 0,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
enum LiveRuntimeStatus { enum LiveRuntimeStatus {
Starting, Starting,
@@ -409,34 +428,31 @@ impl LumbridgeShell {
let terminal_dimensions = let terminal_dimensions =
terminal_dimensions_for_window(window.bounds().size, INITIAL_ATTACHED_PANEL_COUNT); terminal_dimensions_for_window(window.bounds().size, INITIAL_ATTACHED_PANEL_COUNT);
let terminal = TerminalEngine::new(TerminalEngineOptions { let mut runtimes = RuntimeRegistry::new();
dimensions: terminal_dimensions, let mut live_terminals = BTreeMap::new();
..TerminalEngineOptions::default() for pane in LIVE_PANES {
}); let mut terminal = LiveTerminalState::new(terminal_dimensions);
let terminal_snapshot = terminal.snapshot(); if let Err(error) = spawn_live_runtime(&mut runtimes, pane, terminal_dimensions) {
let (runtime, runtime_status) = match start_live_runtime(terminal.dimensions()) { terminal.status = LiveRuntimeStatus::Fault(error.to_string());
Ok(runtime) => (Some(runtime), LiveRuntimeStatus::Starting), }
Err(error) => (None, LiveRuntimeStatus::Fault(error.to_string())), live_terminals.insert(pane, terminal);
}; }
cx.observe_window_bounds(window, |shell, window, cx| { cx.observe_window_bounds(window, |shell, window, cx| {
let dimensions = terminal_dimensions_for_window( let dimensions = terminal_dimensions_for_window(
window.bounds().size, window.bounds().size,
shell.model.attached_panel_count(), shell.model.attached_panel_count(),
); );
shell.resize_terminal(dimensions.rows(), dimensions.columns(), cx); shell.resize_attached_terminals(dimensions.rows(), dimensions.columns(), cx);
}) })
.detach(); .detach();
Self { Self {
model: ShellModel::with_external_output(LIVE_PANE) model: ShellModel::with_external_outputs(LIVE_PANES)
.expect("the live comparison pane is a terminal"), .expect("all live comparison panes are terminals"),
timing: RenderTiming::default(), timing: RenderTiming::default(),
runtime, runtimes,
runtime_status, live_terminals,
terminal,
terminal_snapshot,
last_runtime_sequence: 0,
root_focus, root_focus,
} }
} }
@@ -447,23 +463,33 @@ impl LumbridgeShell {
} }
fn drain_runtime_events(&mut self) -> bool { fn drain_runtime_events(&mut self) -> bool {
if self.runtime_status.is_terminal() {
return false;
}
let mut changed = false; let mut changed = false;
for pane in LIVE_PANES {
if self
.live_terminals
.get(&pane)
.is_none_or(|terminal| terminal.status.is_terminal())
{
continue;
}
for _ in 0..RUNTIME_DRAIN_LIMIT { for _ in 0..RUNTIME_DRAIN_LIMIT {
let event = match self.runtime.as_ref().map(RuntimeActor::try_recv) { let event = match self.runtimes.try_recv(&pane) {
Some(Ok(Some(event))) => event, Ok(Some(event)) => event,
Some(Ok(None)) | None => break, Ok(None) => break,
Some(Err(RuntimeActorError::Disconnected)) => { Err(RuntimeRegistryError::Actor(RuntimeActorError::Disconnected)) => {
self.runtime_status = self.live_terminals
.get_mut(&pane)
.expect("live terminal state exists")
.status =
LiveRuntimeStatus::Fault("runtime actor disconnected".to_owned()); LiveRuntimeStatus::Fault("runtime actor disconnected".to_owned());
changed = true; changed = true;
break; break;
} }
Some(Err(error)) => { Err(error) => {
self.runtime_status = LiveRuntimeStatus::Fault(error.to_string()); self.live_terminals
.get_mut(&pane)
.expect("live terminal state exists")
.status = LiveRuntimeStatus::Fault(error.to_string());
changed = true; changed = true;
break; break;
} }
@@ -474,7 +500,10 @@ impl LumbridgeShell {
session_id, session_id,
process_id, process_id,
} => { } => {
self.runtime_status = LiveRuntimeStatus::Running { self.live_terminals
.get_mut(&pane)
.expect("live terminal state exists")
.status = LiveRuntimeStatus::Running {
session_id: session_id.get(), session_id: session_id.get(),
process_id, process_id,
}; };
@@ -483,83 +512,130 @@ impl LumbridgeShell {
RuntimeEvent::Output { RuntimeEvent::Output {
sequence, bytes, .. sequence, bytes, ..
} => { } => {
if sequence <= self.last_runtime_sequence { let responses = {
self.runtime_status = LiveRuntimeStatus::Fault(format!( let terminal = self
.live_terminals
.get_mut(&pane)
.expect("live terminal state exists");
if sequence <= terminal.last_runtime_sequence {
terminal.status = LiveRuntimeStatus::Fault(format!(
"non-monotonic PTY output sequence {sequence}" "non-monotonic PTY output sequence {sequence}"
)); ));
changed = true; changed = true;
break; break;
} }
self.last_runtime_sequence = sequence; terminal.last_runtime_sequence = sequence;
let update = self.terminal.process(&bytes); terminal.terminal.process(&bytes).outbound
for response in update.outbound { };
for response in responses {
if let Err(error) = if let Err(error) =
self.send_runtime_command(RuntimeCommand::Input(response)) self.send_runtime_command(pane, RuntimeCommand::Input(response))
{ {
self.runtime_status = LiveRuntimeStatus::Fault(error.to_string()); self.live_terminals
.get_mut(&pane)
.expect("live terminal state exists")
.status = LiveRuntimeStatus::Fault(error.to_string());
return true; return true;
} }
} }
self.publish_terminal_snapshot(); self.publish_terminal_snapshot(pane);
changed = true; changed = true;
} }
RuntimeEvent::InputClosed { .. } => {} RuntimeEvent::InputClosed { .. } => {}
RuntimeEvent::Exited { status, .. } => { RuntimeEvent::Exited { status, .. } => {
self.runtime_status = self.live_terminals
LiveRuntimeStatus::Exited(format!("PTY exited with code {}", status.code)); .get_mut(&pane)
.expect("live terminal state exists")
.status = LiveRuntimeStatus::Exited(format!(
"PTY exited with code {}",
status.code
));
changed = true; changed = true;
break; break;
} }
RuntimeEvent::Fault { RuntimeEvent::Fault {
operation, message, .. operation, message, ..
} => { } => {
self.runtime_status = self.live_terminals
LiveRuntimeStatus::Fault(format!("{operation:?}: {message}")); .get_mut(&pane)
.expect("live terminal state exists")
.status = LiveRuntimeStatus::Fault(format!("{operation:?}: {message}"));
changed = true; changed = true;
break; break;
} }
} }
} }
}
changed changed
} }
fn send_runtime_command(&self, command: RuntimeCommand) -> Result<(), RuntimeActorError> { fn send_runtime_command(
self.runtime &self,
.as_ref() pane: PaneId,
.ok_or(RuntimeActorError::Disconnected)? command: RuntimeCommand,
.try_send(command) ) -> Result<(), RuntimeRegistryError> {
self.runtimes.try_send(&pane, command)
} }
fn publish_terminal_snapshot(&mut self) { fn publish_terminal_snapshot(&mut self, pane: PaneId) {
let snapshot = self.terminal.snapshot(); let lines = {
let terminal = self
.live_terminals
.get_mut(&pane)
.expect("live terminal state exists");
let snapshot = terminal.terminal.snapshot();
let lines = snapshot.plain_rows(); let lines = snapshot.plain_rows();
self.terminal_snapshot = snapshot; terminal.snapshot = snapshot;
self.dispatch(ShellAction::ReplaceExternalOutput { lines
pane: LIVE_PANE, };
lines, self.dispatch(ShellAction::ReplaceExternalOutput { pane, lines });
});
} }
fn resize_terminal(&mut self, rows: u16, columns: u16, cx: &mut Context<Self>) { fn resize_terminal(&mut self, pane: PaneId, rows: u16, columns: u16) -> bool {
let dimensions = TerminalDimensions::new(rows, columns) let dimensions = TerminalDimensions::new(rows, columns)
.expect("resize actions always retain non-zero dimensions"); .expect("resize actions always retain non-zero dimensions");
if !self.terminal.resize(dimensions) { let terminal = self
return; .live_terminals
.get_mut(&pane)
.expect("live terminal state exists");
if !terminal.terminal.resize(dimensions) {
return false;
} }
let pty_size = TerminalSize::new(rows, columns) let pty_size = TerminalSize::new(rows, columns)
.expect("terminal engine dimensions are valid PTY dimensions"); .expect("terminal engine dimensions are valid PTY dimensions");
if let Err(error) = self.send_runtime_command(RuntimeCommand::Resize(pty_size)) { if let Err(error) = self.send_runtime_command(pane, RuntimeCommand::Resize(pty_size)) {
self.runtime_status = LiveRuntimeStatus::Fault(error.to_string()); self.live_terminals
.get_mut(&pane)
.expect("live terminal state exists")
.status = LiveRuntimeStatus::Fault(error.to_string());
} }
self.publish_terminal_snapshot(); self.publish_terminal_snapshot(pane);
cx.notify(); true
} }
fn scroll_terminal(&mut self, scroll: TerminalScroll, cx: &mut Context<Self>) { fn resize_attached_terminals(&mut self, rows: u16, columns: u16, cx: &mut Context<Self>) {
if !self.terminal.scroll_display(scroll) { let mut changed = false;
for pane in LIVE_PANES {
if self.model.is_panel_attached(pane) {
changed |= self.resize_terminal(pane, rows, columns);
}
}
if changed {
cx.notify();
}
}
fn scroll_terminal(&mut self, pane: PaneId, scroll: TerminalScroll, cx: &mut Context<Self>) {
if !self
.live_terminals
.get_mut(&pane)
.expect("live terminal state exists")
.terminal
.scroll_display(scroll)
{
return; return;
} }
self.publish_terminal_snapshot(); self.publish_terminal_snapshot(pane);
cx.notify(); cx.notify();
} }
@@ -572,7 +648,7 @@ impl LumbridgeShell {
fn resize_terminal_for_workspace(&mut self, window: &Window, cx: &mut Context<Self>) { fn resize_terminal_for_workspace(&mut self, window: &Window, cx: &mut Context<Self>) {
let dimensions = let dimensions =
terminal_dimensions_for_window(window.bounds().size, self.model.attached_panel_count()); terminal_dimensions_for_window(window.bounds().size, self.model.attached_panel_count());
self.resize_terminal(dimensions.rows(), dimensions.columns(), cx); self.resize_attached_terminals(dimensions.rows(), dimensions.columns(), cx);
} }
fn attach_panel(&mut self, pane: PaneId, window: &mut Window, cx: &mut Context<Self>) { fn attach_panel(&mut self, pane: PaneId, window: &mut Window, cx: &mut Context<Self>) {
@@ -650,55 +726,83 @@ impl LumbridgeShell {
cx.notify(); cx.notify();
} }
fn selected_terminal_dimensions(&self) -> Option<(PaneId, TerminalDimensions)> {
let pane = self.model.selected_pane();
self.live_terminals
.get(&pane)
.map(|terminal| (pane, terminal.terminal.dimensions()))
}
fn terminal_taller(&mut self, _: &TerminalTaller, _: &mut Window, cx: &mut Context<Self>) { fn terminal_taller(&mut self, _: &TerminalTaller, _: &mut Window, cx: &mut Context<Self>) {
let dimensions = self.terminal.dimensions(); let Some((pane, dimensions)) = self.selected_terminal_dimensions() else {
self.resize_terminal( return;
};
if self.resize_terminal(
pane,
dimensions.rows().saturating_add(TERMINAL_ROW_STEP), dimensions.rows().saturating_add(TERMINAL_ROW_STEP),
dimensions.columns(), dimensions.columns(),
cx, ) {
); cx.notify();
}
} }
fn terminal_shorter(&mut self, _: &TerminalShorter, _: &mut Window, cx: &mut Context<Self>) { fn terminal_shorter(&mut self, _: &TerminalShorter, _: &mut Window, cx: &mut Context<Self>) {
let dimensions = self.terminal.dimensions(); let Some((pane, dimensions)) = self.selected_terminal_dimensions() else {
self.resize_terminal( return;
};
if self.resize_terminal(
pane,
dimensions.rows().saturating_sub(TERMINAL_ROW_STEP).max(2), dimensions.rows().saturating_sub(TERMINAL_ROW_STEP).max(2),
dimensions.columns(), dimensions.columns(),
cx, ) {
); cx.notify();
}
} }
fn terminal_wider(&mut self, _: &TerminalWider, _: &mut Window, cx: &mut Context<Self>) { fn terminal_wider(&mut self, _: &TerminalWider, _: &mut Window, cx: &mut Context<Self>) {
let dimensions = self.terminal.dimensions(); let Some((pane, dimensions)) = self.selected_terminal_dimensions() else {
self.resize_terminal( return;
};
if self.resize_terminal(
pane,
dimensions.rows(), dimensions.rows(),
dimensions.columns().saturating_add(TERMINAL_COLUMN_STEP), dimensions.columns().saturating_add(TERMINAL_COLUMN_STEP),
cx, ) {
); cx.notify();
}
} }
fn terminal_narrower(&mut self, _: &TerminalNarrower, _: &mut Window, cx: &mut Context<Self>) { fn terminal_narrower(&mut self, _: &TerminalNarrower, _: &mut Window, cx: &mut Context<Self>) {
let dimensions = self.terminal.dimensions(); let Some((pane, dimensions)) = self.selected_terminal_dimensions() else {
self.resize_terminal( return;
};
if self.resize_terminal(
pane,
dimensions.rows(), dimensions.rows(),
dimensions dimensions
.columns() .columns()
.saturating_sub(TERMINAL_COLUMN_STEP) .saturating_sub(TERMINAL_COLUMN_STEP)
.max(20), .max(20),
cx, ) {
); cx.notify();
}
} }
fn on_key_down(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context<Self>) { fn on_key_down(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context<Self>) {
if !self.model.command_palette().is_open() { if !self.model.command_palette().is_open() {
if self.model.selected_pane() != LIVE_PANE || self.runtime_status.is_terminal() { let pane = self.model.selected_pane();
if self
.live_terminals
.get(&pane)
.is_none_or(|terminal| terminal.status.is_terminal())
{
return; return;
} }
let modifiers = key_modifiers(event); let modifiers = key_modifiers(event);
if let Some(scroll) = if let Some(scroll) =
terminal_scroll_from_parts(event.keystroke.key.as_str(), modifiers) terminal_scroll_from_parts(event.keystroke.key.as_str(), modifiers)
{ {
self.scroll_terminal(scroll, cx); self.scroll_terminal(pane, scroll, cx);
return; return;
} }
let Some(event) = terminal_key_from_parts( let Some(event) = terminal_key_from_parts(
@@ -708,17 +812,31 @@ impl LumbridgeShell {
) else { ) else {
return; return;
}; };
let bytes = self.terminal.encode_key(&event); let bytes = self
.live_terminals
.get(&pane)
.expect("selected live terminal exists")
.terminal
.encode_key(&event);
if bytes.is_empty() { if bytes.is_empty() {
return; return;
} }
if let Err(error) = self.send_runtime_command(RuntimeCommand::Input(bytes)) { if let Err(error) = self.send_runtime_command(pane, RuntimeCommand::Input(bytes)) {
self.runtime_status = LiveRuntimeStatus::Fault(error.to_string()); self.live_terminals
.get_mut(&pane)
.expect("selected live terminal exists")
.status = LiveRuntimeStatus::Fault(error.to_string());
} }
if self.terminal.display_offset() > 0 let moved_to_bottom = {
&& self.terminal.scroll_display(TerminalScroll::Bottom) let terminal = self
{ .live_terminals
self.publish_terminal_snapshot(); .get_mut(&pane)
.expect("selected live terminal exists");
terminal.terminal.display_offset() > 0
&& terminal.terminal.scroll_display(TerminalScroll::Bottom)
};
if moved_to_bottom {
self.publish_terminal_snapshot(pane);
} }
cx.notify(); cx.notify();
return; return;
@@ -798,8 +916,12 @@ impl LumbridgeShell {
.into_any_element() .into_any_element()
} }
fn terminal_view(&self) -> gpui::AnyElement { fn terminal_view(&self, pane: PaneId) -> gpui::AnyElement {
let rows = terminal_paint_rows(&self.terminal_snapshot) let terminal = self
.live_terminals
.get(&pane)
.expect("external terminal pane has live state");
let rows = terminal_paint_rows(&terminal.snapshot)
.into_iter() .into_iter()
.map(|runs| { .map(|runs| {
div() div()
@@ -830,34 +952,38 @@ impl LumbridgeShell {
let pane_id = pane.id(); let pane_id = pane.id();
let can_detach = self.model.attached_panel_count() > 1; let can_detach = self.model.attached_panel_count() > 1;
let external = pane.output_source() == OutputSource::External; let external = pane.output_source() == OutputSource::External;
let status = if external { let live_terminal = external.then(|| {
self.runtime_status.badge() self.live_terminals
} else { .get(&pane_id)
pane.fixture().badge .expect("external terminal pane has live state")
}; });
let detail = if external { let status = live_terminal.map_or(pane.fixture().badge, |terminal| terminal.status.badge());
self.runtime_status.detail() let detail = live_terminal.map_or_else(
} else { || pane.fixture().target.to_owned(),
pane.fixture().target.to_owned() |terminal| terminal.status.detail(),
}; );
let surface_status = if external && self.terminal_snapshot.display_offset > 0 { let surface_status = live_terminal.map_or_else(
|| status.to_owned(),
|terminal| {
let dimensions = terminal.terminal.dimensions();
if terminal.snapshot.display_offset > 0 {
format!( format!(
"{} · ↑{} · {}×{}", "{} · ↑{} · {}×{}",
status, status,
self.terminal_snapshot.display_offset, terminal.snapshot.display_offset,
self.terminal.dimensions().columns(), dimensions.columns(),
self.terminal.dimensions().rows() dimensions.rows()
) )
} else if external { } else {
format!( format!(
"{} · {}×{}", "{} · {}×{}",
status, status,
self.terminal.dimensions().columns(), dimensions.columns(),
self.terminal.dimensions().rows() dimensions.rows()
) )
} else { }
status.to_owned() },
}; );
let surface = match pane.kind() { let surface = match pane.kind() {
SurfaceKind::Terminal => "TERMINAL", SurfaceKind::Terminal => "TERMINAL",
SurfaceKind::Markdown => "CONTEXT", SurfaceKind::Markdown => "CONTEXT",
@@ -993,7 +1119,7 @@ impl LumbridgeShell {
fn pane_work_surface(&self, pane: &PaneState) -> gpui::AnyElement { fn pane_work_surface(&self, pane: &PaneState) -> gpui::AnyElement {
let external = pane.output_source() == OutputSource::External; let external = pane.output_source() == OutputSource::External;
let content = if external { let content = if external {
self.terminal_view() self.terminal_view(pane.id())
} else { } else {
let start = pane.lines().len().saturating_sub(18); let start = pane.lines().len().saturating_sub(18);
div() div()
@@ -1261,6 +1387,15 @@ impl Render for LumbridgeShell {
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let detached_count = detached_entries.len(); let detached_count = detached_entries.len();
let visible_count = panel_capacity.min(attached_count).max(1); let visible_count = panel_capacity.min(attached_count).max(1);
let running_runtime_count = self
.live_terminals
.values()
.filter(|terminal| matches!(terminal.status, LiveRuntimeStatus::Running { .. }))
.count();
let runtime_summary = format!(
"{running_runtime_count}/{} LIVE PTYS",
self.live_terminals.len()
);
let sidebar = div() let sidebar = div()
.flex() .flex()
.flex_col() .flex_col()
@@ -1347,7 +1482,7 @@ impl Render for LumbridgeShell {
.py_1() .py_1()
.text_sm() .text_sm()
.text_color(rgb(SUCCESS)) .text_color(rgb(SUCCESS))
.child(format!("metal · {}", self.runtime_status.badge())), .child(format!("metal · {runtime_summary}")),
) )
.child( .child(
div() div()
@@ -1496,10 +1631,16 @@ impl Render for LumbridgeShell {
.children(workspace_panels); .children(workspace_panels);
let counters = self.model.counters(); let counters = self.model.counters();
let terminal_revision = self
.live_terminals
.values()
.map(|terminal| terminal.terminal.revision())
.sum::<u64>();
let footer_left = format!( let footer_left = format!(
"rev {} · VT rev {} · {} focus · {} VT snapshots · {} synthetic lines", "rev {} · {} PTYs · VT rev {} · {} focus · {} VT snapshots · {} synthetic lines",
self.model.revision(), self.model.revision(),
self.terminal.revision(), self.runtimes.len(),
terminal_revision,
counters.focus_moves, counters.focus_moves,
counters.external_snapshot_updates, counters.external_snapshot_updates,
counters.terminal_lines_appended counters.terminal_lines_appended
@@ -1574,7 +1715,7 @@ impl Render for LumbridgeShell {
.mr_4() .mr_4()
.text_xs() .text_xs()
.text_color(rgb(SUCCESS)) .text_color(rgb(SUCCESS))
.child(format!("metal · {}", self.runtime_status.badge())), .child(format!("metal · {runtime_summary}")),
) )
.child( .child(
div() div()
@@ -1685,17 +1826,38 @@ fn terminal_scroll_from_parts(key: &str, modifiers: KeyModifiers) -> Option<Term
} }
} }
fn start_live_runtime(dimensions: TerminalDimensions) -> Result<RuntimeActor, RuntimeActorError> { fn live_pty_script(pane: PaneId) -> &'static str {
match pane {
PaneId::CodexRuntime => {
"printf 'Codex runtime · independent Lumbridge PTY\\n'; exec /bin/sh -i"
}
PaneId::ClaudeUi => {
"printf 'Claude workspace · independent Lumbridge PTY\\n'; exec /bin/sh -i"
}
PaneId::PiDocs => "printf 'Pi docs · independent Lumbridge PTY\\n'; exec /bin/sh -i",
PaneId::Architecture | PaneId::AcpPreview | PaneId::RuntimeReview => {
unreachable!("only terminal fixtures own live PTYs")
}
}
}
fn spawn_live_runtime(
runtimes: &mut RuntimeRegistry<PaneId>,
pane: PaneId,
dimensions: TerminalDimensions,
) -> Result<(), RuntimeRegistryError> {
let command = CommandConfig::new("/bin/sh") let command = CommandConfig::new("/bin/sh")
.map_err(RuntimeActorError::Start)? .map_err(RuntimeActorError::Start)?
.args(["-c", LIVE_PTY_SCRIPT]); .args(["-c", live_pty_script(pane)]);
let pty_size = TerminalSize::new(dimensions.rows(), dimensions.columns()) let pty_size = TerminalSize::new(dimensions.rows(), dimensions.columns())
.map_err(RuntimeActorError::Start)?; .map_err(RuntimeActorError::Start)?;
RuntimeActor::spawn( runtimes.spawn(
pane,
command, command,
PtyOptions::new(pty_size), PtyOptions::new(pty_size),
RuntimeActorOptions::default(), RuntimeActorOptions::default(),
) )?;
Ok(())
} }
fn main() { fn main() {
+40
View File
@@ -404,13 +404,27 @@ impl ShellModel {
/// ///
/// Returns [`InvalidExternalOutputPane`] when `pane` is not a terminal. /// Returns [`InvalidExternalOutputPane`] when `pane` is not a terminal.
pub fn with_external_output(pane: PaneId) -> Result<Self, InvalidExternalOutputPane> { pub fn with_external_output(pane: PaneId) -> Result<Self, InvalidExternalOutputPane> {
Self::with_external_outputs([pane])
}
/// Creates the comparison model with actor-driven output for each named
/// terminal while non-terminal surfaces retain deterministic updates.
///
/// # Errors
///
/// Returns [`InvalidExternalOutputPane`] if any pane is not a terminal.
pub fn with_external_outputs(
panes: impl IntoIterator<Item = PaneId>,
) -> Result<Self, InvalidExternalOutputPane> {
let mut model = Self::default(); let mut model = Self::default();
for pane in panes {
let pane_state = &mut model.panes[pane.index()]; let pane_state = &mut model.panes[pane.index()];
if pane_state.kind() != SurfaceKind::Terminal { if pane_state.kind() != SurfaceKind::Terminal {
return Err(InvalidExternalOutputPane); return Err(InvalidExternalOutputPane);
} }
pane_state.output_source = OutputSource::External; pane_state.output_source = OutputSource::External;
pane_state.lines.clear(); pane_state.lines.clear();
}
Ok(model) Ok(model)
} }
@@ -990,6 +1004,32 @@ mod tests {
assert_eq!(model.counters().external_snapshot_updates, 1); assert_eq!(model.counters().external_snapshot_updates, 1);
} }
#[test]
fn three_external_terminals_keep_independent_output_and_three_fixtures() {
let mut model = ShellModel::with_external_outputs([
PaneId::CodexRuntime,
PaneId::ClaudeUi,
PaneId::PiDocs,
])
.unwrap();
let tick = model.dispatch(ShellAction::SyntheticStreamTick);
assert_eq!(tick.terminal_lines_appended, 0);
assert_eq!(model.counters().surface_updates, 3);
model.dispatch(ShellAction::ReplaceExternalOutput {
pane: PaneId::ClaudeUi,
lines: vec!["claude shell".into()],
});
model.dispatch(ShellAction::ReplaceExternalOutput {
pane: PaneId::PiDocs,
lines: vec!["pi shell".into()],
});
assert_eq!(model.pane(PaneId::ClaudeUi).lines(), ["claude shell"]);
assert_eq!(model.pane(PaneId::PiDocs).lines(), ["pi shell"]);
assert!(model.pane(PaneId::CodexRuntime).lines().is_empty());
assert_eq!(model.counters().external_snapshot_updates, 2);
}
#[test] #[test]
fn external_output_rejects_nonterminals_and_deterministic_panes() { fn external_output_rejects_nonterminals_and_deterministic_panes() {
assert!(ShellModel::with_external_output(PaneId::Architecture).is_err()); assert!(ShellModel::with_external_output(PaneId::Architecture).is_err());