feat: add reversible workspace panels
CI / rust (push) Successful in 2m11s

This commit is contained in:
2026-08-31 18:18:20 -07:00
parent 63c9cb6210
commit abcf664ab8
12 changed files with 652 additions and 90 deletions
+1 -1
View File
@@ -346,7 +346,7 @@ fn handle_key(
}
fn app_view() -> impl IntoView {
let model = RwSignal::new(ShellModel::default());
let model = RwSignal::new(ShellModel::with_all_panels_attached());
let query = RwSignal::new(String::new());
let timer_pulse = RwSignal::new(());
+210 -62
View File
@@ -10,8 +10,8 @@ use lumbridge_runtime::{
RuntimeCommand, RuntimeEvent, TerminalSize,
};
use lumbridge_spike_model::{
ActionOutcome, FOOTER_RIGHT, FocusDirection, OutputSource, PaneId, PaneState, ShellAction,
ShellModel, SurfaceKind, WORKSPACES,
ActionOutcome, FOOTER_RIGHT, FocusDirection, INITIAL_ATTACHED_PANEL_COUNT, OutputSource,
PaneId, PaneState, ShellAction, ShellModel, SurfaceKind, WORKSPACES,
};
use lumbridge_terminal::{
KeyModifiers, TerminalCellStyle, TerminalColor, TerminalCursorShape, TerminalDimensions,
@@ -62,6 +62,8 @@ actions!(
SelectPane4,
SelectPane5,
SelectPane6,
AddPanel,
DetachSelectedPanel,
TerminalTaller,
TerminalShorter,
TerminalWider,
@@ -272,14 +274,18 @@ fn dim_color(color: u32) -> u32 {
(dim((color >> 16) & 0xff) << 16) | (dim((color >> 8) & 0xff) << 8) | dim(color & 0xff)
}
fn terminal_dimensions_for_window(window_size: Size<Pixels>) -> TerminalDimensions {
fn terminal_dimensions_for_window(
window_size: Size<Pixels>,
attached_panel_count: usize,
) -> TerminalDimensions {
let width = f32::from(window_size.width);
let height = f32::from(window_size.height);
let panel_count = visible_panel_count(window_size);
let panel_count = visible_panel_count(window_size)
.min(attached_panel_count)
.max(1);
let workspace_height =
(height - APP_HEADER_HEIGHT - TAB_BAR_HEIGHT - APP_FOOTER_HEIGHT).max(0.0);
let terminal_height =
(workspace_height * 0.60 - TERMINAL_CONTENT_VERTICAL_INSET).max(0.0);
let terminal_height = (workspace_height * 0.60 - TERMINAL_CONTENT_VERTICAL_INSET).max(0.0);
let workspace_width = (width - SIDEBAR_WIDTH).max(0.0);
let panel_gaps = WORK_PANEL_GAP * panel_count.saturating_sub(1) as f32;
let panel_width = ((workspace_width - panel_gaps).max(0.0) / panel_count as f32).max(0.0);
@@ -401,7 +407,8 @@ impl LumbridgeShell {
})
.detach();
let terminal_dimensions = terminal_dimensions_for_window(window.bounds().size);
let terminal_dimensions =
terminal_dimensions_for_window(window.bounds().size, INITIAL_ATTACHED_PANEL_COUNT);
let terminal = TerminalEngine::new(TerminalEngineOptions {
dimensions: terminal_dimensions,
..TerminalEngineOptions::default()
@@ -413,7 +420,10 @@ impl LumbridgeShell {
};
cx.observe_window_bounds(window, |shell, window, cx| {
let dimensions = terminal_dimensions_for_window(window.bounds().size);
let dimensions = terminal_dimensions_for_window(
window.bounds().size,
shell.model.attached_panel_count(),
);
shell.resize_terminal(dimensions.rows(), dimensions.columns(), cx);
})
.detach();
@@ -559,6 +569,50 @@ impl LumbridgeShell {
cx.notify();
}
fn resize_terminal_for_workspace(&mut self, window: &Window, cx: &mut Context<Self>) {
let dimensions =
terminal_dimensions_for_window(window.bounds().size, self.model.attached_panel_count());
self.resize_terminal(dimensions.rows(), dimensions.columns(), cx);
}
fn attach_panel(&mut self, pane: PaneId, window: &mut Window, cx: &mut Context<Self>) {
let outcome = self.dispatch(ShellAction::AttachPanel(pane));
if outcome.changed {
self.resize_terminal_for_workspace(window, cx);
}
window.focus(&self.root_focus);
cx.notify();
}
fn add_panel(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(pane) = self.model.next_detached_panel() else {
return;
};
self.attach_panel(pane, window, cx);
}
fn detach_panel(&mut self, pane: PaneId, window: &mut Window, cx: &mut Context<Self>) {
let outcome = self.dispatch(ShellAction::DetachPanel(pane));
if outcome.changed {
self.resize_terminal_for_workspace(window, cx);
}
window.focus(&self.root_focus);
cx.notify();
}
fn add_panel_action(&mut self, _: &AddPanel, window: &mut Window, cx: &mut Context<Self>) {
self.add_panel(window, cx);
}
fn detach_selected_panel(
&mut self,
_: &DetachSelectedPanel,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.detach_panel(self.model.selected_pane(), window, cx);
}
fn move_focus(
&mut self,
direction: FocusDirection,
@@ -641,10 +695,9 @@ impl LumbridgeShell {
return;
}
let modifiers = key_modifiers(event);
if let Some(scroll) = terminal_scroll_from_parts(
event.keystroke.key.as_str(),
modifiers,
) {
if let Some(scroll) =
terminal_scroll_from_parts(event.keystroke.key.as_str(), modifiers)
{
self.scroll_terminal(scroll, cx);
return;
}
@@ -768,7 +821,14 @@ impl LumbridgeShell {
.into_any_element()
}
fn pane_context(&self, pane: &PaneState, selected: bool) -> gpui::AnyElement {
fn pane_context(
&self,
pane: &PaneState,
selected: bool,
cx: &mut Context<Self>,
) -> gpui::AnyElement {
let pane_id = pane.id();
let can_detach = self.model.attached_panel_count() > 1;
let external = pane.output_source() == OutputSource::External;
let status = if external {
self.runtime_status.badge()
@@ -859,16 +919,44 @@ impl LumbridgeShell {
)
.child(
div()
.flex()
.flex_col()
.items_end()
.flex_none()
.text_xs()
.text_color(rgb(if pane.needs_input() {
ATTENTION
} else if selected {
SUCCESS
} else {
MUTED
}))
.child(surface_status),
.gap_1()
.child(
div()
.text_xs()
.text_color(rgb(if pane.needs_input() {
ATTENTION
} else if selected {
SUCCESS
} else {
MUTED
}))
.child(surface_status),
)
.child(
div()
.id(("detach-panel", pane_id.index()))
.cursor_pointer()
.px_2()
.py_1()
.rounded(px(4.0))
.border_1()
.border_color(rgb(BORDER))
.text_xs()
.text_color(rgb(if can_detach { MUTED } else { BORDER }))
.child(if can_detach {
" DETACH"
} else {
"LAST PANEL"
})
.on_click(cx.listener(move |shell, _, window, cx| {
cx.stop_propagation();
shell.detach_panel(pane_id, window, cx);
})),
),
),
)
.child(
@@ -990,18 +1078,10 @@ impl LumbridgeShell {
.items_center()
.justify_between()
.text_xs()
.child(div().text_color(rgb(MUTED)).child("DECISION SHELF"))
.child(
div()
.text_color(rgb(MUTED))
.child("DECISION SHELF"),
)
.child(
div()
.text_color(rgb(if pane.needs_input() {
ATTENTION
} else {
MUTED
}))
.text_color(rgb(if pane.needs_input() { ATTENTION } else { MUTED }))
.child(if pane.needs_input() {
"REVIEW REQUIRED"
} else {
@@ -1009,16 +1089,9 @@ impl LumbridgeShell {
}),
),
)
.child(
div()
.flex()
.flex_col()
.gap_1()
.mt_2()
.children(choices.map(|(label, detail, attention)| {
choice(label, detail, attention)
})),
)
.child(div().flex().flex_col().gap_1().mt_2().children(
choices.map(|(label, detail, attention)| choice(label, detail, attention)),
))
.into_any_element()
}
@@ -1048,7 +1121,7 @@ impl LumbridgeShell {
.flex_none()
.min_h_0()
.overflow_hidden()
.child(self.pane_context(pane, selected)),
.child(self.pane_context(pane, selected, cx)),
)
.child(
div()
@@ -1128,7 +1201,26 @@ impl LumbridgeShell {
.px_3()
.py_2()
.text_color(rgb(MUTED))
.child("Open attention request"),
.child("Add workspace panel")
.child(
div()
.mt_1()
.text_xs()
.child("Alt+Shift+N · reattach if detached"),
),
)
.child(
div()
.px_3()
.py_2()
.text_color(rgb(MUTED))
.child("Detach selected panel")
.child(
div()
.mt_1()
.text_xs()
.child("Alt+Shift+W · session keeps running"),
),
)
.child(
div()
@@ -1158,7 +1250,17 @@ impl LumbridgeShell {
impl Render for LumbridgeShell {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let attention = self.model.pane(PaneId::ClaudeUi);
let panel_count = visible_panel_count(window.bounds().size);
let panel_capacity = visible_panel_count(window.bounds().size);
let attached_panes = self.model.attached_pane_ids();
let attached_count = attached_panes.len();
let detached_entries = self
.model
.detached_pane_ids()
.into_iter()
.map(|pane| (pane, self.model.pane(pane).fixture().title))
.collect::<Vec<_>>();
let detached_count = detached_entries.len();
let visible_count = panel_capacity.min(attached_count).max(1);
let sidebar = div()
.flex()
.flex_col()
@@ -1263,6 +1365,33 @@ impl Render for LumbridgeShell {
.text_color(rgb(MUTED))
.child("spark-1 · sleeping"),
)
.child(
div()
.mt_3()
.px_4()
.py_2()
.text_xs()
.text_color(rgb(MUTED))
.child(format!("DETACHED SESSIONS · {detached_count}")),
)
.children(detached_entries.into_iter().map(|(pane, title)| {
div()
.id(("detached-session", pane.index()))
.cursor_pointer()
.mx_2()
.mb_1()
.px_3()
.py_2()
.rounded(px(4.0))
.border_1()
.border_color(rgb(BORDER_QUIET))
.text_xs()
.text_color(rgb(MUTED))
.child(format!("{title}"))
.on_click(cx.listener(move |shell, _, window, cx| {
shell.attach_panel(pane, window, cx);
}))
}))
.child(div().flex_1())
.child(
div()
@@ -1317,33 +1446,44 @@ impl Render for LumbridgeShell {
.child("Review"),
)
.child(div().flex_1())
.child(div().px_3().text_xs().text_color(rgb(MUTED)).child(format!(
"{visible_count} shown · {attached_count} attached · {detached_count} detached"
)))
.child(
div()
.id("add-workspace-panel")
.cursor_pointer()
.ml_2()
.px_3()
.py_1()
.rounded(px(4.0))
.border_1()
.border_color(rgb(if detached_count > 0 { ACCENT } else { BORDER }))
.text_xs()
.text_color(rgb(MUTED))
.child(format!(
"{panel_count} visible panels · 1 interactive VT · 1 waiting"
)),
.text_color(rgb(if detached_count > 0 { ACCENT } else { MUTED }))
.child(if detached_count > 0 {
"+ ADD PANEL"
} else {
"ALL PANELS ATTACHED"
})
.on_click(cx.listener(|shell, _, window, cx| {
shell.add_panel(window, cx);
})),
);
let panel_range = visible_panel_range(
PaneId::ALL.len(),
self.model.selected_pane().index(),
panel_count,
);
let selected_position = attached_panes
.iter()
.position(|pane| *pane == self.model.selected_pane())
.expect("the selected pane must remain attached");
let panel_range = visible_panel_range(attached_count, selected_position, visible_count);
let workspace_panels = panel_range
.map(|index| {
let pane = self.model.pane(PaneId::ALL[index]);
let pane = self.model.pane(attached_panes[index]);
div()
.flex_1()
.min_w_0()
.min_h_0()
.child(self.workspace_panel(
pane,
pane.id() == self.model.selected_pane(),
cx,
))
.child(self.workspace_panel(pane, pane.id() == self.model.selected_pane(), cx))
})
.collect::<Vec<_>>();
let workspace_row = div()
@@ -1380,6 +1520,8 @@ impl Render for LumbridgeShell {
.on_action(cx.listener(Self::focus_down))
.on_action(cx.listener(Self::open_palette))
.on_action(cx.listener(Self::close_palette))
.on_action(cx.listener(Self::add_panel_action))
.on_action(cx.listener(Self::detach_selected_panel))
.on_action(cx.listener(Self::terminal_taller))
.on_action(cx.listener(Self::terminal_shorter))
.on_action(cx.listener(Self::terminal_wider))
@@ -1570,6 +1712,8 @@ fn main() {
KeyBinding::new("alt-4", SelectPane4, Some("LumbridgeShell")),
KeyBinding::new("alt-5", SelectPane5, Some("LumbridgeShell")),
KeyBinding::new("alt-6", SelectPane6, Some("LumbridgeShell")),
KeyBinding::new("alt-shift-n", AddPanel, Some("LumbridgeShell")),
KeyBinding::new("alt-shift-w", DetachSelectedPanel, Some("LumbridgeShell")),
KeyBinding::new("alt-shift-up", TerminalTaller, Some("LumbridgeShell")),
KeyBinding::new("alt-shift-down", TerminalShorter, Some("LumbridgeShell")),
KeyBinding::new("alt-shift-right", TerminalWider, Some("LumbridgeShell")),
@@ -1626,15 +1770,19 @@ mod tests {
#[test]
fn terminal_geometry_tracks_middle_sixty_percent_per_panel() {
let dimensions = terminal_dimensions_for_window(size(px(1500.0), px(960.0)));
let dimensions = terminal_dimensions_for_window(size(px(1500.0), px(960.0)), 5);
assert_eq!(dimensions.rows(), 26);
assert_eq!(dimensions.columns(), 45);
let ultrawide = size(px(3440.0), px(1440.0));
assert_eq!(visible_panel_count(ultrawide), 5);
let dimensions = terminal_dimensions_for_window(ultrawide);
let dimensions = terminal_dimensions_for_window(ultrawide, 5);
assert_eq!(dimensions.rows(), 42);
assert_eq!(dimensions.columns(), 71);
let two_panels = terminal_dimensions_for_window(ultrawide, 2);
assert_eq!(two_panels.rows(), 42);
assert_eq!(two_panels.columns(), 185);
}
#[test]
+198 -20
View File
@@ -7,6 +7,7 @@ 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.
@@ -282,6 +283,8 @@ pub enum FocusDirection {
pub enum ShellAction {
MoveFocus(FocusDirection),
SelectPane(PaneId),
AttachPanel(PaneId),
DetachPanel(PaneId),
SetNeedsInput { pane: PaneId, needs_input: bool },
OpenCommandPalette,
CloseCommandPalette,
@@ -301,6 +304,9 @@ pub struct MeasurementCounters {
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,
@@ -344,6 +350,7 @@ 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,
@@ -381,6 +388,7 @@ impl ShellModel {
});
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,
@@ -406,6 +414,14 @@ impl ShellModel {
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
@@ -415,6 +431,41 @@ impl ShellModel {
&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
}
@@ -445,7 +496,9 @@ impl ShellModel {
let mut appended = 0;
match action {
ShellAction::MoveFocus(direction) => {
if let Some(next) = focus_neighbor(self.selected_pane, 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;
@@ -454,12 +507,41 @@ impl ShellModel {
}
}
ShellAction::SelectPane(pane) => {
if self.selected_pane != 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() {
@@ -570,13 +652,9 @@ impl ShellModel {
#[must_use]
pub const fn focus_neighbor(pane: PaneId, direction: FocusDirection) -> Option<PaneId> {
let row = pane.row();
let column = pane.column();
let next = match direction {
FocusDirection::Left if column > 0 => Some(pane.index() - 1),
FocusDirection::Right if column + 1 < GRID_COLUMNS => Some(pane.index() + 1),
FocusDirection::Up if row > 0 => Some(pane.index() - GRID_COLUMNS),
FocusDirection::Down if row + 1 < GRID_ROWS => Some(pane.index() + GRID_COLUMNS),
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 {
@@ -585,6 +663,25 @@ pub const fn focus_neighbor(pane: PaneId, direction: FocusDirection) -> Option<P
}
}
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::{
@@ -613,6 +710,8 @@ mod tests {
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!(
@@ -627,24 +726,35 @@ mod tests {
}
#[test]
fn neighbors_match_two_by_three_grid_without_wrapping() {
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::Down),
Some(PaneId::AcpPreview)
focus_neighbor(PaneId::ClaudeUi, FocusDirection::Left),
Some(PaneId::CodexRuntime)
);
assert_eq!(
focus_neighbor(PaneId::RuntimeReview, FocusDirection::Up),
Some(PaneId::PiDocs)
focus_neighbor(PaneId::AcpPreview, FocusDirection::Right),
Some(PaneId::RuntimeReview)
);
assert_eq!(
focus_neighbor(PaneId::Architecture, FocusDirection::Left),
focus_neighbor(PaneId::CodexRuntime, FocusDirection::Left),
None
);
assert_eq!(
focus_neighbor(PaneId::RuntimeReview, FocusDirection::Right),
None
);
assert_eq!(focus_neighbor(PaneId::PiDocs, FocusDirection::Right), None);
assert_eq!(
focus_neighbor(PaneId::CodexRuntime, FocusDirection::Up),
None
@@ -661,12 +771,79 @@ mod tests {
assert!(moved.changed);
assert_eq!(moved.selected_pane, PaneId::ClaudeUi);
assert_eq!(moved.revision, 1);
model.dispatch(ShellAction::MoveFocus(FocusDirection::Down));
assert_eq!(model.selected_pane(), PaneId::AcpPreview);
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();
@@ -677,10 +854,10 @@ mod tests {
);
assert!(
model
.dispatch(ShellAction::SelectPane(PaneId::RuntimeReview))
.dispatch(ShellAction::SelectPane(PaneId::AcpPreview))
.changed
);
assert_eq!(model.selected_pane(), PaneId::RuntimeReview);
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);
@@ -829,7 +1006,8 @@ mod tests {
fn identical_action_replays_produce_identical_models_and_counters() {
let actions = [
ShellAction::MoveFocus(FocusDirection::Right),
ShellAction::MoveFocus(FocusDirection::Down),
ShellAction::DetachPanel(PaneId::PiDocs),
ShellAction::AttachPanel(PaneId::RuntimeReview),
ShellAction::OpenCommandPalette,
ShellAction::SetCommandPaletteQuery("workspace".into()),
ShellAction::CloseCommandPalette,