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>
1079 lines
37 KiB
Rust
1079 lines
37 KiB
Rust
//! 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);
|
||
}
|
||
}
|