//! 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, lines: Vec, 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 }, ReplaceExternalOutput { pane: PaneId, lines: Vec }, } /// 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 { 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::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, ) -> Result { 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::ALL .into_iter() .filter(|pane| self.is_panel_attached(*pane)) .collect() } #[must_use] pub fn detached_pane_ids(&self) -> Vec { 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::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 { 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 { 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![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::>(); let floem_outcomes = actions .iter() .cloned() .map(|action| floem.dispatch(action)) .collect::>(); 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); } }