This commit is contained in:
@@ -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(®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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user