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
+170 -2
View File
@@ -268,6 +268,13 @@ pub enum WorkspaceCommand {
ratio: SplitRatio, ratio: SplitRatio,
placement: PanePlacement, placement: PanePlacement,
}, },
AttachPane {
target: PaneId,
pane: PaneId,
axis: SplitAxis,
ratio: SplitRatio,
placement: PanePlacement,
},
SelectPane { SelectPane {
pane: PaneId, pane: PaneId,
}, },
@@ -291,6 +298,7 @@ impl WorkspaceCommand {
} => WorkspaceCapability::Execute, } => WorkspaceCapability::Execute,
Self::SplitPane { pane, .. } if pane.launch.is_some() => WorkspaceCapability::Execute, Self::SplitPane { pane, .. } if pane.launch.is_some() => WorkspaceCapability::Execute,
Self::SplitPane { .. } Self::SplitPane { .. }
| Self::AttachPane { .. }
| Self::SelectPane { .. } | Self::SelectPane { .. }
| Self::RenamePane { .. } | Self::RenamePane { .. }
| Self::ClosePane { | Self::ClosePane {
@@ -319,6 +327,10 @@ pub enum WorkspaceEvent {
PaneSelected { PaneSelected {
pane: PaneId, pane: PaneId,
}, },
PaneAttached {
target: PaneId,
pane: PaneId,
},
PaneRenamed { PaneRenamed {
pane: PaneId, pane: PaneId,
}, },
@@ -366,6 +378,7 @@ pub struct WorkspaceState {
id: WorkspaceId, id: WorkspaceId,
name: String, name: String,
panes: BTreeMap<PaneId, PaneDefinition>, panes: BTreeMap<PaneId, PaneDefinition>,
detached_panes: BTreeMap<PaneId, PaneDefinition>,
layout: LayoutNode, layout: LayoutNode,
selected: PaneId, selected: PaneId,
applied_requests: BTreeMap<CommandRequestId, WorkspaceRequest>, applied_requests: BTreeMap<CommandRequestId, WorkspaceRequest>,
@@ -396,6 +409,7 @@ impl WorkspaceState {
id, id,
name, name,
panes, panes,
detached_panes: BTreeMap::new(),
layout, layout,
selected, selected,
applied_requests: BTreeMap::new(), applied_requests: BTreeMap::new(),
@@ -433,11 +447,21 @@ impl WorkspaceState {
self.panes.get(id) self.panes.get(id)
} }
#[must_use]
pub fn detached_pane(&self, id: &PaneId) -> Option<&PaneDefinition> {
self.detached_panes.get(id)
}
#[must_use] #[must_use]
pub fn pane_count(&self) -> usize { pub fn pane_count(&self) -> usize {
self.panes.len() self.panes.len()
} }
#[must_use]
pub fn detached_pane_count(&self) -> usize {
self.detached_panes.len()
}
/// Applies one idempotent command after checking its required capability. /// Applies one idempotent command after checking its required capability.
/// ///
/// # Errors /// # Errors
@@ -511,7 +535,7 @@ impl WorkspaceState {
if !self.layout.contains(&target) { if !self.layout.contains(&target) {
return Err(WorkspaceError::PaneNotFound(target)); return Err(WorkspaceError::PaneNotFound(target));
} }
if self.panes.contains_key(&pane.id) { if self.panes.contains_key(&pane.id) || self.detached_panes.contains_key(&pane.id) {
return Err(WorkspaceError::PaneAlreadyExists(pane.id)); return Err(WorkspaceError::PaneAlreadyExists(pane.id));
} }
pane.validate()?; pane.validate()?;
@@ -527,6 +551,31 @@ impl WorkspaceState {
pane: pane_id, pane: pane_id,
}) })
} }
WorkspaceCommand::AttachPane {
target,
pane,
axis,
ratio,
placement,
} => {
if !self.layout.contains(&target) {
return Err(WorkspaceError::PaneNotFound(target));
}
if self.panes.contains_key(&pane) {
return Err(WorkspaceError::PaneAlreadyExists(pane));
}
let definition = self
.detached_panes
.remove(&pane)
.ok_or_else(|| WorkspaceError::PaneNotFound(pane.clone()))?;
let did_split = self
.layout
.split(&target, pane.clone(), axis, ratio, placement);
debug_assert!(did_split, "target presence was checked");
self.panes.insert(pane.clone(), definition);
self.selected = pane.clone();
Ok(WorkspaceEvent::PaneAttached { target, pane })
}
WorkspaceCommand::SelectPane { pane } => { WorkspaceCommand::SelectPane { pane } => {
if !self.panes.contains_key(&pane) { if !self.panes.contains_key(&pane) {
return Err(WorkspaceError::PaneNotFound(pane)); return Err(WorkspaceError::PaneNotFound(pane));
@@ -546,6 +595,11 @@ impl WorkspaceState {
Ok(WorkspaceEvent::PaneRenamed { pane }) Ok(WorkspaceEvent::PaneRenamed { pane })
} }
WorkspaceCommand::ClosePane { pane, disposition } => { WorkspaceCommand::ClosePane { pane, disposition } => {
if disposition == PaneCloseDisposition::Terminate
&& self.detached_panes.remove(&pane).is_some()
{
return Ok(WorkspaceEvent::PaneClosed { pane, disposition });
}
if self.panes.len() == 1 && self.panes.contains_key(&pane) { if self.panes.len() == 1 && self.panes.contains_key(&pane) {
return Err(WorkspaceError::CannotCloseLastPane); return Err(WorkspaceError::CannotCloseLastPane);
} }
@@ -557,7 +611,10 @@ impl WorkspaceState {
.clone() .clone()
.remove(&pane) .remove(&pane)
.ok_or(WorkspaceError::CannotCloseLastPane)?; .ok_or(WorkspaceError::CannotCloseLastPane)?;
self.panes.remove(&pane); let definition = self.panes.remove(&pane).expect("pane presence was checked");
if disposition == PaneCloseDisposition::Detach {
self.detached_panes.insert(pane.clone(), definition);
}
self.layout = layout; self.layout = layout;
if self.selected == pane { if self.selected == pane {
self.selected = self.layout.first_pane().clone(); self.selected = self.layout.first_pane().clone();
@@ -580,6 +637,11 @@ impl fmt::Debug for WorkspaceEvent {
.debug_struct("PaneSelected") .debug_struct("PaneSelected")
.field("pane", pane) .field("pane", pane)
.finish(), .finish(),
Self::PaneAttached { target, pane } => formatter
.debug_struct("PaneAttached")
.field("target", target)
.field("pane", pane)
.finish(),
Self::PaneRenamed { pane } => formatter Self::PaneRenamed { pane } => formatter
.debug_struct("PaneRenamed") .debug_struct("PaneRenamed")
.field("pane", pane) .field("pane", pane)
@@ -828,6 +890,8 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(state.selected().as_str(), "root"); assert_eq!(state.selected().as_str(), "root");
assert_eq!(state.pane_count(), 1); assert_eq!(state.pane_count(), 1);
assert_eq!(state.detached_pane_count(), 1);
assert!(state.detached_pane(&id(PaneId::new, "second")).is_some());
assert!(matches!( assert!(matches!(
state.apply( state.apply(
request( request(
@@ -843,6 +907,110 @@ mod tests {
)); ));
} }
#[test]
fn detached_pane_can_be_reattached_without_a_new_launch() {
let mut state = workspace();
state
.apply(
request(
"split",
WorkspaceCommand::SplitPane {
target: id(PaneId::new, "root"),
pane: pane("agent", true),
axis: SplitAxis::Horizontal,
ratio: SplitRatio::default(),
placement: PanePlacement::After,
},
),
WorkspaceCapability::Execute,
)
.unwrap();
state
.apply(
request(
"detach",
WorkspaceCommand::ClosePane {
pane: id(PaneId::new, "agent"),
disposition: PaneCloseDisposition::Detach,
},
),
WorkspaceCapability::Configure,
)
.unwrap();
assert_eq!(state.pane_count(), 1);
assert_eq!(state.detached_pane_count(), 1);
state
.apply(
request(
"attach",
WorkspaceCommand::AttachPane {
target: id(PaneId::new, "root"),
pane: id(PaneId::new, "agent"),
axis: SplitAxis::Horizontal,
ratio: SplitRatio::default(),
placement: PanePlacement::After,
},
),
WorkspaceCapability::Configure,
)
.unwrap();
assert_eq!(state.pane_count(), 2);
assert_eq!(state.detached_pane_count(), 0);
assert_eq!(state.selected().as_str(), "agent");
assert!(state.pane(&id(PaneId::new, "agent")).is_some());
}
#[test]
fn terminating_a_detached_pane_requires_execute_and_removes_it() {
let mut state = workspace();
state
.apply(
request(
"split",
WorkspaceCommand::SplitPane {
target: id(PaneId::new, "root"),
pane: pane("agent", false),
axis: SplitAxis::Horizontal,
ratio: SplitRatio::default(),
placement: PanePlacement::After,
},
),
WorkspaceCapability::Configure,
)
.unwrap();
state
.apply(
request(
"detach",
WorkspaceCommand::ClosePane {
pane: id(PaneId::new, "agent"),
disposition: PaneCloseDisposition::Detach,
},
),
WorkspaceCapability::Configure,
)
.unwrap();
let terminate = request(
"terminate",
WorkspaceCommand::ClosePane {
pane: id(PaneId::new, "agent"),
disposition: PaneCloseDisposition::Terminate,
},
);
assert!(matches!(
state.apply(terminate.clone(), WorkspaceCapability::Configure),
Err(WorkspaceError::CapabilityDenied { .. })
));
state
.apply(terminate, WorkspaceCapability::Execute)
.unwrap();
assert_eq!(state.detached_pane_count(), 0);
assert_eq!(state.pane_count(), 1);
}
#[test] #[test]
fn rejects_invalid_identifiers_ratios_and_launch_surfaces() { fn rejects_invalid_identifiers_ratios_and_launch_surfaces() {
assert!(PaneId::new(" ").is_err()); assert!(PaneId::new(" ").is_err());
+6
View File
@@ -89,6 +89,12 @@ approval boundary. The selected panel alone owns keyboard input. Six surfaces
continue updating so performance comparisons remain meaningful, with a sliding continue updating so performance comparisons remain meaningful, with a sliding
visible window keeping the selected pane onscreen. See decisions 0008 and 0009. visible window keeping the selected pane onscreen. See decisions 0008 and 0009.
Panel attachment is independent from session lifetime. Detaching removes a pane
from the layout tree but retains its definition in the workspace's detached
registry and leaves its runtime-owned process alive. Reattaching restores that
identity without a launch. Terminating an attached or detached session is a
separate Execute-capability operation. See decision 0010.
We should evaluate, not blindly copy, WezTerm, Zellij, RMUX, tmux, and cmux. The We should evaluate, not blindly copy, WezTerm, Zellij, RMUX, tmux, and cmux. The
first spike must compare a reusable terminal crate with a small first-party layer. first spike must compare a reusable terminal crate with a small first-party layer.
Remaining correctness cases include OSC 8 links, Kitty Remaining correctness cases include OSC 8 links, Kitty
+7
View File
@@ -79,6 +79,13 @@ capability each choice would need. Suggestions are inert data. Choosing one may
prepare a typed workspace plan, but any command, file mutation, credential use, prepare a typed workspace plan, but any command, file mutation, credential use,
or external message still passes through the normal approval and command plane. or external message still passes through the normal approval and command plane.
Removing a panel detaches it from the layout; it does not terminate the pane's
PTY, agent, browser, or remote session. Detached sessions remain visible in a
workspace tray and can be reattached without relaunching. Termination is a
separate, explicitly named Execute-capability action with confirmation. The add
control creates the default panel beside the selection, with a menu for choosing
a surface or reattaching a running session. At least one panel remains attached.
## Lumbridge Harness ## Lumbridge Harness
Lumbridge Harness is an optional orchestrator distributed as a separately Lumbridge Harness is an optional orchestrator distributed as a separately
+2 -1
View File
@@ -11,7 +11,8 @@ They are shallow snapshots for study, not dependencies or vendored source.
## Multiplexers and terminal engines ## Multiplexers and terminal engines
- `manaflow-ai/cmux`: the current cmux product; native workspace/pane model, - `manaflow-ai/cmux`: the current cmux product; native workspace/pane model,
automation socket, embedded browser, agent hooks, and terminal UX. automation socket, embedded browser, agent hooks, and terminal UX. The
captured tree is GPL-3.0-or-later, so it is behavior/UX study only.
- `Helvesec/rmux`: Rust multiplexer engine, daemon, typed SDKs, tmux surface. - `Helvesec/rmux`: Rust multiplexer engine, daemon, typed SDKs, tmux surface.
- `tmux/tmux`: the durable client/server and command model to remain compatible - `tmux/tmux`: the durable client/server and command model to remain compatible
with where useful. with where useful.
+4 -2
View File
@@ -104,7 +104,8 @@ who already have the final cargo-watch release installed.
## Implemented vertical-slice coverage ## Implemented vertical-slice coverage
- The framework-neutral six-surface model has deterministic tests for focus, - The framework-neutral six-surface model has deterministic tests for focus,
direct selection, needs-input transitions, command-palette lifecycle, direct selection, reversible attach/detach, last-panel protection, detached
background updates, needs-input transitions, command-palette lifecycle,
identical GPUI/Floem action replay, bounded output, and workload counters. identical GPUI/Floem action replay, bounded output, and workload counters.
- `lumbridge-pty` runs synthetic `/bin/sh` tests for raw output, non-zero exits, - `lumbridge-pty` runs synthetic `/bin/sh` tests for raw output, non-zero exits,
input, resize, one-chunk backpressure, hung-process termination, invalid input, resize, one-chunk backpressure, hung-process termination, invalid
@@ -117,7 +118,8 @@ who already have the final cargo-watch release installed.
alternate screen, bracketed paste, protocol replies, sanitized title and alternate screen, bracketed paste, protocol replies, sanitized title and
blocked OSC 52 behavior, key encoding, application-cursor mode, and resize. blocked OSC 52 behavior, key encoding, application-cursor mode, and resize.
- `lumbridge-core` tests capability-gated split/select/rename/close commands, - `lumbridge-core` tests capability-gated split/select/rename/close commands,
idempotent request IDs, close-tree promotion, and atomic agentic setup plans. idempotent request IDs, close-tree promotion, detached-pane reattachment,
Execute-gated detached termination, and atomic agentic setup plans.
- The GPUI slice tests its key-event adapter, responsive one/three/five-panel - The GPUI slice tests its key-event adapter, responsive one/three/five-panel
geometry, selected-panel windowing, per-panel 20/60/20 PTY sizing, xterm geometry, selected-panel windowing, per-panel 20/60/20 PTY sizing, xterm
256-color conversion, styled-run coalescing, cursor-run boundaries, and 256-color conversion, styled-run coalescing, cursor-run boundaries, and
+5 -2
View File
@@ -25,8 +25,11 @@ The current programs establish dependency, build, launch, and interaction
baselines. GPUI now routes a real PTY through a VT engine and keyboard encoder, baselines. GPUI now routes a real PTY through a VT engine and keyboard encoder,
paints coalesced styled cell runs and cursor shapes, and derives PTY rows/columns paints coalesced styled cell runs and cursor shapes, and derives PTY rows/columns
from the 60% terminal region inside each responsive one/three/five-panel layout from the 60% terminal region inside each responsive one/three/five-panel layout
when the window changes. Retained-history page/top/bottom navigation is wired. Text selection, when the window or attached panel count changes. Retained-history
mouse modes, a native Markdown editor, and one isolated browser child remain. page/top/bottom navigation is wired. Panel detach/reattach keeps background
surface updates alive and exposes detached sessions in the sidebar. Text
selection, mouse modes, a native Markdown editor, and one isolated browser child
remain.
## Ubuntu baseline — metal, 2026-08-31 ## Ubuntu baseline — metal, 2026-08-31
+7
View File
@@ -19,6 +19,11 @@ Remaining visible gaps are terminal text selection and mouse modes, real
decision-shelf actions, usage-detail provenance, and end-to-end platform decision-shelf actions, usage-detail provenance, and end-to-end platform
accessibility/IME. accessibility/IME.
Panel lifecycle is now visible in the slice: every panel has a Detach action,
the workspace bar has Add panel, and the sidebar exposes Detached sessions for
reattachment. The wording is deliberate because panel removal keeps its session
alive; termination is not exposed as a visually equivalent close action.
Screenshots for the audit are stored outside Git under Screenshots for the audit are stored outside Git under
`~/shots/2026-08/lumbridge-ui-audit/`. Accessibility and IME correctness cannot `~/shots/2026-08/lumbridge-ui-audit/`. Accessibility and IME correctness cannot
be established from screenshots and remain explicit runtime gates. be established from screenshots and remain explicit runtime gates.
@@ -82,6 +87,8 @@ surface rather than a hosted Lumbridge control plane.
leaving ordinary terminal arrows and text available to the PTY. leaving ordinary terminal arrows and text available to the PTY.
- `Alt+1` through `Alt+6`: focus a pane directly while leaving terminal digits - `Alt+1` through `Alt+6`: focus a pane directly while leaving terminal digits
available to the PTY. available to the PTY.
- `Alt+Shift+N`: add or reattach the next panel in the deterministic slice.
- `Alt+Shift+W`: detach the selected panel while its session keeps running.
- `Cmd+K` on macOS or `Ctrl+K` on Linux: open the command palette. - `Cmd+K` on macOS or `Ctrl+K` on Linux: open the command palette.
- `Shift+PageUp/PageDown`: move the selected live terminal through retained - `Shift+PageUp/PageDown`: move the selected live terminal through retained
history; `Shift+Home/End` jumps to the history top/live bottom. history; `Shift+Home/End` jumps to the history top/live bottom.
@@ -14,6 +14,11 @@ process requires Execute; layout-only changes require Configure. Launch intents
refer to a user shell or an approved harness profile and working directory, not refer to a user shell or an approved harness profile and working directory, not
arbitrary executable arguments or secret values. arbitrary executable arguments or secret values.
Detaching and reattaching an existing pane are layout-only Configure operations.
Detached pane definitions remain outside the layout tree so reattachment does
not imply a new launch. Terminating either an attached or detached session
remains an Execute operation. See decision 0010.
This reducer is not an authorization bypass. The future IPC layer authenticates This reducer is not an authorization bypass. The future IPC layer authenticates
the caller and supplies its granted capability; the reducer rechecks it before the caller and supplies its granted capability; the reducer rechecks it before
mutation. Durable audit events record request and object identities, never mutation. Durable audit events record request and object identities, never
@@ -0,0 +1,37 @@
# 0010: Removing a panel detaches its session before termination
Status: accepted for implementation.
A workspace panel is a view attachment, not the lifetime owner of a PTY, agent,
browser, or remote session. The normal remove action is therefore **Detach
panel**. It removes the panel from the visible layout, keeps its stable pane and
session identities, preserves output and decision state, and exposes the item in
a Detached sessions tray. Reattaching it is a Configure-capability operation and
must not launch a second process.
Termination is a separate Execute-capability operation. It is presented as
**Terminate session**, never as an ambiguous close icon, and will require a
confirmation that names the process, target machine, and any dirty or waiting
state. Detaching the selected panel chooses its next attached sibling, falling
back to the previous sibling. A workspace retains at least one attached panel.
The add affordance has two product paths: quick activation creates the user's
default panel beside the selection, while its menu offers Terminal, Browser,
Markdown, Review, and Reattach running session. The deterministic UI spike uses
six fixed fixtures, starts with five attached, and reattaches the first detached
fixture when Add panel is activated. That fixture limit is test scaffolding, not
a product limit.
The typed command plane retains detached pane definitions separately from the
layout tree. `ClosePane(Detach)` moves a definition into that registry,
`AttachPane` restores it without a launch intent, and `ClosePane(Terminate)`
requires Execute authority whether the pane is attached or detached. The future
runtime registry remains the process owner throughout these layout transitions.
This interaction vocabulary was informed by behavior study of cmux at
`fc36aea87e3b4152637597443d7aa7c22e4ec9f8` and Orca at
`02a7742406a5a84fb372d6255d5a4367421990bd`. cmux is GPL-3.0-or-later and was
used only to study workspace/split shortcuts and close-versus-detached process
behavior. Orca is MIT-licensed and was used to study worktree-first navigation,
durable terminal state, and mixed surface density. No implementation code or
visual asset was copied from either project.
+1 -1
View File
@@ -346,7 +346,7 @@ fn handle_key(
} }
fn app_view() -> impl IntoView { 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 query = RwSignal::new(String::new());
let timer_pulse = RwSignal::new(()); let timer_pulse = RwSignal::new(());
+210 -62
View File
@@ -10,8 +10,8 @@ use lumbridge_runtime::{
RuntimeCommand, RuntimeEvent, TerminalSize, RuntimeCommand, RuntimeEvent, TerminalSize,
}; };
use lumbridge_spike_model::{ use lumbridge_spike_model::{
ActionOutcome, FOOTER_RIGHT, FocusDirection, OutputSource, PaneId, PaneState, ShellAction, ActionOutcome, FOOTER_RIGHT, FocusDirection, INITIAL_ATTACHED_PANEL_COUNT, OutputSource,
ShellModel, SurfaceKind, WORKSPACES, PaneId, PaneState, ShellAction, ShellModel, SurfaceKind, WORKSPACES,
}; };
use lumbridge_terminal::{ use lumbridge_terminal::{
KeyModifiers, TerminalCellStyle, TerminalColor, TerminalCursorShape, TerminalDimensions, KeyModifiers, TerminalCellStyle, TerminalColor, TerminalCursorShape, TerminalDimensions,
@@ -62,6 +62,8 @@ actions!(
SelectPane4, SelectPane4,
SelectPane5, SelectPane5,
SelectPane6, SelectPane6,
AddPanel,
DetachSelectedPanel,
TerminalTaller, TerminalTaller,
TerminalShorter, TerminalShorter,
TerminalWider, TerminalWider,
@@ -272,14 +274,18 @@ fn dim_color(color: u32) -> u32 {
(dim((color >> 16) & 0xff) << 16) | (dim((color >> 8) & 0xff) << 8) | dim(color & 0xff) (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 width = f32::from(window_size.width);
let height = f32::from(window_size.height); 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 = let workspace_height =
(height - APP_HEADER_HEIGHT - TAB_BAR_HEIGHT - APP_FOOTER_HEIGHT).max(0.0); (height - APP_HEADER_HEIGHT - TAB_BAR_HEIGHT - APP_FOOTER_HEIGHT).max(0.0);
let terminal_height = let terminal_height = (workspace_height * 0.60 - TERMINAL_CONTENT_VERTICAL_INSET).max(0.0);
(workspace_height * 0.60 - TERMINAL_CONTENT_VERTICAL_INSET).max(0.0);
let workspace_width = (width - SIDEBAR_WIDTH).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_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); let panel_width = ((workspace_width - panel_gaps).max(0.0) / panel_count as f32).max(0.0);
@@ -401,7 +407,8 @@ impl LumbridgeShell {
}) })
.detach(); .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 { let terminal = TerminalEngine::new(TerminalEngineOptions {
dimensions: terminal_dimensions, dimensions: terminal_dimensions,
..TerminalEngineOptions::default() ..TerminalEngineOptions::default()
@@ -413,7 +420,10 @@ impl LumbridgeShell {
}; };
cx.observe_window_bounds(window, |shell, window, cx| { 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); shell.resize_terminal(dimensions.rows(), dimensions.columns(), cx);
}) })
.detach(); .detach();
@@ -559,6 +569,50 @@ impl LumbridgeShell {
cx.notify(); 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( fn move_focus(
&mut self, &mut self,
direction: FocusDirection, direction: FocusDirection,
@@ -641,10 +695,9 @@ impl LumbridgeShell {
return; return;
} }
let modifiers = key_modifiers(event); let modifiers = key_modifiers(event);
if let Some(scroll) = terminal_scroll_from_parts( if let Some(scroll) =
event.keystroke.key.as_str(), terminal_scroll_from_parts(event.keystroke.key.as_str(), modifiers)
modifiers, {
) {
self.scroll_terminal(scroll, cx); self.scroll_terminal(scroll, cx);
return; return;
} }
@@ -768,7 +821,14 @@ impl LumbridgeShell {
.into_any_element() .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 external = pane.output_source() == OutputSource::External;
let status = if external { let status = if external {
self.runtime_status.badge() self.runtime_status.badge()
@@ -859,16 +919,44 @@ impl LumbridgeShell {
) )
.child( .child(
div() div()
.flex()
.flex_col()
.items_end()
.flex_none() .flex_none()
.text_xs() .gap_1()
.text_color(rgb(if pane.needs_input() { .child(
ATTENTION div()
} else if selected { .text_xs()
SUCCESS .text_color(rgb(if pane.needs_input() {
} else { ATTENTION
MUTED } else if selected {
})) SUCCESS
.child(surface_status), } 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( .child(
@@ -990,18 +1078,10 @@ impl LumbridgeShell {
.items_center() .items_center()
.justify_between() .justify_between()
.text_xs() .text_xs()
.child(div().text_color(rgb(MUTED)).child("DECISION SHELF"))
.child( .child(
div() div()
.text_color(rgb(MUTED)) .text_color(rgb(if pane.needs_input() { ATTENTION } else { MUTED }))
.child("DECISION SHELF"),
)
.child(
div()
.text_color(rgb(if pane.needs_input() {
ATTENTION
} else {
MUTED
}))
.child(if pane.needs_input() { .child(if pane.needs_input() {
"REVIEW REQUIRED" "REVIEW REQUIRED"
} else { } else {
@@ -1009,16 +1089,9 @@ impl LumbridgeShell {
}), }),
), ),
) )
.child( .child(div().flex().flex_col().gap_1().mt_2().children(
div() choices.map(|(label, detail, attention)| choice(label, detail, attention)),
.flex() ))
.flex_col()
.gap_1()
.mt_2()
.children(choices.map(|(label, detail, attention)| {
choice(label, detail, attention)
})),
)
.into_any_element() .into_any_element()
} }
@@ -1048,7 +1121,7 @@ impl LumbridgeShell {
.flex_none() .flex_none()
.min_h_0() .min_h_0()
.overflow_hidden() .overflow_hidden()
.child(self.pane_context(pane, selected)), .child(self.pane_context(pane, selected, cx)),
) )
.child( .child(
div() div()
@@ -1128,7 +1201,26 @@ impl LumbridgeShell {
.px_3() .px_3()
.py_2() .py_2()
.text_color(rgb(MUTED)) .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( .child(
div() div()
@@ -1158,7 +1250,17 @@ impl LumbridgeShell {
impl Render for LumbridgeShell { impl Render for LumbridgeShell {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let attention = self.model.pane(PaneId::ClaudeUi); 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() let sidebar = div()
.flex() .flex()
.flex_col() .flex_col()
@@ -1263,6 +1365,33 @@ impl Render for LumbridgeShell {
.text_color(rgb(MUTED)) .text_color(rgb(MUTED))
.child("spark-1 · sleeping"), .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().flex_1())
.child( .child(
div() div()
@@ -1317,33 +1446,44 @@ impl Render for LumbridgeShell {
.child("Review"), .child("Review"),
) )
.child(div().flex_1()) .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( .child(
div() div()
.id("add-workspace-panel")
.cursor_pointer()
.ml_2()
.px_3() .px_3()
.py_1()
.rounded(px(4.0))
.border_1()
.border_color(rgb(if detached_count > 0 { ACCENT } else { BORDER }))
.text_xs() .text_xs()
.text_color(rgb(MUTED)) .text_color(rgb(if detached_count > 0 { ACCENT } else { MUTED }))
.child(format!( .child(if detached_count > 0 {
"{panel_count} visible panels · 1 interactive VT · 1 waiting" "+ ADD PANEL"
)), } else {
"ALL PANELS ATTACHED"
})
.on_click(cx.listener(|shell, _, window, cx| {
shell.add_panel(window, cx);
})),
); );
let panel_range = visible_panel_range( let selected_position = attached_panes
PaneId::ALL.len(), .iter()
self.model.selected_pane().index(), .position(|pane| *pane == self.model.selected_pane())
panel_count, .expect("the selected pane must remain attached");
); let panel_range = visible_panel_range(attached_count, selected_position, visible_count);
let workspace_panels = panel_range let workspace_panels = panel_range
.map(|index| { .map(|index| {
let pane = self.model.pane(PaneId::ALL[index]); let pane = self.model.pane(attached_panes[index]);
div() div()
.flex_1() .flex_1()
.min_w_0() .min_w_0()
.min_h_0() .min_h_0()
.child(self.workspace_panel( .child(self.workspace_panel(pane, pane.id() == self.model.selected_pane(), cx))
pane,
pane.id() == self.model.selected_pane(),
cx,
))
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let workspace_row = div() let workspace_row = div()
@@ -1380,6 +1520,8 @@ impl Render for LumbridgeShell {
.on_action(cx.listener(Self::focus_down)) .on_action(cx.listener(Self::focus_down))
.on_action(cx.listener(Self::open_palette)) .on_action(cx.listener(Self::open_palette))
.on_action(cx.listener(Self::close_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_taller))
.on_action(cx.listener(Self::terminal_shorter)) .on_action(cx.listener(Self::terminal_shorter))
.on_action(cx.listener(Self::terminal_wider)) .on_action(cx.listener(Self::terminal_wider))
@@ -1570,6 +1712,8 @@ fn main() {
KeyBinding::new("alt-4", SelectPane4, Some("LumbridgeShell")), KeyBinding::new("alt-4", SelectPane4, Some("LumbridgeShell")),
KeyBinding::new("alt-5", SelectPane5, Some("LumbridgeShell")), KeyBinding::new("alt-5", SelectPane5, Some("LumbridgeShell")),
KeyBinding::new("alt-6", SelectPane6, 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-up", TerminalTaller, Some("LumbridgeShell")),
KeyBinding::new("alt-shift-down", TerminalShorter, Some("LumbridgeShell")), KeyBinding::new("alt-shift-down", TerminalShorter, Some("LumbridgeShell")),
KeyBinding::new("alt-shift-right", TerminalWider, Some("LumbridgeShell")), KeyBinding::new("alt-shift-right", TerminalWider, Some("LumbridgeShell")),
@@ -1626,15 +1770,19 @@ mod tests {
#[test] #[test]
fn terminal_geometry_tracks_middle_sixty_percent_per_panel() { 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.rows(), 26);
assert_eq!(dimensions.columns(), 45); assert_eq!(dimensions.columns(), 45);
let ultrawide = size(px(3440.0), px(1440.0)); let ultrawide = size(px(3440.0), px(1440.0));
assert_eq!(visible_panel_count(ultrawide), 5); 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.rows(), 42);
assert_eq!(dimensions.columns(), 71); 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] #[test]
+198 -20
View File
@@ -7,6 +7,7 @@ use std::fmt;
pub const GRID_ROWS: usize = 2; pub const GRID_ROWS: usize = 2;
pub const GRID_COLUMNS: usize = 3; pub const GRID_COLUMNS: usize = 3;
pub const INITIAL_ATTACHED_PANEL_COUNT: usize = 5;
pub const DEFAULT_TERMINAL_LINE_LIMIT: usize = 64; pub const DEFAULT_TERMINAL_LINE_LIMIT: usize = 64;
/// Stable identity used in traces and accessibility identifiers. /// Stable identity used in traces and accessibility identifiers.
@@ -282,6 +283,8 @@ pub enum FocusDirection {
pub enum ShellAction { pub enum ShellAction {
MoveFocus(FocusDirection), MoveFocus(FocusDirection),
SelectPane(PaneId), SelectPane(PaneId),
AttachPanel(PaneId),
DetachPanel(PaneId),
SetNeedsInput { pane: PaneId, needs_input: bool }, SetNeedsInput { pane: PaneId, needs_input: bool },
OpenCommandPalette, OpenCommandPalette,
CloseCommandPalette, CloseCommandPalette,
@@ -301,6 +304,9 @@ pub struct MeasurementCounters {
pub focus_moves: u64, pub focus_moves: u64,
pub blocked_focus_moves: u64, pub blocked_focus_moves: u64,
pub direct_selections: u64, pub direct_selections: u64,
pub panel_attaches: u64,
pub panel_detaches: u64,
pub blocked_panel_detaches: u64,
pub needs_input_changes: u64, pub needs_input_changes: u64,
pub palette_changes: u64, pub palette_changes: u64,
pub synthetic_ticks: u64, pub synthetic_ticks: u64,
@@ -344,6 +350,7 @@ impl std::error::Error for InvalidExternalOutputPane {}
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
pub struct ShellModel { pub struct ShellModel {
panes: [PaneState; GRID_ROWS * GRID_COLUMNS], panes: [PaneState; GRID_ROWS * GRID_COLUMNS],
attached_panels: [bool; GRID_ROWS * GRID_COLUMNS],
selected_pane: PaneId, selected_pane: PaneId,
command_palette: CommandPaletteState, command_palette: CommandPaletteState,
terminal_line_limit: usize, terminal_line_limit: usize,
@@ -381,6 +388,7 @@ impl ShellModel {
}); });
Ok(Self { Ok(Self {
panes, panes,
attached_panels: std::array::from_fn(|index| index < INITIAL_ATTACHED_PANEL_COUNT),
selected_pane: PaneId::CodexRuntime, selected_pane: PaneId::CodexRuntime,
command_palette: CommandPaletteState::default(), command_palette: CommandPaletteState::default(),
terminal_line_limit: limit, terminal_line_limit: limit,
@@ -406,6 +414,14 @@ impl ShellModel {
Ok(model) 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] #[must_use]
pub fn panes(&self) -> &[PaneState; GRID_ROWS * GRID_COLUMNS] { pub fn panes(&self) -> &[PaneState; GRID_ROWS * GRID_COLUMNS] {
&self.panes &self.panes
@@ -415,6 +431,41 @@ impl ShellModel {
&self.panes[id.index()] &self.panes[id.index()]
} }
#[must_use] #[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 { pub const fn selected_pane(&self) -> PaneId {
self.selected_pane self.selected_pane
} }
@@ -445,7 +496,9 @@ impl ShellModel {
let mut appended = 0; let mut appended = 0;
match action { match action {
ShellAction::MoveFocus(direction) => { 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.selected_pane = next;
self.counters.focus_moves += 1; self.counters.focus_moves += 1;
changed = true; changed = true;
@@ -454,12 +507,41 @@ impl ShellModel {
} }
} }
ShellAction::SelectPane(pane) => { ShellAction::SelectPane(pane) => {
if self.selected_pane != pane { if self.is_panel_attached(pane) && self.selected_pane != pane {
self.selected_pane = pane; self.selected_pane = pane;
self.counters.direct_selections += 1; self.counters.direct_selections += 1;
changed = true; 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 } => { ShellAction::SetNeedsInput { pane, needs_input } => {
let pane = &mut self.panes[pane.index()]; let pane = &mut self.panes[pane.index()];
if needs_input && !pane.needs_input() { if needs_input && !pane.needs_input() {
@@ -570,13 +652,9 @@ impl ShellModel {
#[must_use] #[must_use]
pub const fn focus_neighbor(pane: PaneId, direction: FocusDirection) -> Option<PaneId> { pub const fn focus_neighbor(pane: PaneId, direction: FocusDirection) -> Option<PaneId> {
let row = pane.row();
let column = pane.column();
let next = match direction { let next = match direction {
FocusDirection::Left if column > 0 => Some(pane.index() - 1), FocusDirection::Left if pane.index() > 0 => Some(pane.index() - 1),
FocusDirection::Right if column + 1 < GRID_COLUMNS => Some(pane.index() + 1), FocusDirection::Right if pane.index() + 1 < PaneId::ALL.len() => 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),
_ => None, _ => None,
}; };
match next { 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)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
@@ -613,6 +710,8 @@ mod tests {
fn initial_state_has_one_explicit_needs_input_pane() { fn initial_state_has_one_explicit_needs_input_pane() {
let model = ShellModel::default(); let model = ShellModel::default();
assert_eq!(model.selected_pane(), PaneId::CodexRuntime); 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_eq!(model.revision(), 0);
assert!(!model.command_palette().is_open()); assert!(!model.command_palette().is_open());
assert_eq!( assert_eq!(
@@ -627,24 +726,35 @@ mod tests {
} }
#[test] #[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!( assert_eq!(
focus_neighbor(PaneId::CodexRuntime, FocusDirection::Right), focus_neighbor(PaneId::CodexRuntime, FocusDirection::Right),
Some(PaneId::ClaudeUi) Some(PaneId::ClaudeUi)
); );
assert_eq!( assert_eq!(
focus_neighbor(PaneId::ClaudeUi, FocusDirection::Down), focus_neighbor(PaneId::ClaudeUi, FocusDirection::Left),
Some(PaneId::AcpPreview) Some(PaneId::CodexRuntime)
); );
assert_eq!( assert_eq!(
focus_neighbor(PaneId::RuntimeReview, FocusDirection::Up), focus_neighbor(PaneId::AcpPreview, FocusDirection::Right),
Some(PaneId::PiDocs) Some(PaneId::RuntimeReview)
); );
assert_eq!( assert_eq!(
focus_neighbor(PaneId::Architecture, FocusDirection::Left), focus_neighbor(PaneId::CodexRuntime, FocusDirection::Left),
None
);
assert_eq!(
focus_neighbor(PaneId::RuntimeReview, FocusDirection::Right),
None None
); );
assert_eq!(focus_neighbor(PaneId::PiDocs, FocusDirection::Right), None);
assert_eq!( assert_eq!(
focus_neighbor(PaneId::CodexRuntime, FocusDirection::Up), focus_neighbor(PaneId::CodexRuntime, FocusDirection::Up),
None None
@@ -661,12 +771,79 @@ mod tests {
assert!(moved.changed); assert!(moved.changed);
assert_eq!(moved.selected_pane, PaneId::ClaudeUi); assert_eq!(moved.selected_pane, PaneId::ClaudeUi);
assert_eq!(moved.revision, 1); assert_eq!(moved.revision, 1);
model.dispatch(ShellAction::MoveFocus(FocusDirection::Down)); model.dispatch(ShellAction::MoveFocus(FocusDirection::Right));
assert_eq!(model.selected_pane(), PaneId::AcpPreview); assert_eq!(model.selected_pane(), PaneId::PiDocs);
assert_eq!(model.counters().focus_moves, 2); assert_eq!(model.counters().focus_moves, 2);
assert_eq!(model.counters().blocked_focus_moves, 1); 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] #[test]
fn direct_selection_is_idempotent_and_counted_separately() { fn direct_selection_is_idempotent_and_counted_separately() {
let mut model = ShellModel::default(); let mut model = ShellModel::default();
@@ -677,10 +854,10 @@ mod tests {
); );
assert!( assert!(
model model
.dispatch(ShellAction::SelectPane(PaneId::RuntimeReview)) .dispatch(ShellAction::SelectPane(PaneId::AcpPreview))
.changed .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().direct_selections, 1);
assert_eq!(model.counters().events_dispatched, 2); assert_eq!(model.counters().events_dispatched, 2);
assert_eq!(model.counters().state_changes, 1); assert_eq!(model.counters().state_changes, 1);
@@ -829,7 +1006,8 @@ mod tests {
fn identical_action_replays_produce_identical_models_and_counters() { fn identical_action_replays_produce_identical_models_and_counters() {
let actions = [ let actions = [
ShellAction::MoveFocus(FocusDirection::Right), ShellAction::MoveFocus(FocusDirection::Right),
ShellAction::MoveFocus(FocusDirection::Down), ShellAction::DetachPanel(PaneId::PiDocs),
ShellAction::AttachPanel(PaneId::RuntimeReview),
ShellAction::OpenCommandPalette, ShellAction::OpenCommandPalette,
ShellAction::SetCommandPaletteQuery("workspace".into()), ShellAction::SetCommandPaletteQuery("workspace".into()),
ShellAction::CloseCommandPalette, ShellAction::CloseCommandPalette,