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
+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());