From abcf664ab8ed12aa136390774c9fc0cda7908b22 Mon Sep 17 00:00:00 2001 From: Kartios Date: Mon, 31 Aug 2026 18:18:20 -0700 Subject: [PATCH] feat: add reversible workspace panels --- crates/lumbridge-core/src/workspace.rs | 172 ++++++++++- docs/ARCHITECTURE.md | 6 + docs/PRODUCT_SPEC.md | 7 + docs/RESEARCH.md | 3 +- docs/TESTING.md | 6 +- docs/UI_SPIKE_SCORECARD.md | 7 +- docs/UX_VERTICAL_SLICE.md | 7 + .../decisions/0006-workspace-command-plane.md | 5 + ...panel-lifecycle-detach-before-terminate.md | 37 +++ spikes/floem-shell/src/main.rs | 2 +- spikes/gpui-shell/src/main.rs | 272 ++++++++++++++---- spikes/ui-shell-model/src/lib.rs | 218 ++++++++++++-- 12 files changed, 652 insertions(+), 90 deletions(-) create mode 100644 docs/decisions/0010-panel-lifecycle-detach-before-terminate.md diff --git a/crates/lumbridge-core/src/workspace.rs b/crates/lumbridge-core/src/workspace.rs index 0a5ad2d..34a5804 100644 --- a/crates/lumbridge-core/src/workspace.rs +++ b/crates/lumbridge-core/src/workspace.rs @@ -268,6 +268,13 @@ pub enum WorkspaceCommand { ratio: SplitRatio, placement: PanePlacement, }, + AttachPane { + target: PaneId, + pane: PaneId, + axis: SplitAxis, + ratio: SplitRatio, + placement: PanePlacement, + }, SelectPane { pane: PaneId, }, @@ -291,6 +298,7 @@ impl WorkspaceCommand { } => WorkspaceCapability::Execute, Self::SplitPane { pane, .. } if pane.launch.is_some() => WorkspaceCapability::Execute, Self::SplitPane { .. } + | Self::AttachPane { .. } | Self::SelectPane { .. } | Self::RenamePane { .. } | Self::ClosePane { @@ -319,6 +327,10 @@ pub enum WorkspaceEvent { PaneSelected { pane: PaneId, }, + PaneAttached { + target: PaneId, + pane: PaneId, + }, PaneRenamed { pane: PaneId, }, @@ -366,6 +378,7 @@ pub struct WorkspaceState { id: WorkspaceId, name: String, panes: BTreeMap, + detached_panes: BTreeMap, layout: LayoutNode, selected: PaneId, applied_requests: BTreeMap, @@ -396,6 +409,7 @@ impl WorkspaceState { id, name, panes, + detached_panes: BTreeMap::new(), layout, selected, applied_requests: BTreeMap::new(), @@ -433,11 +447,21 @@ impl WorkspaceState { self.panes.get(id) } + #[must_use] + pub fn detached_pane(&self, id: &PaneId) -> Option<&PaneDefinition> { + self.detached_panes.get(id) + } + #[must_use] pub fn pane_count(&self) -> usize { 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. /// /// # Errors @@ -511,7 +535,7 @@ impl WorkspaceState { if !self.layout.contains(&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)); } pane.validate()?; @@ -527,6 +551,31 @@ impl WorkspaceState { 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 } => { if !self.panes.contains_key(&pane) { return Err(WorkspaceError::PaneNotFound(pane)); @@ -546,6 +595,11 @@ impl WorkspaceState { Ok(WorkspaceEvent::PaneRenamed { pane }) } 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) { return Err(WorkspaceError::CannotCloseLastPane); } @@ -557,7 +611,10 @@ impl WorkspaceState { .clone() .remove(&pane) .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; if self.selected == pane { self.selected = self.layout.first_pane().clone(); @@ -580,6 +637,11 @@ impl fmt::Debug for WorkspaceEvent { .debug_struct("PaneSelected") .field("pane", pane) .finish(), + Self::PaneAttached { target, pane } => formatter + .debug_struct("PaneAttached") + .field("target", target) + .field("pane", pane) + .finish(), Self::PaneRenamed { pane } => formatter .debug_struct("PaneRenamed") .field("pane", pane) @@ -828,6 +890,8 @@ mod tests { .unwrap(); assert_eq!(state.selected().as_str(), "root"); 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!( state.apply( 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] fn rejects_invalid_identifiers_ratios_and_launch_surfaces() { assert!(PaneId::new(" ").is_err()); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5501e01..0b64e4d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 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 first spike must compare a reusable terminal crate with a small first-party layer. Remaining correctness cases include OSC 8 links, Kitty diff --git a/docs/PRODUCT_SPEC.md b/docs/PRODUCT_SPEC.md index 52e9e73..73ae2e5 100644 --- a/docs/PRODUCT_SPEC.md +++ b/docs/PRODUCT_SPEC.md @@ -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, 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 is an optional orchestrator distributed as a separately diff --git a/docs/RESEARCH.md b/docs/RESEARCH.md index fb46b6e..8be6a30 100644 --- a/docs/RESEARCH.md +++ b/docs/RESEARCH.md @@ -11,7 +11,8 @@ They are shallow snapshots for study, not dependencies or vendored source. ## Multiplexers and terminal engines - `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. - `tmux/tmux`: the durable client/server and command model to remain compatible with where useful. diff --git a/docs/TESTING.md b/docs/TESTING.md index 2c78acb..38082db 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -104,7 +104,8 @@ who already have the final cargo-watch release installed. ## Implemented vertical-slice coverage - 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. - `lumbridge-pty` runs synthetic `/bin/sh` tests for raw output, non-zero exits, 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 blocked OSC 52 behavior, key encoding, application-cursor mode, and resize. - `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 geometry, selected-panel windowing, per-panel 20/60/20 PTY sizing, xterm 256-color conversion, styled-run coalescing, cursor-run boundaries, and diff --git a/docs/UI_SPIKE_SCORECARD.md b/docs/UI_SPIKE_SCORECARD.md index e801a21..5944070 100644 --- a/docs/UI_SPIKE_SCORECARD.md +++ b/docs/UI_SPIKE_SCORECARD.md @@ -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, 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 -when the window changes. Retained-history page/top/bottom navigation is wired. Text selection, -mouse modes, a native Markdown editor, and one isolated browser child remain. +when the window or attached panel count changes. Retained-history +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 diff --git a/docs/UX_VERTICAL_SLICE.md b/docs/UX_VERTICAL_SLICE.md index a9d12cf..d666c71 100644 --- a/docs/UX_VERTICAL_SLICE.md +++ b/docs/UX_VERTICAL_SLICE.md @@ -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 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 `~/shots/2026-08/lumbridge-ui-audit/`. Accessibility and IME correctness cannot 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. - `Alt+1` through `Alt+6`: focus a pane directly while leaving terminal digits 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. - `Shift+PageUp/PageDown`: move the selected live terminal through retained history; `Shift+Home/End` jumps to the history top/live bottom. diff --git a/docs/decisions/0006-workspace-command-plane.md b/docs/decisions/0006-workspace-command-plane.md index 865f9c2..06976bd 100644 --- a/docs/decisions/0006-workspace-command-plane.md +++ b/docs/decisions/0006-workspace-command-plane.md @@ -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 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 the caller and supplies its granted capability; the reducer rechecks it before mutation. Durable audit events record request and object identities, never diff --git a/docs/decisions/0010-panel-lifecycle-detach-before-terminate.md b/docs/decisions/0010-panel-lifecycle-detach-before-terminate.md new file mode 100644 index 0000000..a477adb --- /dev/null +++ b/docs/decisions/0010-panel-lifecycle-detach-before-terminate.md @@ -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. diff --git a/spikes/floem-shell/src/main.rs b/spikes/floem-shell/src/main.rs index 7274b25..c31dfcc 100644 --- a/spikes/floem-shell/src/main.rs +++ b/spikes/floem-shell/src/main.rs @@ -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(()); diff --git a/spikes/gpui-shell/src/main.rs b/spikes/gpui-shell/src/main.rs index 47dab0d..28d8697 100644 --- a/spikes/gpui-shell/src/main.rs +++ b/spikes/gpui-shell/src/main.rs @@ -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) -> TerminalDimensions { +fn terminal_dimensions_for_window( + window_size: Size, + 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) { + 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) { + 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) { + 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) { + 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.add_panel(window, cx); + } + + fn detach_selected_panel( + &mut self, + _: &DetachSelectedPanel, + window: &mut Window, + cx: &mut Context, + ) { + 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, + ) -> 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) -> 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::>(); + 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::>(); 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] diff --git a/spikes/ui-shell-model/src/lib.rs b/spikes/ui-shell-model/src/lib.rs index 2266f2e..ac4f2ba 100644 --- a/spikes/ui-shell-model/src/lib.rs +++ b/spikes/ui-shell-model/src/lib.rs @@ -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::ALL + .into_iter() + .filter(|pane| self.is_panel_attached(*pane)) + .collect() + } + #[must_use] + pub fn detached_pane_ids(&self) -> Vec { + PaneId::ALL + .into_iter() + .filter(|pane| !self.is_panel_attached(*pane)) + .collect() + } + #[must_use] + pub const fn is_panel_attached(&self, pane: PaneId) -> bool { + self.attached_panels[pane.index()] + } + #[must_use] + pub fn attached_panel_count(&self) -> usize { + self.attached_panels + .iter() + .filter(|attached| **attached) + .count() + } + #[must_use] + pub fn detached_panel_count(&self) -> usize { + self.attached_panels.len() - self.attached_panel_count() + } + #[must_use] + pub fn next_detached_panel(&self) -> Option { + PaneId::ALL + .into_iter() + .find(|pane| !self.is_panel_attached(*pane)) + } + #[must_use] pub const fn selected_pane(&self) -> PaneId { self.selected_pane } @@ -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 { - 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

Option { + match direction { + FocusDirection::Left => PaneId::ALL[..pane.index()] + .iter() + .rev() + .copied() + .find(|candidate| attached[candidate.index()]), + FocusDirection::Right => PaneId::ALL[pane.index() + 1..] + .iter() + .copied() + .find(|candidate| attached[candidate.index()]), + FocusDirection::Up | FocusDirection::Down => None, + } +} + #[cfg(test)] mod tests { use super::{ @@ -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,