Files
lumbridge-code/spikes/ui-shell-model/src/lib.rs
T
Metal AgentandClaude Opus 5 834b73e831 Stop the shell asserting things that are not true
The footer was rebuilt on a real ledger two commits ago. The rest of the shell
was never audited the same way, and a multi-agent pass over it found the same
class of defect everywhere else:

- A sidebar card reading "Buzz · lumbridgecode / connected · signed identity"
  in the success colour. lumbridge-buzz is not a dependency of this binary.
- A saved host "amd-server", and a WORKTREES section with five entries and a
  working selector, backed by a lumbridge-git crate that does not exist.
- A first-run workspace of six panes announcing "Codex · runtime / metal ·
  Tailscale SSH", "Claude Code · UI / MacBook Air · local" and a Pi pane on a
  saved host. Every one of them was a /bin/sh, and the machine names were this
  developer's.
- A declared "Pi · spark-1 · laguna-s-2.1" usage profile with no probe of any
  kind behind it. Declaring a profile promises the gap is real; that one could
  never be filled.
- FOOTER_CENTER = "Codex · ChatGPT subscription · 62% window remaining",
  rendered by the Floem shell. Decision 0013 names that exact form as the thing
  that must never be shown.
- The header's PTY count painted green unconditionally, so "0/5 LIVE PTYS" read
  as success. runtime_rows already had the right rule three hundred lines away.
- A browser panel describing itself as "An isolated system-web-engine surface"
  on the chooser screen where you pick it. There is no web engine in this build.

First run is now three real local shells, and a pane claims a harness when one
has actually been launched into it. The seed mapping stays for when that is
possible.

Also removes the only unsafe block in the shell: a test set LUMBRIDGE_*_PROBE
through the environment, which needs unsafe under edition 2024 and silently
disabled both probes for every other test in the binary. Replaced with
UsageFeedOptions passed to start_with.

Clippy pedantic on the spike goes 79 -> 15 against root CI's -D warnings, so
graduating it into the workspace is not gated on a warning cleanup. The four
remaining too_many_lines are the render split, which the sidebar work needs to
do anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 22:54:24 -07:00

1079 lines
37 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Deterministic state and fixtures shared by the GPUI and Floem spikes.
//!
//! UI candidates translate framework input into [`ShellAction`] and render the
//! resulting [`ShellModel`]. This makes their interaction traces comparable.
use std::fmt;
pub const GRID_ROWS: usize = 2;
pub const GRID_COLUMNS: usize = 3;
pub const INITIAL_ATTACHED_PANEL_COUNT: usize = 5;
pub const DEFAULT_TERMINAL_LINE_LIMIT: usize = 64;
/// Stable identity used in traces and accessibility identifiers.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
#[repr(u8)]
pub enum PaneId {
CodexRuntime = 0,
ClaudeUi = 1,
PiDocs = 2,
Architecture = 3,
AcpPreview = 4,
RuntimeReview = 5,
}
impl PaneId {
pub const ALL: [Self; GRID_ROWS * GRID_COLUMNS] = [
Self::CodexRuntime,
Self::ClaudeUi,
Self::PiDocs,
Self::Architecture,
Self::AcpPreview,
Self::RuntimeReview,
];
#[must_use]
pub const fn index(self) -> usize {
self as usize
}
#[must_use]
pub const fn stable_name(self) -> &'static str {
match self {
Self::CodexRuntime => "codex-runtime",
Self::ClaudeUi => "claude-ui",
Self::PiDocs => "pi-docs",
Self::Architecture => "architecture",
Self::AcpPreview => "acp-preview",
Self::RuntimeReview => "runtime-review",
}
}
#[must_use]
pub const fn row(self) -> usize {
self.index() / GRID_COLUMNS
}
#[must_use]
pub const fn column(self) -> usize {
self.index() % GRID_COLUMNS
}
}
impl fmt::Display for PaneId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.stable_name())
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SurfaceKind {
Terminal,
Markdown,
Browser,
Review,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PaneStatus {
Working,
NeedsInput,
Streaming,
Ready,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum OutputSource {
Deterministic,
External,
}
impl PaneStatus {
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Working => "WORKING",
Self::NeedsInput => "NEEDS INPUT",
Self::Streaming => "STREAMING",
Self::Ready => "READY",
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct PaneFixture {
pub id: PaneId,
pub title: &'static str,
pub badge: &'static str,
pub target: &'static str,
pub kind: SurfaceKind,
pub status: PaneStatus,
pub lines: [&'static str; 4],
}
pub const PANES: [PaneFixture; 6] = [
PaneFixture {
id: PaneId::CodexRuntime,
title: "Codex · runtime",
badge: "WORKING",
target: "metal · Tailscale SSH",
kind: SurfaceKind::Terminal,
status: PaneStatus::Working,
lines: [
"$ cargo nextest run -p lumbridge-runtime",
"PASS remote::reconnect_replays_output",
"PASS pty::resize_preserves_cursor",
"agent is editing 3 files…",
],
},
PaneFixture {
id: PaneId::ClaudeUi,
title: "Claude Code · UI",
badge: "NEEDS INPUT",
target: "MacBook Air · local",
kind: SurfaceKind::Terminal,
status: PaneStatus::NeedsInput,
lines: [
"$ bacon clippy",
"finished in 0.42s",
"Which split should receive focus?",
"[Approve] [Steer] [Cancel]",
],
},
PaneFixture {
id: PaneId::PiDocs,
title: "Pi · docs",
badge: "STREAMING",
target: "amd-server · OpenSSH",
kind: SurfaceKind::Terminal,
status: PaneStatus::Streaming,
lines: [
"$ cargo watch --why",
"ACP session resumed at event 1842",
"writing remote-session.md",
"▌",
],
},
PaneFixture {
id: PaneId::Architecture,
title: "Architecture.md",
badge: "MARKDOWN",
target: "lumbridge-code · worktree",
kind: SurfaceKind::Markdown,
status: PaneStatus::Ready,
lines: [
"## Remote session path",
"The destination runtime owns the PTY.",
"The laptop may disconnect and reattach.",
"SQLite remains local to each machine.",
],
},
PaneFixture {
id: PaneId::AcpPreview,
title: "Preview · ACP docs",
badge: "BROWSER",
target: "isolated system web engine",
kind: SurfaceKind::Browser,
status: PaneStatus::Ready,
lines: [
"https://agentclientprotocol.com",
"Content process: sandboxed",
"Runtime bridge: no privileged access",
"Open in external browser ↗",
],
},
PaneFixture {
id: PaneId::RuntimeReview,
title: "Changes · lumbridge-runtime",
badge: "REVIEW",
target: "metal · worktree remote-runtime",
kind: SurfaceKind::Review,
status: PaneStatus::Ready,
lines: [
"+ 184 remote transport",
"+ 96 SQLite migrations",
" 12 obsolete scaffold",
"6 files · tests passing",
],
},
];
/// The one footer string the frozen Floem shell renders.
///
/// It replaces three that read like data — a pane census, "Codex · ChatGPT
/// subscription · 62% window remaining", and "burn 8.4%/hr · resets in 2h 14m".
/// Decision 0013 names that exact form as the thing that must never be shown,
/// and a comparison shell rendering a fabricated quota beside a real one is
/// worse than a comparison shell with no footer numbers at all. The GPUI shell
/// derives its footer from `lumbridge_core::UsageLedger`. See decision 0012.
pub const FOOTER_FIXTURE_NOTICE: &str = "layout fixture · no usage source";
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PaneState {
fixture: PaneFixture,
status: PaneStatus,
status_before_input: Option<PaneStatus>,
lines: Vec<String>,
synthetic_line_sequence: u64,
output_source: OutputSource,
}
impl PaneState {
#[must_use]
pub const fn id(&self) -> PaneId {
self.fixture.id
}
#[must_use]
pub const fn fixture(&self) -> &PaneFixture {
&self.fixture
}
#[must_use]
pub const fn kind(&self) -> SurfaceKind {
self.fixture.kind
}
#[must_use]
pub const fn status(&self) -> PaneStatus {
self.status
}
#[must_use]
pub const fn needs_input(&self) -> bool {
matches!(self.status, PaneStatus::NeedsInput)
}
#[must_use]
pub fn lines(&self) -> &[String] {
&self.lines
}
#[must_use]
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)]
pub struct CommandPaletteState {
open: bool,
query: String,
}
impl CommandPaletteState {
#[must_use]
pub const fn is_open(&self) -> bool {
self.open
}
#[must_use]
pub fn query(&self) -> &str {
&self.query
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum FocusDirection {
Left,
Right,
Up,
Down,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ShellAction {
MoveFocus(FocusDirection),
SelectPane(PaneId),
AttachPanel(PaneId),
DetachPanel(PaneId),
SetNeedsInput { pane: PaneId, needs_input: bool },
OpenCommandPalette,
CloseCommandPalette,
SetCommandPaletteQuery(String),
SyntheticStreamTick,
AppendExternalOutput { pane: PaneId, lines: Vec<String> },
ReplaceExternalOutput { pane: PaneId, lines: Vec<String> },
}
/// Counters prove both candidates replayed the same workload. Framework
/// adapters may use the event sequence and revision for explicitly labelled
/// timing stages; these counters do not imply pixels reached the display.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct MeasurementCounters {
pub events_dispatched: u64,
pub state_changes: u64,
pub focus_moves: u64,
pub blocked_focus_moves: u64,
pub direct_selections: u64,
pub panel_attaches: u64,
pub panel_detaches: u64,
pub blocked_panel_detaches: u64,
pub needs_input_changes: u64,
pub palette_changes: u64,
pub synthetic_ticks: u64,
/// 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,
pub external_snapshot_updates: u64,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ActionOutcome {
pub event_sequence: u64,
pub revision: u64,
pub changed: bool,
pub selected_pane: PaneId,
pub terminal_lines_appended: usize,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct InvalidTerminalLineLimit;
impl fmt::Display for InvalidTerminalLineLimit {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("terminal line limit must be greater than zero")
}
}
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],
attached_panels: [bool; GRID_ROWS * GRID_COLUMNS],
selected_pane: PaneId,
command_palette: CommandPaletteState,
terminal_line_limit: usize,
synthetic_tick: u64,
revision: u64,
counters: MeasurementCounters,
}
impl Default for ShellModel {
fn default() -> Self {
Self::with_terminal_line_limit(DEFAULT_TERMINAL_LINE_LIMIT)
.expect("the default terminal line limit is non-zero")
}
}
impl ShellModel {
pub fn with_terminal_line_limit(limit: usize) -> Result<Self, InvalidTerminalLineLimit> {
if limit == 0 {
return Err(InvalidTerminalLineLimit);
}
let panes = PANES.map(|fixture| {
let mut lines = fixture.lines.map(String::from).to_vec();
if fixture.kind == SurfaceKind::Terminal && lines.len() > limit {
lines.drain(..lines.len() - limit);
}
PaneState {
fixture,
status: fixture.status,
status_before_input: (fixture.status == PaneStatus::NeedsInput)
.then_some(PaneStatus::Working),
lines,
synthetic_line_sequence: 0,
output_source: OutputSource::Deterministic,
}
});
Ok(Self {
panes,
attached_panels: std::array::from_fn(|index| index < INITIAL_ATTACHED_PANEL_COUNT),
selected_pane: PaneId::CodexRuntime,
command_palette: CommandPaletteState::default(),
terminal_line_limit: limit,
synthetic_tick: 0,
revision: 0,
counters: MeasurementCounters::default(),
})
}
/// 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> {
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();
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();
}
Ok(model)
}
/// Creates the legacy six-card comparison state used by the Floem spike.
#[must_use]
pub fn with_all_panels_attached() -> Self {
let mut model = Self::default();
model.attached_panels.fill(true);
model
}
#[must_use]
pub fn panes(&self) -> &[PaneState; GRID_ROWS * GRID_COLUMNS] {
&self.panes
}
#[must_use]
pub fn pane(&self, id: PaneId) -> &PaneState {
&self.panes[id.index()]
}
#[must_use]
pub fn attached_pane_ids(&self) -> Vec<PaneId> {
PaneId::ALL
.into_iter()
.filter(|pane| self.is_panel_attached(*pane))
.collect()
}
#[must_use]
pub fn detached_pane_ids(&self) -> Vec<PaneId> {
PaneId::ALL
.into_iter()
.filter(|pane| !self.is_panel_attached(*pane))
.collect()
}
#[must_use]
pub const fn is_panel_attached(&self, pane: PaneId) -> bool {
self.attached_panels[pane.index()]
}
#[must_use]
pub fn attached_panel_count(&self) -> usize {
self.attached_panels
.iter()
.filter(|attached| **attached)
.count()
}
#[must_use]
pub fn detached_panel_count(&self) -> usize {
self.attached_panels.len() - self.attached_panel_count()
}
#[must_use]
pub fn next_detached_panel(&self) -> Option<PaneId> {
PaneId::ALL
.into_iter()
.find(|pane| !self.is_panel_attached(*pane))
}
#[must_use]
pub const fn selected_pane(&self) -> PaneId {
self.selected_pane
}
#[must_use]
pub const fn command_palette(&self) -> &CommandPaletteState {
&self.command_palette
}
#[must_use]
pub const fn terminal_line_limit(&self) -> usize {
self.terminal_line_limit
}
#[must_use]
pub const fn synthetic_tick(&self) -> u64 {
self.synthetic_tick
}
#[must_use]
pub const fn revision(&self) -> u64 {
self.revision
}
#[must_use]
pub const fn counters(&self) -> MeasurementCounters {
self.counters
}
pub fn dispatch(&mut self, action: ShellAction) -> ActionOutcome {
self.counters.events_dispatched += 1;
let mut changed = false;
let mut appended = 0;
match action {
ShellAction::MoveFocus(direction) => {
if let Some(next) =
attached_focus_neighbor(&self.attached_panels, self.selected_pane, direction)
{
self.selected_pane = next;
self.counters.focus_moves += 1;
changed = true;
} else {
self.counters.blocked_focus_moves += 1;
}
}
ShellAction::SelectPane(pane) => {
if self.is_panel_attached(pane) && self.selected_pane != pane {
self.selected_pane = pane;
self.counters.direct_selections += 1;
changed = true;
}
}
ShellAction::AttachPanel(pane) => {
if !self.is_panel_attached(pane) {
self.attached_panels[pane.index()] = true;
self.selected_pane = pane;
self.counters.panel_attaches += 1;
changed = true;
}
}
ShellAction::DetachPanel(pane) => {
if self.is_panel_attached(pane) {
if self.attached_panel_count() == 1 {
self.counters.blocked_panel_detaches += 1;
} else {
let attached_before = self.attached_pane_ids();
let detached_position = attached_before
.iter()
.position(|candidate| *candidate == pane)
.expect("attached panel must appear in attached IDs");
self.attached_panels[pane.index()] = false;
if self.selected_pane == pane {
let remaining = self.attached_pane_ids();
self.selected_pane =
remaining[detached_position.min(remaining.len().saturating_sub(1))];
}
self.counters.panel_detaches += 1;
changed = true;
}
}
}
ShellAction::SetNeedsInput { pane, needs_input } => {
let pane = &mut self.panes[pane.index()];
if needs_input && !pane.needs_input() {
pane.status_before_input = Some(pane.status);
pane.status = PaneStatus::NeedsInput;
self.counters.needs_input_changes += 1;
changed = true;
} else if !needs_input && pane.needs_input() {
pane.status = pane.status_before_input.take().unwrap_or(PaneStatus::Ready);
self.counters.needs_input_changes += 1;
changed = true;
}
}
ShellAction::OpenCommandPalette => {
if !self.command_palette.open {
self.command_palette.open = true;
self.command_palette.query.clear();
self.counters.palette_changes += 1;
changed = true;
}
}
ShellAction::CloseCommandPalette => {
if self.command_palette.open {
self.command_palette.open = false;
self.command_palette.query.clear();
self.counters.palette_changes += 1;
changed = true;
}
}
ShellAction::SetCommandPaletteQuery(query) => {
if self.command_palette.open && self.command_palette.query != query {
self.command_palette.query = query;
self.counters.palette_changes += 1;
changed = true;
}
}
ShellAction::SyntheticStreamTick => {
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;
}
pane.lines.push(format!(
"[tick {:06}] {} · synthetic line {:06}",
self.synthetic_tick,
pane.id(),
pane.synthetic_line_sequence
));
if pane.lines.len() > self.terminal_line_limit {
pane.lines.remove(0);
}
appended += 1;
}
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;
}
}
ShellAction::ReplaceExternalOutput { pane, mut lines } => {
let pane = &mut self.panes[pane.index()];
if pane.output_source == OutputSource::External {
if lines.len() > self.terminal_line_limit {
lines.drain(..lines.len() - self.terminal_line_limit);
}
if pane.lines != lines {
pane.lines = lines;
self.counters.surface_updates += 1;
self.counters.external_output_batches += 1;
self.counters.external_snapshot_updates += 1;
changed = true;
}
}
}
}
if changed {
self.revision += 1;
self.counters.state_changes += 1;
}
ActionOutcome {
event_sequence: self.counters.events_dispatched,
revision: self.revision,
changed,
selected_pane: self.selected_pane,
terminal_lines_appended: appended,
}
}
}
#[must_use]
pub const fn focus_neighbor(pane: PaneId, direction: FocusDirection) -> Option<PaneId> {
let next = match direction {
FocusDirection::Left if pane.index() > 0 => Some(pane.index() - 1),
FocusDirection::Right if pane.index() + 1 < PaneId::ALL.len() => Some(pane.index() + 1),
_ => None,
};
match next {
Some(index) => Some(PaneId::ALL[index]),
None => None,
}
}
fn attached_focus_neighbor(
attached: &[bool; GRID_ROWS * GRID_COLUMNS],
pane: PaneId,
direction: FocusDirection,
) -> Option<PaneId> {
match direction {
FocusDirection::Left => PaneId::ALL[..pane.index()]
.iter()
.rev()
.copied()
.find(|candidate| attached[candidate.index()]),
FocusDirection::Right => PaneId::ALL[pane.index() + 1..]
.iter()
.copied()
.find(|candidate| attached[candidate.index()]),
FocusDirection::Up | FocusDirection::Down => None,
}
}
#[cfg(test)]
mod tests {
use super::{
FocusDirection, GRID_COLUMNS, GRID_ROWS, OutputSource, PANES, PaneId, PaneStatus,
ShellAction, ShellModel, SurfaceKind, focus_neighbor,
};
#[test]
fn fixture_has_stable_ids_and_mixed_surfaces() {
assert_eq!(PANES.len(), GRID_ROWS * GRID_COLUMNS);
assert_eq!(PANES.map(|pane| pane.id), PaneId::ALL);
assert_eq!(PaneId::CodexRuntime.stable_name(), "codex-runtime");
assert_eq!(PaneId::RuntimeReview.to_string(), "runtime-review");
assert!(PANES.iter().any(|pane| pane.kind == SurfaceKind::Markdown));
assert!(PANES.iter().any(|pane| pane.kind == SurfaceKind::Browser));
assert_eq!(
PANES
.iter()
.filter(|p| p.kind == SurfaceKind::Terminal)
.count(),
3
);
}
#[test]
fn initial_state_has_one_explicit_needs_input_pane() {
let model = ShellModel::default();
assert_eq!(model.selected_pane(), PaneId::CodexRuntime);
assert_eq!(model.attached_panel_count(), 5);
assert_eq!(model.detached_pane_ids(), vec![PaneId::RuntimeReview]);
assert_eq!(model.revision(), 0);
assert!(!model.command_palette().is_open());
assert_eq!(
model
.panes()
.iter()
.filter(|pane| pane.needs_input())
.map(|pane| pane.id())
.collect::<Vec<_>>(),
vec![PaneId::ClaudeUi]
);
}
#[test]
fn legacy_comparison_constructor_attaches_all_six_without_an_event() {
let model = ShellModel::with_all_panels_attached();
assert_eq!(model.attached_panel_count(), PaneId::ALL.len());
assert_eq!(model.revision(), 0);
assert_eq!(model.counters().events_dispatched, 0);
}
#[test]
fn neighbors_follow_the_horizontal_panel_row_without_wrapping() {
assert_eq!(
focus_neighbor(PaneId::CodexRuntime, FocusDirection::Right),
Some(PaneId::ClaudeUi)
);
assert_eq!(
focus_neighbor(PaneId::ClaudeUi, FocusDirection::Left),
Some(PaneId::CodexRuntime)
);
assert_eq!(
focus_neighbor(PaneId::AcpPreview, FocusDirection::Right),
Some(PaneId::RuntimeReview)
);
assert_eq!(
focus_neighbor(PaneId::CodexRuntime, FocusDirection::Left),
None
);
assert_eq!(
focus_neighbor(PaneId::RuntimeReview, FocusDirection::Right),
None
);
assert_eq!(
focus_neighbor(PaneId::CodexRuntime, FocusDirection::Up),
None
);
}
#[test]
fn focus_updates_selection_revision_and_blocked_counter() {
let mut model = ShellModel::default();
let blocked = model.dispatch(ShellAction::MoveFocus(FocusDirection::Left));
assert!(!blocked.changed);
assert_eq!((blocked.event_sequence, blocked.revision), (1, 0));
let moved = model.dispatch(ShellAction::MoveFocus(FocusDirection::Right));
assert!(moved.changed);
assert_eq!(moved.selected_pane, PaneId::ClaudeUi);
assert_eq!(moved.revision, 1);
model.dispatch(ShellAction::MoveFocus(FocusDirection::Right));
assert_eq!(model.selected_pane(), PaneId::PiDocs);
assert_eq!(model.counters().focus_moves, 2);
assert_eq!(model.counters().blocked_focus_moves, 1);
}
#[test]
fn detach_is_reversible_and_keeps_the_surface_running() {
let mut model = ShellModel::default();
model.dispatch(ShellAction::SyntheticStreamTick);
let sequence_before = model.pane(PaneId::ClaudeUi).synthetic_line_sequence();
let detached = model.dispatch(ShellAction::DetachPanel(PaneId::ClaudeUi));
assert!(detached.changed);
assert!(!model.is_panel_attached(PaneId::ClaudeUi));
assert_eq!(model.attached_panel_count(), 4);
model.dispatch(ShellAction::SyntheticStreamTick);
assert_eq!(
model.pane(PaneId::ClaudeUi).synthetic_line_sequence(),
sequence_before + 1
);
let attached = model.dispatch(ShellAction::AttachPanel(PaneId::ClaudeUi));
assert!(attached.changed);
assert!(model.is_panel_attached(PaneId::ClaudeUi));
assert_eq!(model.selected_pane(), PaneId::ClaudeUi);
assert_eq!(model.counters().panel_detaches, 1);
assert_eq!(model.counters().panel_attaches, 1);
}
#[test]
fn detaching_selected_panel_prefers_the_next_sibling_then_previous() {
let mut model = ShellModel::default();
model.dispatch(ShellAction::SelectPane(PaneId::PiDocs));
model.dispatch(ShellAction::DetachPanel(PaneId::PiDocs));
assert_eq!(model.selected_pane(), PaneId::Architecture);
model.dispatch(ShellAction::SelectPane(PaneId::AcpPreview));
model.dispatch(ShellAction::DetachPanel(PaneId::AcpPreview));
assert_eq!(model.selected_pane(), PaneId::Architecture);
}
#[test]
fn the_last_attached_panel_cannot_be_detached() {
let mut model = ShellModel::default();
for pane in [
PaneId::ClaudeUi,
PaneId::PiDocs,
PaneId::Architecture,
PaneId::AcpPreview,
] {
assert!(model.dispatch(ShellAction::DetachPanel(pane)).changed);
}
assert_eq!(model.attached_pane_ids(), vec![PaneId::CodexRuntime]);
let blocked = model.dispatch(ShellAction::DetachPanel(PaneId::CodexRuntime));
assert!(!blocked.changed);
assert_eq!(model.attached_panel_count(), 1);
assert_eq!(model.counters().blocked_panel_detaches, 1);
}
#[test]
fn hidden_panels_cannot_take_keyboard_focus() {
let mut model = ShellModel::default();
let outcome = model.dispatch(ShellAction::SelectPane(PaneId::RuntimeReview));
assert!(!outcome.changed);
assert_eq!(model.selected_pane(), PaneId::CodexRuntime);
model.dispatch(ShellAction::DetachPanel(PaneId::ClaudeUi));
let moved = model.dispatch(ShellAction::MoveFocus(FocusDirection::Right));
assert_eq!(moved.selected_pane, PaneId::PiDocs);
}
#[test]
fn direct_selection_is_idempotent_and_counted_separately() {
let mut model = ShellModel::default();
assert!(
!model
.dispatch(ShellAction::SelectPane(PaneId::CodexRuntime))
.changed
);
assert!(
model
.dispatch(ShellAction::SelectPane(PaneId::AcpPreview))
.changed
);
assert_eq!(model.selected_pane(), PaneId::AcpPreview);
assert_eq!(model.counters().direct_selections, 1);
assert_eq!(model.counters().events_dispatched, 2);
assert_eq!(model.counters().state_changes, 1);
}
#[test]
fn needs_input_restores_previous_status() {
let mut model = ShellModel::default();
model.dispatch(ShellAction::SetNeedsInput {
pane: PaneId::PiDocs,
needs_input: true,
});
assert_eq!(model.pane(PaneId::PiDocs).status(), PaneStatus::NeedsInput);
assert!(
!model
.dispatch(ShellAction::SetNeedsInput {
pane: PaneId::PiDocs,
needs_input: true,
})
.changed
);
model.dispatch(ShellAction::SetNeedsInput {
pane: PaneId::PiDocs,
needs_input: false,
});
assert_eq!(model.pane(PaneId::PiDocs).status(), PaneStatus::Streaming);
model.dispatch(ShellAction::SetNeedsInput {
pane: PaneId::ClaudeUi,
needs_input: false,
});
assert_eq!(model.pane(PaneId::ClaudeUi).status(), PaneStatus::Working);
assert_eq!(model.counters().needs_input_changes, 3);
}
#[test]
fn command_palette_has_explicit_lifecycle() {
let mut model = ShellModel::default();
assert!(
!model
.dispatch(ShellAction::SetCommandPaletteQuery("pane".into()))
.changed
);
model.dispatch(ShellAction::OpenCommandPalette);
model.dispatch(ShellAction::SetCommandPaletteQuery("pane: next".into()));
assert!(model.command_palette().is_open());
assert_eq!(model.command_palette().query(), "pane: next");
model.dispatch(ShellAction::CloseCommandPalette);
assert!(!model.command_palette().is_open());
assert_eq!(model.command_palette().query(), "");
assert_eq!(model.counters().palette_changes, 3);
}
#[test]
fn ticks_append_predictable_lines_to_terminals_only() {
let mut model = ShellModel::default();
let markdown = model.pane(PaneId::Architecture).lines().to_vec();
let outcome = model.dispatch(ShellAction::SyntheticStreamTick);
assert_eq!(outcome.terminal_lines_appended, 3);
assert_eq!(model.synthetic_tick(), 1);
assert_eq!(
model.pane(PaneId::CodexRuntime).lines().last().unwrap(),
"[tick 000001] codex-runtime · synthetic line 000001"
);
assert_eq!(model.pane(PaneId::Architecture).lines(), markdown);
assert!(
model
.panes()
.iter()
.all(|pane| pane.synthetic_line_sequence() == 1)
);
assert_eq!(model.counters().synthetic_ticks, 1);
assert_eq!(model.counters().surface_updates, 6);
assert_eq!(model.counters().terminal_lines_appended, 3);
}
#[test]
fn terminal_scrollback_is_bounded_below_fixture_size() {
let mut model = ShellModel::with_terminal_line_limit(2).unwrap();
assert_eq!(model.pane(PaneId::CodexRuntime).lines().len(), 2);
for _ in 0..10 {
model.dispatch(ShellAction::SyntheticStreamTick);
}
for pane in model
.panes()
.iter()
.filter(|pane| pane.kind() == SurfaceKind::Terminal)
{
assert_eq!(pane.lines().len(), 2);
assert_eq!(pane.synthetic_line_sequence(), 10);
assert!(pane.lines()[0].starts_with("[tick 000009]"));
assert!(pane.lines()[1].starts_with("[tick 000010]"));
}
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);
let snapshot = model.dispatch(ShellAction::ReplaceExternalOutput {
pane: PaneId::CodexRuntime,
lines: vec!["prompt $".into()],
});
assert!(snapshot.changed);
assert_eq!(model.pane(PaneId::CodexRuntime).lines(), ["prompt $"]);
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());
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 = [
ShellAction::MoveFocus(FocusDirection::Right),
ShellAction::DetachPanel(PaneId::PiDocs),
ShellAction::AttachPanel(PaneId::RuntimeReview),
ShellAction::OpenCommandPalette,
ShellAction::SetCommandPaletteQuery("workspace".into()),
ShellAction::CloseCommandPalette,
ShellAction::SetNeedsInput {
pane: PaneId::CodexRuntime,
needs_input: true,
},
ShellAction::SyntheticStreamTick,
ShellAction::SyntheticStreamTick,
];
let mut gpui = ShellModel::default();
let mut floem = ShellModel::default();
let gpui_outcomes = actions
.iter()
.cloned()
.map(|action| gpui.dispatch(action))
.collect::<Vec<_>>();
let floem_outcomes = actions
.iter()
.cloned()
.map(|action| floem.dispatch(action))
.collect::<Vec<_>>();
assert_eq!(gpui_outcomes, floem_outcomes);
assert_eq!(gpui, floem);
assert_eq!(gpui.counters().events_dispatched, actions.len() as u64);
assert_eq!(gpui.counters().surface_updates, 12);
assert_eq!(gpui.counters().terminal_lines_appended, 6);
}
}