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,
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<PaneId, PaneDefinition>,
detached_panes: BTreeMap<PaneId, PaneDefinition>,
layout: LayoutNode,
selected: PaneId,
applied_requests: BTreeMap<CommandRequestId, WorkspaceRequest>,
@@ -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());
+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
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
+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,
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
+2 -1
View File
@@ -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.
+4 -2
View File
@@ -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
+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,
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
+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
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.
@@ -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
@@ -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 {
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(());
+201 -53
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,7 +919,13 @@ impl LumbridgeShell {
)
.child(
div()
.flex()
.flex_col()
.items_end()
.flex_none()
.gap_1()
.child(
div()
.text_xs()
.text_color(rgb(if pane.needs_input() {
ATTENTION
@@ -869,6 +935,28 @@ impl LumbridgeShell {
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,