Add bounded runtime actor and live PTY pane
CI / rust (push) Successful in 3m26s

This commit is contained in:
2026-08-31 16:32:50 -07:00
parent ab7543b9c6
commit 32d190c6c6
15 changed files with 1111 additions and 39 deletions
+5
View File
@@ -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
+99 -6
View File
@@ -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"
+1
View File
@@ -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]
+261 -14
View File
@@ -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<RuntimeActor>,
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<u32>,
},
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<u8>,
}
impl PtyLineFramer {
fn push(&mut self, bytes: &[u8]) -> Vec<String> {
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::<Vec<_>>();
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<String> {
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<Instant>,
@@ -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>) {
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<Self>,
) -> 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<RuntimeActor, RuntimeActorError> {
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"), [""]);
}
}
+104 -3
View File
@@ -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<PaneStatus>,
lines: Vec<String>,
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<String> },
}
/// 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<Self, InvalidExternalOutputPane> {
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<P
#[cfg(test)]
mod tests {
use super::{
FocusDirection, GRID_COLUMNS, GRID_ROWS, PANES, PaneId, PaneStatus, ShellAction,
ShellModel, SurfaceKind, focus_neighbor,
FocusDirection, GRID_COLUMNS, GRID_ROWS, OutputSource, PANES, PaneId, PaneStatus,
ShellAction, ShellModel, SurfaceKind, focus_neighbor,
};
#[test]
@@ -699,6 +759,47 @@ mod tests {
assert!(ShellModel::with_terminal_line_limit(0).is_err());
}
#[test]
fn one_external_terminal_leaves_five_deterministic_surfaces() {
let mut model = ShellModel::with_external_output(PaneId::CodexRuntime).unwrap();
assert_eq!(
model.pane(PaneId::CodexRuntime).output_source(),
OutputSource::External
);
assert!(model.pane(PaneId::CodexRuntime).lines().is_empty());
let tick = model.dispatch(ShellAction::SyntheticStreamTick);
assert_eq!(tick.terminal_lines_appended, 2);
assert!(model.pane(PaneId::CodexRuntime).lines().is_empty());
assert_eq!(model.counters().surface_updates, 5);
let output = model.dispatch(ShellAction::AppendExternalOutput {
pane: PaneId::CodexRuntime,
lines: vec!["runtime ready".into(), "pty line".into()],
});
assert!(output.changed);
assert_eq!(output.terminal_lines_appended, 2);
assert_eq!(
model.pane(PaneId::CodexRuntime).lines(),
["runtime ready", "pty line"]
);
assert_eq!(model.counters().surface_updates, 6);
assert_eq!(model.counters().external_output_batches, 1);
assert_eq!(model.counters().external_lines_appended, 2);
}
#[test]
fn external_output_rejects_nonterminals_and_deterministic_panes() {
assert!(ShellModel::with_external_output(PaneId::Architecture).is_err());
let mut model = ShellModel::default();
let outcome = model.dispatch(ShellAction::AppendExternalOutput {
pane: PaneId::CodexRuntime,
lines: vec!["must not replace fixture output".into()],
});
assert!(!outcome.changed);
assert_eq!(model.counters().external_output_batches, 0);
}
#[test]
fn identical_action_replays_produce_identical_models_and_counters() {
let actions = [