From 32d190c6c635f8d4802a57a6c27a89bfe2454f06 Mon Sep 17 00:00:00 2001 From: Kartios Date: Mon, 31 Aug 2026 16:32:50 -0700 Subject: [PATCH] Add bounded runtime actor and live PTY pane --- Cargo.lock | 8 + Cargo.toml | 1 + README.md | 7 +- crates/lumbridge-runtime/Cargo.toml | 15 + crates/lumbridge-runtime/src/lib.rs | 558 ++++++++++++++++++ docs/ARCHITECTURE.md | 11 +- docs/TESTING.md | 5 + docs/UI_SPIKE_SCORECARD.md | 15 +- docs/UX_VERTICAL_SLICE.md | 11 +- .../0004-runtime-actor-owns-each-pty.md | 26 + spikes/README.md | 5 + spikes/gpui-shell/Cargo.lock | 105 +++- spikes/gpui-shell/Cargo.toml | 1 + spikes/gpui-shell/src/main.rs | 275 ++++++++- spikes/ui-shell-model/src/lib.rs | 107 +++- 15 files changed, 1111 insertions(+), 39 deletions(-) create mode 100644 crates/lumbridge-runtime/Cargo.toml create mode 100644 crates/lumbridge-runtime/src/lib.rs create mode 100644 docs/decisions/0004-runtime-actor-owns-each-pty.md diff --git a/Cargo.lock b/Cargo.lock index 3a45450..9753810 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -761,6 +761,14 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "lumbridge-runtime" +version = "0.0.1" +dependencies = [ + "lumbridge-pty", + "thiserror 2.0.20", +] + [[package]] name = "lumbridge-storage" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index 0474323..da2d268 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "crates/lumbridge-buzz", "crates/lumbridge-core", "crates/lumbridge-pty", + "crates/lumbridge-runtime", "crates/lumbridge-storage", ] exclude = ["spikes"] diff --git a/README.md b/README.md index 20df6b7..eda3464 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,10 @@ as installable binaries; building from source will remain supported. This repository is in architecture and vertical-slice phase. The installable binary is still a scaffold, while the isolated native UI spikes now exercise an interactive six-surface workspace and the root workspace contains the first -bounded local PTY boundary. We are still validating terminal emulation, the -session runtime, ACP integration, packaging, and usage-data contracts before a -large implementation. +bounded local PTY and runtime-actor boundaries. The GPUI slice renders one real +actor-owned PTY beside five deterministic surfaces. We are still validating +terminal emulation, standalone runtime IPC/durability, ACP integration, +packaging, and usage-data contracts before a large implementation. ## Product shape diff --git a/crates/lumbridge-runtime/Cargo.toml b/crates/lumbridge-runtime/Cargo.toml new file mode 100644 index 0000000..f77a8e5 --- /dev/null +++ b/crates/lumbridge-runtime/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "lumbridge-runtime" +description = "Session actor and process ownership boundary for Lumbridge" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +lumbridge-pty = { path = "../lumbridge-pty" } +thiserror = "2.0" + +[lints] +workspace = true diff --git a/crates/lumbridge-runtime/src/lib.rs b/crates/lumbridge-runtime/src/lib.rs new file mode 100644 index 0000000..2386022 --- /dev/null +++ b/crates/lumbridge-runtime/src/lib.rs @@ -0,0 +1,558 @@ +//! Bounded session actor for Lumbridge-owned processes. +//! +//! The actor is deliberately transport-neutral. It owns one local PTY today; +//! future local IPC and remote transports can preserve the same ordered command +//! and event contract without exposing [`lumbridge_pty::PtySession`] to a UI. + +#![forbid(unsafe_code)] + +pub use lumbridge_pty::{CommandConfig, PtyOptions, TerminalSize}; +use lumbridge_pty::{ExitStatus, OutputEvent, PtyError, PtySession}; +use std::num::NonZeroUsize; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError, SyncSender, TryRecvError, TrySendError}; +use std::thread::{self, JoinHandle}; +use std::time::Duration; +use thiserror::Error; + +const DEFAULT_COMMAND_QUEUE: usize = 64; +const DEFAULT_EVENT_QUEUE: usize = 128; +const DEFAULT_POLL_INTERVAL: Duration = Duration::from_millis(10); +static NEXT_SESSION_ID: AtomicU64 = AtomicU64::new(1); + +/// Process-local identity for the first runtime slice. +/// +/// Durable local and remote session IDs belong in the persisted runtime +/// protocol; this value only correlates events within the current process. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct RuntimeSessionId(u64); + +impl RuntimeSessionId { + #[must_use] + pub const fn get(self) -> u64 { + self.0 + } +} + +/// A command accepted by the single-owner runtime actor. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum RuntimeCommand { + Input(Vec), + Resize(TerminalSize), + CloseInput, + Shutdown, +} + +/// The operation associated with a redacted runtime fault. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RuntimeOperation { + Input, + Resize, + Output, + Wait, + Shutdown, +} + +/// An ordered event emitted by the runtime actor. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum RuntimeEvent { + Started { + session_id: RuntimeSessionId, + process_id: Option, + }, + Output { + session_id: RuntimeSessionId, + sequence: u64, + bytes: Vec, + }, + InputClosed { + session_id: RuntimeSessionId, + }, + Exited { + session_id: RuntimeSessionId, + status: ExitStatus, + }, + Fault { + session_id: RuntimeSessionId, + operation: RuntimeOperation, + message: String, + }, +} + +/// Bounded queue and polling settings for a runtime actor. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct RuntimeActorOptions { + pub command_queue: NonZeroUsize, + pub event_queue: NonZeroUsize, + pub poll_interval: Duration, +} + +impl Default for RuntimeActorOptions { + fn default() -> Self { + Self { + command_queue: NonZeroUsize::new(DEFAULT_COMMAND_QUEUE).expect("non-zero constant"), + event_queue: NonZeroUsize::new(DEFAULT_EVENT_QUEUE).expect("non-zero constant"), + poll_interval: DEFAULT_POLL_INTERVAL, + } + } +} + +#[derive(Debug, Error)] +pub enum RuntimeActorError { + #[error("failed to start the PTY session: {0}")] + Start(#[source] PtyError), + #[error("the runtime command queue is full")] + CommandQueueFull, + #[error("the runtime actor is disconnected")] + Disconnected, + #[error("timed out waiting for a runtime event")] + EventTimeout, + #[error("the runtime actor thread panicked")] + ThreadPanicked, + #[error("the runtime actor poll interval must be greater than zero")] + InvalidPollInterval, +} + +/// UI-side owner of one runtime actor and its bounded channels. +pub struct RuntimeActor { + session_id: RuntimeSessionId, + process_id: Option, + commands: Option>, + events: Option>, + thread: Option>, +} + +impl RuntimeActor { + /// Starts a PTY synchronously, then transfers it to its actor thread. + /// + /// # Errors + /// + /// Returns [`RuntimeActorError::Start`] if the PTY or child cannot start. + pub fn spawn( + command: CommandConfig, + pty_options: PtyOptions, + actor_options: RuntimeActorOptions, + ) -> Result { + if actor_options.poll_interval.is_zero() { + return Err(RuntimeActorError::InvalidPollInterval); + } + let session = PtySession::spawn(command, pty_options).map_err(RuntimeActorError::Start)?; + let process_id = session.process_id(); + let session_id = RuntimeSessionId(NEXT_SESSION_ID.fetch_add(1, Ordering::Relaxed)); + let (command_sender, command_receiver) = + mpsc::sync_channel(actor_options.command_queue.get()); + let (event_sender, event_receiver) = mpsc::sync_channel(actor_options.event_queue.get()); + let thread = thread::Builder::new() + .name(format!("lumbridge-runtime-{}", session_id.get())) + .spawn(move || { + run_actor( + session_id, + session, + &command_receiver, + &event_sender, + actor_options.poll_interval, + ); + }) + .map_err(|error| RuntimeActorError::Start(PtyError::Io(error)))?; + + Ok(Self { + session_id, + process_id, + commands: Some(command_sender), + events: Some(event_receiver), + thread: Some(thread), + }) + } + + #[must_use] + pub const fn session_id(&self) -> RuntimeSessionId { + self.session_id + } + + #[must_use] + pub const fn process_id(&self) -> Option { + self.process_id + } + + /// Enqueues without blocking the UI thread. + /// + /// # Errors + /// + /// Reports bounded backpressure or actor disconnection without returning + /// command contents in the error value. + pub fn try_send(&self, command: RuntimeCommand) -> Result<(), RuntimeActorError> { + let commands = self + .commands + .as_ref() + .ok_or(RuntimeActorError::Disconnected)?; + commands.try_send(command).map_err(|error| match error { + TrySendError::Full(_) => RuntimeActorError::CommandQueueFull, + TrySendError::Disconnected(_) => RuntimeActorError::Disconnected, + }) + } + + /// Polls the next ordered runtime event without blocking. + /// + /// # Errors + /// + /// Returns [`RuntimeActorError::Disconnected`] after the actor stops and all + /// queued events have been consumed. + pub fn try_recv(&self) -> Result, RuntimeActorError> { + let events = self + .events + .as_ref() + .ok_or(RuntimeActorError::Disconnected)?; + match events.try_recv() { + Ok(event) => Ok(Some(event)), + Err(TryRecvError::Empty) => Ok(None), + Err(TryRecvError::Disconnected) => Err(RuntimeActorError::Disconnected), + } + } + + /// Waits for the next ordered runtime event. + /// + /// # Errors + /// + /// Reports timeout or actor disconnection. + pub fn recv_timeout(&self, timeout: Duration) -> Result { + let events = self + .events + .as_ref() + .ok_or(RuntimeActorError::Disconnected)?; + match events.recv_timeout(timeout) { + Ok(event) => Ok(event), + Err(RecvTimeoutError::Timeout) => Err(RuntimeActorError::EventTimeout), + Err(RecvTimeoutError::Disconnected) => Err(RuntimeActorError::Disconnected), + } + } + + /// Requests graceful actor shutdown and waits for its thread. + /// + /// # Errors + /// + /// Reports queue disconnection or an actor-thread panic. + pub fn shutdown(mut self) -> Result<(), RuntimeActorError> { + self.try_send(RuntimeCommand::Shutdown)?; + self.commands.take(); + self.events.take(); + join_thread(&mut self.thread) + } +} + +impl Drop for RuntimeActor { + fn drop(&mut self) { + if let Some(commands) = &self.commands { + let _ = commands.try_send(RuntimeCommand::Shutdown); + } + // Disconnect both channels before joining. This releases an actor that + // is applying event backpressure and lets its PtySession clean up. + self.commands.take(); + self.events.take(); + let _ = join_thread(&mut self.thread); + } +} + +fn join_thread(thread: &mut Option>) -> Result<(), RuntimeActorError> { + let Some(thread) = thread.take() else { + return Ok(()); + }; + thread.join().map_err(|_| RuntimeActorError::ThreadPanicked) +} + +fn run_actor( + session_id: RuntimeSessionId, + mut session: PtySession, + commands: &Receiver, + events: &SyncSender, + poll_interval: Duration, +) { + if send_event( + events, + RuntimeEvent::Started { + session_id, + process_id: session.process_id(), + }, + ) + .is_err() + { + return; + } + + let mut output_sequence = 0_u64; + loop { + loop { + match commands.try_recv() { + Ok(command) => { + if !handle_command(session_id, &mut session, events, command) { + return; + } + } + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => return, + } + } + + match session.recv_output_timeout(poll_interval) { + Ok(OutputEvent::Data(bytes)) => { + output_sequence += 1; + if send_event( + events, + RuntimeEvent::Output { + session_id, + sequence: output_sequence, + bytes, + }, + ) + .is_err() + { + return; + } + } + Ok(OutputEvent::Eof) => match session.wait() { + Ok(status) => { + let _ = send_event(events, RuntimeEvent::Exited { session_id, status }); + return; + } + Err(error) => { + let _ = send_fault(events, session_id, RuntimeOperation::Wait, &error); + return; + } + }, + Ok(OutputEvent::ReadFailed(error)) => { + let _ = send_event( + events, + RuntimeEvent::Fault { + session_id, + operation: RuntimeOperation::Output, + message: error.message, + }, + ); + return; + } + Err(PtyError::OutputTimeout) => match session.try_wait() { + Ok(Some(status)) => { + let _ = send_event(events, RuntimeEvent::Exited { session_id, status }); + return; + } + Ok(None) => {} + Err(error) => { + let _ = send_fault(events, session_id, RuntimeOperation::Wait, &error); + return; + } + }, + Err(error) => { + let _ = send_fault(events, session_id, RuntimeOperation::Output, &error); + return; + } + } + } +} + +fn handle_command( + session_id: RuntimeSessionId, + session: &mut PtySession, + events: &SyncSender, + command: RuntimeCommand, +) -> bool { + let result = match command { + RuntimeCommand::Input(bytes) => session + .write_all(&bytes) + .map_err(|error| (RuntimeOperation::Input, error)), + RuntimeCommand::Resize(size) => session + .resize(size) + .map_err(|error| (RuntimeOperation::Resize, error)), + RuntimeCommand::CloseInput => { + session.close_input(); + return send_event(events, RuntimeEvent::InputClosed { session_id }).is_ok(); + } + RuntimeCommand::Shutdown => { + match session.terminate() { + Ok(status) => { + let _ = send_event(events, RuntimeEvent::Exited { session_id, status }); + } + Err(error) => { + let _ = send_fault(events, session_id, RuntimeOperation::Shutdown, &error); + } + } + return false; + } + }; + + if let Err((operation, error)) = result { + return send_fault(events, session_id, operation, &error).is_ok(); + } + true +} + +fn send_event( + events: &SyncSender, + event: RuntimeEvent, +) -> Result<(), mpsc::SendError> { + events.send(event) +} + +fn send_fault( + events: &SyncSender, + session_id: RuntimeSessionId, + operation: RuntimeOperation, + error: &PtyError, +) -> Result<(), mpsc::SendError> { + send_event( + events, + RuntimeEvent::Fault { + session_id, + operation, + message: error.to_string(), + }, + ) +} + +#[cfg(test)] +mod tests { + use super::{ + RuntimeActor, RuntimeActorError, RuntimeActorOptions, RuntimeCommand, RuntimeEvent, + }; + use lumbridge_pty::{CommandConfig, PtyOptions, TerminalSize}; + use std::time::{Duration, Instant}; + + const TEST_TIMEOUT: Duration = Duration::from_secs(3); + + fn shell(script: &str) -> CommandConfig { + CommandConfig::new("/bin/sh") + .expect("valid synthetic shell") + .args(["-c", script]) + } + + fn actor(script: &str) -> RuntimeActor { + RuntimeActor::spawn( + shell(script), + PtyOptions::default(), + RuntimeActorOptions::default(), + ) + .expect("spawn runtime actor") + } + + fn events_until_exit(actor: &RuntimeActor) -> 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 actor exit"); + let event = actor.recv_timeout(remaining).expect("runtime event"); + let exited = matches!(event, RuntimeEvent::Exited { .. }); + events.push(event); + if exited { + return events; + } + } + } + + #[test] + fn emits_started_ordered_output_and_exit() { + let actor = actor("printf 'first\\n'; printf 'second\\n'; exit 7"); + let session_id = actor.session_id(); + let events = events_until_exit(&actor); + + assert!(matches!( + events.first(), + Some(RuntimeEvent::Started { + session_id: started, + .. + }) if *started == session_id + )); + let output = events + .iter() + .filter_map(|event| match event { + RuntimeEvent::Output { + sequence, bytes, .. + } => Some((*sequence, bytes.as_slice())), + _ => None, + }) + .collect::>(); + assert!(!output.is_empty()); + assert!( + output.windows(2).all(|pair| pair[0].0 < pair[1].0), + "output sequence must increase" + ); + let bytes = output + .iter() + .flat_map(|(_, bytes)| bytes.iter().copied()) + .collect::>(); + let text = String::from_utf8_lossy(&bytes).replace('\r', ""); + assert!( + text.contains("first\nsecond\n"), + "unexpected output: {text:?}" + ); + assert!(matches!( + events.last(), + Some(RuntimeEvent::Exited { status, .. }) if status.code == 7 + )); + } + + #[test] + fn serializes_input_and_resize_through_commands() { + let actor = actor("IFS= read -r line; stty size; printf 'got:%s\\n' \"$line\""); + actor + .try_send(RuntimeCommand::Resize( + TerminalSize::new(41, 101).expect("valid size"), + )) + .expect("queue resize"); + actor + .try_send(RuntimeCommand::Input(b"hello-actor\n".to_vec())) + .expect("queue input"); + + let events = events_until_exit(&actor); + let bytes = events + .iter() + .filter_map(|event| match event { + RuntimeEvent::Output { bytes, .. } => Some(bytes.as_slice()), + _ => None, + }) + .flatten() + .copied() + .collect::>(); + let text = String::from_utf8_lossy(&bytes).replace('\r', ""); + assert!(text.contains("41 101"), "unexpected output: {text:?}"); + assert!( + text.contains("got:hello-actor"), + "unexpected output: {text:?}" + ); + } + + #[test] + fn drop_terminates_a_hung_session_without_waiting_for_output() { + let actor = actor("trap '' HUP; exec sleep 30"); + let process_id = actor.process_id().expect("Unix shell has a process id"); + let started = Instant::now(); + drop(actor); + assert!(started.elapsed() < TEST_TIMEOUT); + + #[cfg(target_os = "linux")] + { + let proc_path = format!("/proc/{process_id}"); + assert!(!std::path::Path::new(&proc_path).exists()); + } + } + + #[test] + fn reports_timeout_without_exposing_command_contents() { + let actor = actor("sleep 1"); + assert!(matches!( + actor.recv_timeout(Duration::ZERO), + Err(RuntimeActorError::EventTimeout) | Ok(RuntimeEvent::Started { .. }) + )); + } + + #[test] + fn rejects_a_zero_poll_interval_before_spawning() { + let options = RuntimeActorOptions { + poll_interval: Duration::ZERO, + ..RuntimeActorOptions::default() + }; + let result = RuntimeActor::spawn(shell("exit 0"), PtyOptions::default(), options); + assert!(matches!( + result, + Err(RuntimeActorError::InvalidPollInterval) + )); + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 36112bd..b0b7b44 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -41,8 +41,8 @@ messages should be real from the beginning. - `lumbridge`: installable application entry point. The scaffold currently contains `lumbridge-core`, `lumbridge-storage`, -`lumbridge-buzz`, `lumbridge-pty`, and the entry point. Larger runtime crates are -added after their architecture spikes pass. +`lumbridge-buzz`, `lumbridge-pty`, `lumbridge-runtime`, and the entry point. +Larger runtime crates are added after their architecture spikes pass. ## Terminal path @@ -55,6 +55,13 @@ scrollback. The terminal engine turns output into immutable render snapshots and bounded deltas for the UI. Scrollback is chunked and persisted separately from the live screen to prevent large agent transcripts from blocking input. +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 consumes one actor +session while five surfaces retain deterministic comparison output. This actor +still runs in-process; moving the same framework-neutral contract behind local +authenticated IPC is the next durability step. See decision 0004. + We should evaluate, not blindly copy, WezTerm, Zellij, RMUX, tmux, and cmux. The first spike must compare a reusable terminal crate with a small first-party layer. Correctness cases include alternate screen, bracketed paste, OSC 8 links, Kitty diff --git a/docs/TESTING.md b/docs/TESTING.md index bbbc70a..048aca4 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -109,6 +109,11 @@ who already have the final cargo-watch release installed. - `lumbridge-pty` runs synthetic `/bin/sh` tests for raw output, non-zero exits, 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. +- The GPUI slice tests byte line-framing across CRLF, chunk boundaries, split + UTF-8, and invalid bytes. It renders one real actor-owned PTY and keeps five + surfaces deterministic; line framing is explicitly not VT emulation. - The current-GPUI probe compile-checks real AccessKit element wiring and real platform input-handler installation. Unit tests cover its semantic tree and UTF-16/UTF-8 composed-text mutations. OS screen readers, IME candidate windows, diff --git a/docs/UI_SPIKE_SCORECARD.md b/docs/UI_SPIKE_SCORECARD.md index 4e1cb4c..a883b7c 100644 --- a/docs/UI_SPIKE_SCORECARD.md +++ b/docs/UI_SPIKE_SCORECARD.md @@ -83,10 +83,11 @@ fallback. ## Measurement semantics -Both renderers consume the same deterministic six-surface action stream. Each -tick invalidates six surfaces, appends one bounded line to each of the three -terminal fixtures, and records stable event/revision counters. The GPUI footer -currently 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 candidate exposes a reliable public cross-platform post-present -callback. True present latency requires an external platform probe. +Both renderers retain the same all-deterministic six-surface action stream for +comparison. The GPUI integration mode replaces one terminal fixture with a real +actor-owned PTY and leaves five deterministic surfaces 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 +candidate exposes a reliable public cross-platform post-present callback. True +present latency requires an external platform probe. diff --git a/docs/UX_VERTICAL_SLICE.md b/docs/UX_VERTICAL_SLICE.md index 6143ab9..5ec6976 100644 --- a/docs/UX_VERTICAL_SLICE.md +++ b/docs/UX_VERTICAL_SLICE.md @@ -84,16 +84,19 @@ receive the same state transitions and tests. ## Decisive workload -- One deterministic tick invalidates all six surfaces and appends bounded output - to the three terminal panes. Framework adapters consume the same event trace. +- 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. 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. - The model records actions, revisions, six-surface updates, and bounded terminal line counts. Framework adapters label each measured timing stage explicitly; element-build timing is never presented as display-present timing. -- A later PTY adapter replaces one synthetic stream without changing the UI - contract. Synthetic streams stay available for repeatable performance tests. +- The PTY actor replaces exactly one synthetic stream without changing focus or + layout contracts. Synthetic streams stay available for repeatable performance + tests. Raw lines do not claim terminal-emulation fidelity. ## Hard gates diff --git a/docs/decisions/0004-runtime-actor-owns-each-pty.md b/docs/decisions/0004-runtime-actor-owns-each-pty.md new file mode 100644 index 0000000..72dad20 --- /dev/null +++ b/docs/decisions/0004-runtime-actor-owns-each-pty.md @@ -0,0 +1,26 @@ +# 0004: A bounded runtime actor owns each PTY + +Status: accepted for the first local runtime slice. + +The desktop UI never owns or directly polls `PtySession`. A single runtime +actor owns the PTY, child process, input ordering, resize ordering, output +sequence, and process-tree cleanup. Its command and event channels are bounded, +so a slow UI produces explicit backpressure instead of unbounded transcript +memory. + +The first implementation runs this actor on a dedicated thread in the GPUI +process. That is an implementation step, not the final deployment boundary. The +commands and events remain free of GPUI types so they can move behind the +versioned local IPC connection when the standalone `lumbridge-runtime` process +arrives. Process-local session IDs are correlation IDs only; durable IDs are +assigned by the persisted runtime protocol later. + +Runtime events contain ordered raw PTY bytes. The UI spike may line-frame plain +fixture output for display, but ANSI/VT parsing, screen state, cursor behavior, +scrollback, and terminal input modes belong to `lumbridge-terminal`. The UI must +not infer terminal semantics from raw strings. + +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. diff --git a/spikes/README.md b/spikes/README.md index 13fad15..be20dfe 100644 --- a/spikes/README.md +++ b/spikes/README.md @@ -18,6 +18,11 @@ Both spikes must preserve the same information architecture: - native Markdown editor/preview and browser placeholders; - connection, harness, usage, and burn context in the footer. +GPUI also has an integration mode with one real local PTY owned by +`lumbridge-runtime`; the other five surfaces remain deterministic. Floem and the +shared model retain the all-deterministic mode for like-for-like framework +comparison. The PTY display is plain byte line-framing, not terminal emulation. + Build independently: ```bash diff --git a/spikes/gpui-shell/Cargo.lock b/spikes/gpui-shell/Cargo.lock index 72580c6..9917d34 100644 --- a/spikes/gpui-shell/Cargo.lock +++ b/spikes/gpui-shell/Cargo.lock @@ -791,6 +791,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + [[package]] name = "cfg_aliases" version = "0.2.2" @@ -1432,7 +1438,7 @@ dependencies = [ "rustc_version", "toml 1.1.4+spec-1.1.0", "vswhom", - "winreg", + "winreg 0.55.0", ] [[package]] @@ -2977,11 +2983,29 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "lumbridge-pty" +version = "0.0.1" +dependencies = [ + "nix 0.28.0", + "portable-pty", + "thiserror 2.0.20", +] + +[[package]] +name = "lumbridge-runtime" +version = "0.0.1" +dependencies = [ + "lumbridge-pty", + "thiserror 2.0.20", +] + [[package]] name = "lumbridge-spike-gpui" version = "0.0.1" dependencies = [ "gpui", + "lumbridge-runtime", "lumbridge-spike-model", ] @@ -3193,7 +3217,7 @@ dependencies = [ "arrayvec", "bit-set", "bitflags 2.13.1", - "cfg_aliases", + "cfg_aliases 0.2.2", "codespan-reporting", "half", "hashbrown 0.15.5", @@ -3224,6 +3248,18 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases 0.1.1", + "libc", +] + [[package]] name = "nix" version = "0.29.0" @@ -3232,7 +3268,7 @@ checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ "bitflags 2.13.1", "cfg-if", - "cfg_aliases", + "cfg_aliases 0.2.2", "libc", ] @@ -3244,7 +3280,7 @@ checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ "bitflags 2.13.1", "cfg-if", - "cfg_aliases", + "cfg_aliases 0.2.2", "libc", ] @@ -3789,6 +3825,27 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5da3b0203fd7ee5720aa0b5e790b591aa5d3f41c3ed2c34a3a393382198af2f7" +[[package]] +name = "portable-pty" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix 0.28.0", + "serial2", + "shared_library", + "shell-words", + "winapi", + "winreg 0.10.1", +] + [[package]] name = "postage" version = "0.5.0" @@ -3963,7 +4020,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ "bytes", - "cfg_aliases", + "cfg_aliases 0.2.2", "pin-project-lite", "quinn-proto", "quinn-udp", @@ -4004,7 +4061,7 @@ version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ - "cfg_aliases", + "cfg_aliases 0.2.2", "libc", "once_cell", "socket2", @@ -4783,6 +4840,17 @@ dependencies = [ "serde", ] +[[package]] +name = "serial2" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b16809bc35793b19ce4e0c53924bc0dce3937f15487997cfdaed936004180730" +dependencies = [ + "cfg-if", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "sha1_smol" version = "1.0.1" @@ -4811,6 +4879,22 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" @@ -6629,6 +6713,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "winreg" version = "0.55.0" diff --git a/spikes/gpui-shell/Cargo.toml b/spikes/gpui-shell/Cargo.toml index 9c263e1..c6cd72e 100644 --- a/spikes/gpui-shell/Cargo.toml +++ b/spikes/gpui-shell/Cargo.toml @@ -9,6 +9,7 @@ publish = false [dependencies] gpui = "0.2.2" +lumbridge-runtime = { path = "../../crates/lumbridge-runtime" } lumbridge-spike-model = { path = "../ui-shell-model" } [workspace] diff --git a/spikes/gpui-shell/src/main.rs b/spikes/gpui-shell/src/main.rs index 0d4ed7d..818e3ba 100644 --- a/spikes/gpui-shell/src/main.rs +++ b/spikes/gpui-shell/src/main.rs @@ -5,9 +5,12 @@ use gpui::{ App, Application, Bounds, Context, FocusHandle, KeyBinding, KeyDownEvent, Window, WindowBounds, WindowOptions, actions, div, prelude::*, px, rgb, size, }; +use lumbridge_runtime::{ + CommandConfig, PtyOptions, RuntimeActor, RuntimeActorError, RuntimeActorOptions, RuntimeEvent, +}; use lumbridge_spike_model::{ - ActionOutcome, FOOTER_RIGHT, FocusDirection, PaneId, PaneState, ShellAction, ShellModel, - SurfaceKind, WORKSPACES, + ActionOutcome, FOOTER_RIGHT, FocusDirection, OutputSource, PaneId, PaneState, ShellAction, + ShellModel, SurfaceKind, WORKSPACES, }; const BG: u32 = 0x090c12; @@ -22,6 +25,10 @@ const ACCENT: u32 = 0x68b5f8; const ATTENTION: u32 = 0xf1b96a; 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 = "i=0; printf 'Lumbridge runtime actor owns this PTY\\n'; while :; do printf '[pty %06d] /bin/sh · actor output\\n' \"$i\"; i=$((i+1)); sleep 1; done"; actions!( lumbridge, @@ -44,10 +51,93 @@ actions!( struct LumbridgeShell { model: ShellModel, timing: RenderTiming, + runtime: Option, + runtime_status: LiveRuntimeStatus, + runtime_lines: PtyLineFramer, + last_runtime_sequence: u64, root_focus: FocusHandle, pane_focus: [FocusHandle; 6], } +#[derive(Clone, Debug, Eq, PartialEq)] +enum LiveRuntimeStatus { + Starting, + Running { + session_id: u64, + process_id: Option, + }, + Exited(String), + Fault(String), +} + +impl LiveRuntimeStatus { + fn badge(&self) -> &'static str { + match self { + Self::Starting => "PTY STARTING", + Self::Running { .. } => "LIVE PTY", + Self::Exited(_) => "PTY EXITED", + Self::Fault(_) => "PTY FAULT", + } + } + + fn detail(&self) -> String { + match self { + Self::Starting => "local · runtime actor starting".to_owned(), + Self::Running { + session_id, + process_id, + } => match process_id { + Some(process_id) => { + format!("local · runtime session {session_id} · pid {process_id}") + } + None => format!("local · runtime session {session_id}"), + }, + Self::Exited(status) => format!("local · {status}"), + Self::Fault(message) => format!("local · {message}"), + } + } + + const fn is_fault(&self) -> bool { + matches!(self, Self::Fault(_)) + } + + const fn is_terminal(&self) -> bool { + matches!(self, Self::Exited(_) | Self::Fault(_)) + } +} + +#[derive(Default)] +struct PtyLineFramer { + pending: Vec, +} + +impl PtyLineFramer { + fn push(&mut self, bytes: &[u8]) -> Vec { + self.pending.extend_from_slice(bytes); + let mut lines = Vec::new(); + while let Some(newline) = self.pending.iter().position(|byte| *byte == b'\n') { + let mut line = self.pending.drain(..=newline).collect::>(); + line.pop(); + if line.last() == Some(&b'\r') { + line.pop(); + } + lines.push(String::from_utf8_lossy(&line).into_owned()); + } + lines + } + + fn finish(&mut self) -> Vec { + if self.pending.is_empty() { + return Vec::new(); + } + let mut line = std::mem::take(&mut self.pending); + if line.last() == Some(&b'\r') { + line.pop(); + } + vec![String::from_utf8_lossy(&line).into_owned()] + } +} + #[derive(Default)] struct RenderTiming { pending_dispatch: Option, @@ -121,9 +211,36 @@ impl LumbridgeShell { }) .detach(); + cx.spawn(async move |this, cx| { + loop { + cx.background_executor().timer(RUNTIME_POLL_INTERVAL).await; + if this + .update(cx, |shell, cx| { + if shell.drain_runtime_events() { + cx.notify(); + } + }) + .is_err() + { + break; + } + } + }) + .detach(); + + let (runtime, runtime_status) = match start_live_runtime() { + Ok(runtime) => (Some(runtime), LiveRuntimeStatus::Starting), + Err(error) => (None, LiveRuntimeStatus::Fault(error.to_string())), + }; + Self { - model: ShellModel::default(), + model: ShellModel::with_external_output(LIVE_PANE) + .expect("the live comparison pane is a terminal"), timing: RenderTiming::default(), + runtime, + runtime_status, + runtime_lines: PtyLineFramer::default(), + last_runtime_sequence: 0, root_focus, pane_focus, } @@ -134,6 +251,87 @@ impl LumbridgeShell { self.model.dispatch(action) } + 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; + } + }; + + match event { + RuntimeEvent::Started { + session_id, + process_id, + } => { + self.runtime_status = LiveRuntimeStatus::Running { + session_id: session_id.get(), + 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}" + )); + changed = true; + break; + } + self.last_runtime_sequence = sequence; + let lines = self.runtime_lines.push(&bytes); + if !lines.is_empty() { + self.dispatch(ShellAction::AppendExternalOutput { + pane: LIVE_PANE, + lines, + }); + changed = true; + } + } + RuntimeEvent::InputClosed { .. } => {} + RuntimeEvent::Exited { status, .. } => { + let lines = self.runtime_lines.finish(); + if !lines.is_empty() { + self.dispatch(ShellAction::AppendExternalOutput { + pane: LIVE_PANE, + lines, + }); + } + 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 select_pane(&mut self, pane: PaneId, window: &mut Window, cx: &mut Context) { self.dispatch(ShellAction::SelectPane(pane)); window.focus(&self.pane_focus[pane.index()]); @@ -210,16 +408,22 @@ impl LumbridgeShell { fn pane_card( pane: &PaneState, selected: bool, + runtime_status: LiveRuntimeStatus, focus: FocusHandle, cx: &mut Context, ) -> gpui::AnyElement { let id = pane.id(); let needs_input = pane.needs_input(); - let label = match pane.kind() { - SurfaceKind::Terminal => pane.status().label(), - _ => pane.fixture().badge, + let external = pane.output_source() == OutputSource::External; + let label = if external { + runtime_status.badge() + } else { + match pane.kind() { + SurfaceKind::Terminal => pane.status().label(), + _ => pane.fixture().badge, + } }; - let state_color = if needs_input { + let state_color = if needs_input || (external && runtime_status.is_fault()) { ATTENTION } else if matches!(pane.kind(), SurfaceKind::Terminal) { SUCCESS @@ -273,7 +477,11 @@ impl LumbridgeShell { .text_color(rgb(MUTED)) .child(format!("{}", id.index() + 1)), ) - .child(pane.fixture().title), + .child(if external { + "Shell · runtime actor" + } else { + pane.fixture().title + }), ) .child(div().text_xs().text_color(rgb(state_color)).child(label)), ) @@ -286,7 +494,11 @@ impl LumbridgeShell { .py_2() .text_xs() .text_color(rgb(MUTED)) - .child(pane.fixture().target) + .child(if external { + runtime_status.detail() + } else { + pane.fixture().target.to_owned() + }) .when(selected, |view| view.child("FOCUSED")), ) .child( @@ -478,7 +690,7 @@ impl Render for LumbridgeShell { .py_1() .text_sm() .text_color(rgb(SUCCESS)) - .child("metal · connected"), + .child(format!("metal · {}", self.runtime_status.badge())), ) .child( div() @@ -555,7 +767,7 @@ impl Render for LumbridgeShell { .px_3() .text_xs() .text_color(rgb(MUTED)) - .child("6 surfaces · 3 remote · 1 waiting"), + .child("1 live PTY · 5 deterministic · 1 waiting"), ); let pane_cards = self @@ -566,6 +778,7 @@ impl Render for LumbridgeShell { Self::pane_card( pane, self.model.selected_pane() == pane.id(), + self.runtime_status.clone(), self.pane_focus[pane.id().index()].clone(), cx, ) @@ -582,10 +795,11 @@ impl Render for LumbridgeShell { let counters = self.model.counters(); let footer_left = format!( - "rev {} · {} focus moves · {} surface updates · {} lines", + "rev {} · {} focus · {} external batches · {} PTY lines · {} total lines", self.model.revision(), counters.focus_moves, - counters.surface_updates, + counters.external_output_batches, + counters.external_lines_appended, counters.terminal_lines_appended ); let timing = self.timing.summary(); @@ -649,7 +863,7 @@ impl Render for LumbridgeShell { .mr_4() .text_xs() .text_color(rgb(SUCCESS)) - .child("metal · runtime online"), + .child(format!("metal · {}", self.runtime_status.badge())), ) .child( div() @@ -694,6 +908,17 @@ impl Render for LumbridgeShell { } } +fn start_live_runtime() -> Result { + let command = CommandConfig::new("/bin/sh") + .map_err(RuntimeActorError::Start)? + .args(["-c", LIVE_PTY_SCRIPT]); + RuntimeActor::spawn( + command, + PtyOptions::default(), + RuntimeActorOptions::default(), + ) +} + fn main() { Application::new().run(|cx: &mut App| { cx.bind_keys([ @@ -731,3 +956,25 @@ fn main() { cx.activate(true); }); } + +#[cfg(test)] +mod tests { + use super::PtyLineFramer; + + #[test] + fn frames_split_crlf_and_utf8_chunks_without_losing_bytes() { + let mut framer = PtyLineFramer::default(); + assert!(framer.push(b"first\r").is_empty()); + assert_eq!(framer.push(b"\nsecond\npart"), ["first", "second"]); + assert!(framer.push(&[0xf0, 0x9f]).is_empty()); + assert!(framer.push(&[0x91, 0xa9]).is_empty()); + assert_eq!(framer.finish(), ["part👩"]); + } + + #[test] + fn replaces_invalid_utf8_only_after_a_complete_line() { + let mut framer = PtyLineFramer::default(); + assert!(framer.push(&[0xff]).is_empty()); + assert_eq!(framer.push(b"\n"), ["�"]); + } +} diff --git a/spikes/ui-shell-model/src/lib.rs b/spikes/ui-shell-model/src/lib.rs index c2ed869..3f0583f 100644 --- a/spikes/ui-shell-model/src/lib.rs +++ b/spikes/ui-shell-model/src/lib.rs @@ -81,6 +81,12 @@ pub enum PaneStatus { Ready, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum OutputSource { + Deterministic, + External, +} + impl PaneStatus { #[must_use] pub const fn label(self) -> &'static str { @@ -209,6 +215,7 @@ pub struct PaneState { status_before_input: Option, lines: Vec, synthetic_line_sequence: u64, + output_source: OutputSource, } impl PaneState { @@ -240,6 +247,10 @@ impl PaneState { pub const fn synthetic_line_sequence(&self) -> u64 { self.synthetic_line_sequence } + #[must_use] + pub const fn output_source(&self) -> OutputSource { + self.output_source + } } #[derive(Clone, Debug, Default, Eq, PartialEq)] @@ -276,6 +287,7 @@ pub enum ShellAction { CloseCommandPalette, SetCommandPaletteQuery(String), SyntheticStreamTick, + AppendExternalOutput { pane: PaneId, lines: Vec }, } /// Counters prove both candidates replayed the same workload. Framework @@ -294,6 +306,8 @@ pub struct MeasurementCounters { /// Total surfaces invalidated by the deterministic six-surface workload. pub surface_updates: u64, pub terminal_lines_appended: u64, + pub external_output_batches: u64, + pub external_lines_appended: u64, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -315,6 +329,16 @@ impl fmt::Display for InvalidTerminalLineLimit { } impl std::error::Error for InvalidTerminalLineLimit {} +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct InvalidExternalOutputPane; + +impl fmt::Display for InvalidExternalOutputPane { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("external output requires a terminal pane") + } +} +impl std::error::Error for InvalidExternalOutputPane {} + #[derive(Clone, Debug, Eq, PartialEq)] pub struct ShellModel { panes: [PaneState; GRID_ROWS * GRID_COLUMNS], @@ -350,6 +374,7 @@ impl ShellModel { .then_some(PaneStatus::Working), lines, synthetic_line_sequence: 0, + output_source: OutputSource::Deterministic, } }); Ok(Self { @@ -363,6 +388,22 @@ impl ShellModel { }) } + /// Creates the comparison model with exactly one actor-driven terminal. + /// + /// # Errors + /// + /// Returns [`InvalidExternalOutputPane`] when `pane` is not a terminal. + pub fn with_external_output(pane: PaneId) -> Result { + let mut model = Self::default(); + 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(); + Ok(model) + } + #[must_use] pub fn panes(&self) -> &[PaneState; GRID_ROWS * GRID_COLUMNS] { &self.panes @@ -457,7 +498,11 @@ impl ShellModel { self.synthetic_tick += 1; self.counters.synthetic_ticks += 1; for pane in &mut self.panes { + if pane.output_source == OutputSource::External { + continue; + } pane.synthetic_line_sequence += 1; + self.counters.surface_updates += 1; if pane.kind() != SurfaceKind::Terminal { continue; } @@ -472,10 +517,25 @@ impl ShellModel { } appended += 1; } - self.counters.surface_updates += self.panes.len() as u64; self.counters.terminal_lines_appended += appended as u64; changed = true; } + ShellAction::AppendExternalOutput { pane, lines } => { + let pane = &mut self.panes[pane.index()]; + if pane.output_source == OutputSource::External && !lines.is_empty() { + appended = lines.len(); + pane.lines.extend(lines); + if pane.lines.len() > self.terminal_line_limit { + pane.lines + .drain(..pane.lines.len() - self.terminal_line_limit); + } + self.counters.surface_updates += 1; + self.counters.terminal_lines_appended += appended as u64; + self.counters.external_output_batches += 1; + self.counters.external_lines_appended += appended as u64; + changed = true; + } + } } if changed { self.revision += 1; @@ -511,8 +571,8 @@ pub const fn focus_neighbor(pane: PaneId, direction: FocusDirection) -> Option