This commit is contained in:
@@ -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());
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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(());
|
||||||
|
|
||||||
|
|||||||
+201
-53
@@ -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,7 +919,13 @@ impl LumbridgeShell {
|
|||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.items_end()
|
||||||
.flex_none()
|
.flex_none()
|
||||||
|
.gap_1()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
.text_xs()
|
.text_xs()
|
||||||
.text_color(rgb(if pane.needs_input() {
|
.text_color(rgb(if pane.needs_input() {
|
||||||
ATTENTION
|
ATTENTION
|
||||||
@@ -869,6 +935,28 @@ impl LumbridgeShell {
|
|||||||
MUTED
|
MUTED
|
||||||
}))
|
}))
|
||||||
.child(surface_status),
|
.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]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user