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};
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<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.
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<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]
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(&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
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.
+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
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.
+3 -3
View File
@@ -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
+5 -5
View File
@@ -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.
@@ -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.
@@ -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.
+339 -177
View File
@@ -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<RuntimeActor>,
runtime_status: LiveRuntimeStatus,
terminal: TerminalEngine,
terminal_snapshot: TerminalSnapshot,
last_runtime_sequence: u64,
runtimes: RuntimeRegistry<PaneId>,
live_terminals: BTreeMap<PaneId, LiveTerminalState>,
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<Self>) {
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<Self>) {
if !self.terminal.scroll_display(scroll) {
fn resize_attached_terminals(&mut self, rows: u16, columns: u16, cx: &mut Context<Self>) {
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;
}
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<Self>) {
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<Self>) {
@@ -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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
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::<Vec<_>>();
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::<u64>();
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<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")
.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() {
+45 -5
View File
@@ -404,13 +404,27 @@ impl ShellModel {
///
/// Returns [`InvalidExternalOutputPane`] when `pane` is not a terminal.
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 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());