diff --git a/crates/lumbridge-runtime/src/lib.rs b/crates/lumbridge-runtime/src/lib.rs index 2386022..79b7dc5 100644 --- a/crates/lumbridge-runtime/src/lib.rs +++ b/crates/lumbridge-runtime/src/lib.rs @@ -8,6 +8,7 @@ pub use lumbridge_pty::{CommandConfig, PtyOptions, TerminalSize}; use lumbridge_pty::{ExitStatus, OutputEvent, PtyError, PtySession}; +use std::collections::BTreeMap; use std::num::NonZeroUsize; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::mpsc::{self, Receiver, RecvTimeoutError, SyncSender, TryRecvError, TrySendError}; @@ -113,6 +114,142 @@ pub enum RuntimeActorError { 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 { + actors: BTreeMap, +} + +impl Default for RuntimeRegistry { + fn default() -> Self { + Self { + actors: BTreeMap::new(), + } + } +} + +impl RuntimeRegistry { + #[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 { + self.actors.get(pane).map(RuntimeActor::session_id) + } + + #[must_use] + pub fn process_id(&self, pane: &K) -> Option { + 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 { + 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, 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 { + 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. pub struct RuntimeActor { session_id: RuntimeSessionId, @@ -411,6 +548,7 @@ fn send_fault( mod tests { use super::{ RuntimeActor, RuntimeActorError, RuntimeActorOptions, RuntimeCommand, RuntimeEvent, + RuntimeRegistry, RuntimeRegistryError, }; use lumbridge_pty::{CommandConfig, PtyOptions, TerminalSize}; use std::time::{Duration, Instant}; @@ -447,6 +585,36 @@ mod tests { } } + fn registry_events_until_exit(registry: &RuntimeRegistry, pane: u8) -> Vec { + 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::>(); + String::from_utf8_lossy(&bytes).replace('\r', "") + } + #[test] fn emits_started_ordered_output_and_exit() { let actor = actor("printf 'first\\n'; printf 'second\\n'; exit 7"); @@ -555,4 +723,97 @@ mod tests { 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(®istry_events_until_exit(®istry, 1)); + let right = output_text(®istry_events_until_exit(®istry, 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(®istry, 2); + assert!(output_text(&events).contains("got:still-running")); + } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0b64e4d..220b6c9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 queue as human input. See decision 0005. -The first `lumbridge-runtime` actor now owns one `PtySession` on a dedicated -thread. Bounded command and event queues serialize input, resize, close, and -shutdown against ordered raw-byte output. The GPUI slice feeds one actor session -through the terminal engine while five surfaces retain deterministic comparison -output. Its adapter groups adjacent cells into native GPUI paint runs and renders +Each `lumbridge-runtime` actor owns one `PtySession` on a dedicated thread. +Bounded command and event queues serialize input, resize, close, and shutdown +against ordered raw-byte output. A generic pane-indexed registry now owns and +routes multiple actors without knowing whether their panels are attached. The +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 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 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 durability step. See decisions 0004 and 0005. diff --git a/docs/TESTING.md b/docs/TESTING.md index 38082db..6bd0617 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -111,9 +111,10 @@ who already have the final cargo-watch release installed. input, resize, one-chunk backpressure, hung-process termination, invalid dimensions, and exclusion of provider credentials from the child environment. - `lumbridge-runtime` tests ordered actor output, serialized input and resize, - event polling, invalid configuration, and bounded-time cleanup of a hung PTY. - A cross-crate integration test drives a real synthetic shell through actor - output, VT parsing, terminal key encoding, and a 41×101 resize. + event polling, invalid configuration, bounded-time cleanup of a hung PTY, + pane-keyed input/output isolation, duplicate ownership, and independent + 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, alternate screen, bracketed paste, protocol replies, sanitized title and 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 geometry, selected-panel windowing, per-panel 20/60/20 PTY sizing, xterm 256-color conversion, styled-run coalescing, cursor-run boundaries, and - scrollback shortcut routing. It renders one real actor-owned VT session while - five surfaces continue their deterministic background workload. + scrollback shortcut routing. It renders three independently actor-owned VT + sessions while three non-terminal surfaces continue their deterministic + background workload. - `lumbridge-terminal` retains 10,000 history lines by default and tests framework-neutral page/top/bottom viewport movement, revision changes, and live-bottom no-ops without sending history-navigation bytes to the child PTY. diff --git a/docs/UI_SPIKE_SCORECARD.md b/docs/UI_SPIKE_SCORECARD.md index 5944070..9af95b2 100644 --- a/docs/UI_SPIKE_SCORECARD.md +++ b/docs/UI_SPIKE_SCORECARD.md @@ -91,9 +91,9 @@ fallback. 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 -own 20/60/20 context/work/decision composition, while one terminal fixture is -replaced with a real actor-owned VT session and five deterministic surfaces keep -running. Counters +own 20/60/20 context/work/decision composition, while all three terminal +fixtures are independent actor-owned VT sessions and the three non-terminal +surfaces keep deterministic updates running. Counters 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 is deliberately not called key-to-present or frame-present latency: neither diff --git a/docs/UX_VERTICAL_SLICE.md b/docs/UX_VERTICAL_SLICE.md index d666c71..54713c9 100644 --- a/docs/UX_VERTICAL_SLICE.md +++ b/docs/UX_VERTICAL_SLICE.md @@ -103,11 +103,11 @@ receive the same state transitions and tests. ## Decisive workload -- In the first actor integration, one terminal pane consumes ordered output from - a real local PTY while each deterministic tick updates the other five - surfaces. One, three, or five are visible according to available width; the - all-deterministic constructor remains available for framework comparison and - replay tests. +- Three terminal panes consume ordered output from independent local PTYs in a + pane-indexed runtime registry while each deterministic tick updates the three + non-terminal surfaces. One, three, or five are visible according to available + width; the all-deterministic constructor remains available for framework + comparison and replay tests. - One pane enters and leaves `needs input` through a deterministic event. - Markdown, browser-boundary, and review panes update counters without using a web application shell. diff --git a/docs/decisions/0004-runtime-actor-owns-each-pty.md b/docs/decisions/0004-runtime-actor-owns-each-pty.md index 2b80c7b..679bda5 100644 --- a/docs/decisions/0004-runtime-actor-owns-each-pty.md +++ b/docs/decisions/0004-runtime-actor-owns-each-pty.md @@ -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 `lumbridge-pty`, and joins the actor thread. Tests use only synthetic shell 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. diff --git a/docs/decisions/0009-responsive-ultrawide-work-lanes.md b/docs/decisions/0009-responsive-ultrawide-work-lanes.md index 601c9b9..6f5ee8e 100644 --- a/docs/decisions/0009-responsive-ultrawide-work-lanes.md +++ b/docs/decisions/0009-responsive-ultrawide-work-lanes.md @@ -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 derives approximately 71 columns by 42 rows per panel. -The first actor slice still owns one real PTY and five deterministic comparison -surfaces. Promoting the runtime boundary from one actor to a pane-indexed session -registry is required before all five visible terminal panels can own independent -real processes. +The runtime slice now owns three independent real PTYs through a pane-indexed +session registry, one for every terminal fixture. The Markdown, browser, and +review fixtures remain deterministic comparison surfaces. Adding arbitrary new +terminal panels requires replacing the fixed fixture identity pool with dynamic +pane creation; it does not require another runtime ownership design. diff --git a/spikes/gpui-shell/src/main.rs b/spikes/gpui-shell/src/main.rs index 28d8697..79cc6f4 100644 --- a/spikes/gpui-shell/src/main.rs +++ b/spikes/gpui-shell/src/main.rs @@ -1,4 +1,4 @@ -use std::collections::VecDeque; +use std::collections::{BTreeMap, VecDeque}; use std::time::{Duration, Instant}; use gpui::{ @@ -6,8 +6,8 @@ use gpui::{ Size, Window, WindowBounds, WindowOptions, actions, div, prelude::*, px, relative, rgb, size, }; use lumbridge_runtime::{ - CommandConfig, PtyOptions, RuntimeActor, RuntimeActorError, RuntimeActorOptions, - RuntimeCommand, RuntimeEvent, TerminalSize, + CommandConfig, PtyOptions, RuntimeActorError, RuntimeActorOptions, RuntimeCommand, + RuntimeEvent, RuntimeRegistry, RuntimeRegistryError, TerminalSize, }; use lumbridge_spike_model::{ ActionOutcome, FOOTER_RIGHT, FocusDirection, INITIAL_ATTACHED_PANEL_COUNT, OutputSource, @@ -33,8 +33,7 @@ const SUCCESS: u32 = 0x70d6a8; const TIMING_SAMPLE_LIMIT: usize = 256; const RUNTIME_POLL_INTERVAL: Duration = Duration::from_millis(16); const RUNTIME_DRAIN_LIMIT: usize = 64; -const LIVE_PANE: PaneId = PaneId::CodexRuntime; -const LIVE_PTY_SCRIPT: &str = "printf 'Lumbridge interactive PTY · type here\\n'; exec /bin/sh -i"; +const LIVE_PANES: [PaneId; 3] = [PaneId::CodexRuntime, PaneId::ClaudeUi, PaneId::PiDocs]; const SIDEBAR_WIDTH: f32 = 248.0; const APP_HEADER_HEIGHT: f32 = 48.0; const TAB_BAR_HEIGHT: f32 = 38.0; @@ -74,14 +73,34 @@ actions!( struct LumbridgeShell { model: ShellModel, timing: RenderTiming, - runtime: Option, - runtime_status: LiveRuntimeStatus, - terminal: TerminalEngine, - terminal_snapshot: TerminalSnapshot, - last_runtime_sequence: u64, + runtimes: RuntimeRegistry, + live_terminals: BTreeMap, 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)] enum LiveRuntimeStatus { Starting, @@ -409,34 +428,31 @@ impl LumbridgeShell { let terminal_dimensions = terminal_dimensions_for_window(window.bounds().size, INITIAL_ATTACHED_PANEL_COUNT); - let terminal = TerminalEngine::new(TerminalEngineOptions { - dimensions: terminal_dimensions, - ..TerminalEngineOptions::default() - }); - let terminal_snapshot = terminal.snapshot(); - let (runtime, runtime_status) = match start_live_runtime(terminal.dimensions()) { - Ok(runtime) => (Some(runtime), LiveRuntimeStatus::Starting), - Err(error) => (None, LiveRuntimeStatus::Fault(error.to_string())), - }; + let mut runtimes = RuntimeRegistry::new(); + let mut live_terminals = BTreeMap::new(); + for pane in LIVE_PANES { + let mut terminal = LiveTerminalState::new(terminal_dimensions); + if let Err(error) = spawn_live_runtime(&mut runtimes, pane, terminal_dimensions) { + terminal.status = LiveRuntimeStatus::Fault(error.to_string()); + } + live_terminals.insert(pane, terminal); + } cx.observe_window_bounds(window, |shell, window, cx| { let dimensions = terminal_dimensions_for_window( window.bounds().size, shell.model.attached_panel_count(), ); - shell.resize_terminal(dimensions.rows(), dimensions.columns(), cx); + shell.resize_attached_terminals(dimensions.rows(), dimensions.columns(), cx); }) .detach(); Self { - model: ShellModel::with_external_output(LIVE_PANE) - .expect("the live comparison pane is a terminal"), + model: ShellModel::with_external_outputs(LIVE_PANES) + .expect("all live comparison panes are terminals"), timing: RenderTiming::default(), - runtime, - runtime_status, - terminal, - terminal_snapshot, - last_runtime_sequence: 0, + runtimes, + live_terminals, root_focus, } } @@ -447,119 +463,179 @@ impl LumbridgeShell { } fn drain_runtime_events(&mut self) -> bool { - if self.runtime_status.is_terminal() { - return false; - } - let mut changed = false; - for _ in 0..RUNTIME_DRAIN_LIMIT { - let event = match self.runtime.as_ref().map(RuntimeActor::try_recv) { - Some(Ok(Some(event))) => event, - Some(Ok(None)) | None => break, - Some(Err(RuntimeActorError::Disconnected)) => { - self.runtime_status = - LiveRuntimeStatus::Fault("runtime actor disconnected".to_owned()); - changed = true; - break; - } - Some(Err(error)) => { - self.runtime_status = LiveRuntimeStatus::Fault(error.to_string()); - changed = true; - break; - } - }; + 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 { + let event = match self.runtimes.try_recv(&pane) { + Ok(Some(event)) => event, + Ok(None) => break, + Err(RuntimeRegistryError::Actor(RuntimeActorError::Disconnected)) => { + self.live_terminals + .get_mut(&pane) + .expect("live terminal state exists") + .status = + LiveRuntimeStatus::Fault("runtime actor disconnected".to_owned()); + changed = true; + break; + } + Err(error) => { + self.live_terminals + .get_mut(&pane) + .expect("live terminal state exists") + .status = LiveRuntimeStatus::Fault(error.to_string()); + changed = true; + break; + } + }; - match event { - RuntimeEvent::Started { - session_id, - process_id, - } => { - self.runtime_status = LiveRuntimeStatus::Running { - session_id: session_id.get(), + match event { + RuntimeEvent::Started { + session_id, process_id, - }; - changed = true; - } - RuntimeEvent::Output { - sequence, bytes, .. - } => { - if sequence <= self.last_runtime_sequence { - self.runtime_status = LiveRuntimeStatus::Fault(format!( - "non-monotonic PTY output sequence {sequence}" + } => { + self.live_terminals + .get_mut(&pane) + .expect("live terminal state exists") + .status = LiveRuntimeStatus::Running { + session_id: session_id.get(), + process_id, + }; + changed = true; + } + RuntimeEvent::Output { + sequence, bytes, .. + } => { + let responses = { + 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}" + )); + changed = true; + break; + } + terminal.last_runtime_sequence = sequence; + terminal.terminal.process(&bytes).outbound + }; + for response in responses { + if let Err(error) = + self.send_runtime_command(pane, RuntimeCommand::Input(response)) + { + self.live_terminals + .get_mut(&pane) + .expect("live terminal state exists") + .status = LiveRuntimeStatus::Fault(error.to_string()); + return true; + } + } + self.publish_terminal_snapshot(pane); + changed = true; + } + RuntimeEvent::InputClosed { .. } => {} + RuntimeEvent::Exited { status, .. } => { + self.live_terminals + .get_mut(&pane) + .expect("live terminal state exists") + .status = LiveRuntimeStatus::Exited(format!( + "PTY exited with code {}", + status.code )); changed = true; break; } - self.last_runtime_sequence = sequence; - let update = self.terminal.process(&bytes); - for response in update.outbound { - if let Err(error) = - self.send_runtime_command(RuntimeCommand::Input(response)) - { - self.runtime_status = LiveRuntimeStatus::Fault(error.to_string()); - return true; - } + RuntimeEvent::Fault { + operation, message, .. + } => { + self.live_terminals + .get_mut(&pane) + .expect("live terminal state exists") + .status = LiveRuntimeStatus::Fault(format!("{operation:?}: {message}")); + changed = true; + break; } - self.publish_terminal_snapshot(); - changed = true; - } - RuntimeEvent::InputClosed { .. } => {} - RuntimeEvent::Exited { status, .. } => { - self.runtime_status = - LiveRuntimeStatus::Exited(format!("PTY exited with code {}", status.code)); - changed = true; - break; - } - RuntimeEvent::Fault { - operation, message, .. - } => { - self.runtime_status = - LiveRuntimeStatus::Fault(format!("{operation:?}: {message}")); - changed = true; - break; } } } changed } - fn send_runtime_command(&self, command: RuntimeCommand) -> Result<(), RuntimeActorError> { - self.runtime - .as_ref() - .ok_or(RuntimeActorError::Disconnected)? - .try_send(command) + fn send_runtime_command( + &self, + pane: PaneId, + command: RuntimeCommand, + ) -> Result<(), RuntimeRegistryError> { + self.runtimes.try_send(&pane, command) } - fn publish_terminal_snapshot(&mut self) { - let snapshot = self.terminal.snapshot(); - let lines = snapshot.plain_rows(); - self.terminal_snapshot = snapshot; - self.dispatch(ShellAction::ReplaceExternalOutput { - pane: LIVE_PANE, - lines, - }); + fn publish_terminal_snapshot(&mut self, pane: PaneId) { + 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(); + terminal.snapshot = snapshot; + lines + }; + self.dispatch(ShellAction::ReplaceExternalOutput { pane, lines }); } - fn resize_terminal(&mut self, rows: u16, columns: u16, cx: &mut Context) { + fn resize_terminal(&mut self, pane: PaneId, rows: u16, columns: u16) -> bool { let dimensions = TerminalDimensions::new(rows, columns) .expect("resize actions always retain non-zero dimensions"); - if !self.terminal.resize(dimensions) { - return; + let terminal = self + .live_terminals + .get_mut(&pane) + .expect("live terminal state exists"); + if !terminal.terminal.resize(dimensions) { + return false; } let pty_size = TerminalSize::new(rows, columns) .expect("terminal engine dimensions are valid PTY dimensions"); - if let Err(error) = self.send_runtime_command(RuntimeCommand::Resize(pty_size)) { - self.runtime_status = LiveRuntimeStatus::Fault(error.to_string()); + if let Err(error) = self.send_runtime_command(pane, RuntimeCommand::Resize(pty_size)) { + self.live_terminals + .get_mut(&pane) + .expect("live terminal state exists") + .status = LiveRuntimeStatus::Fault(error.to_string()); } - self.publish_terminal_snapshot(); - cx.notify(); + self.publish_terminal_snapshot(pane); + true } - fn scroll_terminal(&mut self, scroll: TerminalScroll, cx: &mut Context) { - if !self.terminal.scroll_display(scroll) { + fn resize_attached_terminals(&mut self, rows: u16, columns: u16, cx: &mut Context) { + 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) { + if !self + .live_terminals + .get_mut(&pane) + .expect("live terminal state exists") + .terminal + .scroll_display(scroll) + { return; } - self.publish_terminal_snapshot(); + self.publish_terminal_snapshot(pane); cx.notify(); } @@ -572,7 +648,7 @@ impl LumbridgeShell { fn resize_terminal_for_workspace(&mut self, window: &Window, cx: &mut Context) { let dimensions = 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) { @@ -650,55 +726,83 @@ impl LumbridgeShell { 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) { - let dimensions = self.terminal.dimensions(); - self.resize_terminal( + let Some((pane, dimensions)) = self.selected_terminal_dimensions() else { + return; + }; + if self.resize_terminal( + pane, dimensions.rows().saturating_add(TERMINAL_ROW_STEP), dimensions.columns(), - cx, - ); + ) { + cx.notify(); + } } fn terminal_shorter(&mut self, _: &TerminalShorter, _: &mut Window, cx: &mut Context) { - let dimensions = self.terminal.dimensions(); - self.resize_terminal( + let Some((pane, dimensions)) = self.selected_terminal_dimensions() else { + return; + }; + if self.resize_terminal( + pane, dimensions.rows().saturating_sub(TERMINAL_ROW_STEP).max(2), dimensions.columns(), - cx, - ); + ) { + cx.notify(); + } } fn terminal_wider(&mut self, _: &TerminalWider, _: &mut Window, cx: &mut Context) { - let dimensions = self.terminal.dimensions(); - self.resize_terminal( + let Some((pane, dimensions)) = self.selected_terminal_dimensions() else { + return; + }; + if self.resize_terminal( + pane, dimensions.rows(), dimensions.columns().saturating_add(TERMINAL_COLUMN_STEP), - cx, - ); + ) { + cx.notify(); + } } fn terminal_narrower(&mut self, _: &TerminalNarrower, _: &mut Window, cx: &mut Context) { - let dimensions = self.terminal.dimensions(); - self.resize_terminal( + let Some((pane, dimensions)) = self.selected_terminal_dimensions() else { + return; + }; + if self.resize_terminal( + pane, dimensions.rows(), dimensions .columns() .saturating_sub(TERMINAL_COLUMN_STEP) .max(20), - cx, - ); + ) { + cx.notify(); + } } fn on_key_down(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context) { 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; } let modifiers = key_modifiers(event); if let Some(scroll) = terminal_scroll_from_parts(event.keystroke.key.as_str(), modifiers) { - self.scroll_terminal(scroll, cx); + self.scroll_terminal(pane, scroll, cx); return; } let Some(event) = terminal_key_from_parts( @@ -708,17 +812,31 @@ impl LumbridgeShell { ) else { 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() { return; } - if let Err(error) = self.send_runtime_command(RuntimeCommand::Input(bytes)) { - self.runtime_status = LiveRuntimeStatus::Fault(error.to_string()); + if let Err(error) = self.send_runtime_command(pane, RuntimeCommand::Input(bytes)) { + self.live_terminals + .get_mut(&pane) + .expect("selected live terminal exists") + .status = LiveRuntimeStatus::Fault(error.to_string()); } - if self.terminal.display_offset() > 0 - && self.terminal.scroll_display(TerminalScroll::Bottom) - { - self.publish_terminal_snapshot(); + let moved_to_bottom = { + let terminal = self + .live_terminals + .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(); return; @@ -798,8 +916,12 @@ impl LumbridgeShell { .into_any_element() } - fn terminal_view(&self) -> gpui::AnyElement { - let rows = terminal_paint_rows(&self.terminal_snapshot) + fn terminal_view(&self, pane: PaneId) -> gpui::AnyElement { + let terminal = self + .live_terminals + .get(&pane) + .expect("external terminal pane has live state"); + let rows = terminal_paint_rows(&terminal.snapshot) .into_iter() .map(|runs| { div() @@ -830,34 +952,38 @@ impl LumbridgeShell { let pane_id = pane.id(); let can_detach = self.model.attached_panel_count() > 1; let external = pane.output_source() == OutputSource::External; - let status = if external { - self.runtime_status.badge() - } else { - pane.fixture().badge - }; - let detail = if external { - self.runtime_status.detail() - } else { - pane.fixture().target.to_owned() - }; - let surface_status = if external && self.terminal_snapshot.display_offset > 0 { - format!( - "{} · ↑{} · {}×{}", - status, - self.terminal_snapshot.display_offset, - self.terminal.dimensions().columns(), - self.terminal.dimensions().rows() - ) - } else if external { - format!( - "{} · {}×{}", - status, - self.terminal.dimensions().columns(), - self.terminal.dimensions().rows() - ) - } else { - status.to_owned() - }; + let live_terminal = external.then(|| { + self.live_terminals + .get(&pane_id) + .expect("external terminal pane has live state") + }); + let status = live_terminal.map_or(pane.fixture().badge, |terminal| terminal.status.badge()); + let detail = live_terminal.map_or_else( + || pane.fixture().target.to_owned(), + |terminal| terminal.status.detail(), + ); + let surface_status = live_terminal.map_or_else( + || status.to_owned(), + |terminal| { + let dimensions = terminal.terminal.dimensions(); + if terminal.snapshot.display_offset > 0 { + format!( + "{} · ↑{} · {}×{}", + status, + terminal.snapshot.display_offset, + dimensions.columns(), + dimensions.rows() + ) + } else { + format!( + "{} · {}×{}", + status, + dimensions.columns(), + dimensions.rows() + ) + } + }, + ); let surface = match pane.kind() { SurfaceKind::Terminal => "TERMINAL", SurfaceKind::Markdown => "CONTEXT", @@ -993,7 +1119,7 @@ impl LumbridgeShell { fn pane_work_surface(&self, pane: &PaneState) -> gpui::AnyElement { let external = pane.output_source() == OutputSource::External; let content = if external { - self.terminal_view() + self.terminal_view(pane.id()) } else { let start = pane.lines().len().saturating_sub(18); div() @@ -1261,6 +1387,15 @@ impl Render for LumbridgeShell { .collect::>(); let detached_count = detached_entries.len(); 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() .flex() .flex_col() @@ -1347,7 +1482,7 @@ impl Render for LumbridgeShell { .py_1() .text_sm() .text_color(rgb(SUCCESS)) - .child(format!("metal · {}", self.runtime_status.badge())), + .child(format!("metal · {runtime_summary}")), ) .child( div() @@ -1496,10 +1631,16 @@ impl Render for LumbridgeShell { .children(workspace_panels); let counters = self.model.counters(); + let terminal_revision = self + .live_terminals + .values() + .map(|terminal| terminal.terminal.revision()) + .sum::(); 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.terminal.revision(), + self.runtimes.len(), + terminal_revision, counters.focus_moves, counters.external_snapshot_updates, counters.terminal_lines_appended @@ -1574,7 +1715,7 @@ impl Render for LumbridgeShell { .mr_4() .text_xs() .text_color(rgb(SUCCESS)) - .child(format!("metal · {}", self.runtime_status.badge())), + .child(format!("metal · {runtime_summary}")), ) .child( div() @@ -1685,17 +1826,38 @@ fn terminal_scroll_from_parts(key: &str, modifiers: KeyModifiers) -> Option Result { +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, + pane: PaneId, + dimensions: TerminalDimensions, +) -> Result<(), RuntimeRegistryError> { let command = CommandConfig::new("/bin/sh") .map_err(RuntimeActorError::Start)? - .args(["-c", LIVE_PTY_SCRIPT]); + .args(["-c", live_pty_script(pane)]); let pty_size = TerminalSize::new(dimensions.rows(), dimensions.columns()) .map_err(RuntimeActorError::Start)?; - RuntimeActor::spawn( + runtimes.spawn( + pane, command, PtyOptions::new(pty_size), RuntimeActorOptions::default(), - ) + )?; + Ok(()) } fn main() { diff --git a/spikes/ui-shell-model/src/lib.rs b/spikes/ui-shell-model/src/lib.rs index ac4f2ba..6d290e9 100644 --- a/spikes/ui-shell-model/src/lib.rs +++ b/spikes/ui-shell-model/src/lib.rs @@ -404,13 +404,27 @@ impl ShellModel { /// /// Returns [`InvalidExternalOutputPane`] when `pane` is not a terminal. pub fn with_external_output(pane: PaneId) -> Result { + 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, + ) -> Result { let mut model = Self::default(); - let pane_state = &mut model.panes[pane.index()]; - if pane_state.kind() != SurfaceKind::Terminal { - return Err(InvalidExternalOutputPane); + for pane in panes { + let pane_state = &mut model.panes[pane.index()]; + if pane_state.kind() != SurfaceKind::Terminal { + return Err(InvalidExternalOutputPane); + } + pane_state.output_source = OutputSource::External; + pane_state.lines.clear(); } - pane_state.output_source = OutputSource::External; - pane_state.lines.clear(); Ok(model) } @@ -990,6 +1004,32 @@ mod tests { 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] fn external_output_rejects_nonterminals_and_deterministic_panes() { assert!(ShellModel::with_external_output(PaneId::Architecture).is_err());