fix: make workspace panels self contained
CI / rust (push) Successful in 2m54s

This commit is contained in:
2026-08-31 18:02:01 -07:00
parent 3f15b13312
commit 63c9cb6210
10 changed files with 297 additions and 350 deletions
+215 -275
View File
@@ -39,11 +39,11 @@ const SIDEBAR_WIDTH: f32 = 248.0;
const APP_HEADER_HEIGHT: f32 = 48.0;
const TAB_BAR_HEIGHT: f32 = 38.0;
const APP_FOOTER_HEIGHT: f32 = 32.0;
const TERMINAL_CHROME_HEIGHT: f32 = 72.0;
const TERMINAL_CONTENT_VERTICAL_INSET: f32 = 24.0;
const TERMINAL_HORIZONTAL_INSET: f32 = 32.0;
const TERMINAL_CELL_WIDTH: f32 = 8.4;
const TERMINAL_CELL_HEIGHT: f32 = 18.0;
const WORK_LANE_GAP: f32 = 4.0;
const WORK_PANEL_GAP: f32 = 4.0;
const TERMINAL_ROW_STEP: u16 = 2;
const TERMINAL_COLUMN_STEP: u16 = 10;
@@ -78,7 +78,6 @@ struct LumbridgeShell {
terminal_snapshot: TerminalSnapshot,
last_runtime_sequence: u64,
root_focus: FocusHandle,
pane_focus: [FocusHandle; 6],
}
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -119,10 +118,6 @@ impl LiveRuntimeStatus {
}
}
const fn is_fault(&self) -> bool {
matches!(self, Self::Fault(_))
}
const fn is_terminal(&self) -> bool {
matches!(self, Self::Exited(_) | Self::Fault(_))
}
@@ -280,14 +275,15 @@ fn dim_color(color: u32) -> u32 {
fn terminal_dimensions_for_window(window_size: Size<Pixels>) -> TerminalDimensions {
let width = f32::from(window_size.width);
let height = f32::from(window_size.height);
let lane_count = visible_lane_count(window_size);
let panel_count = visible_panel_count(window_size);
let workspace_height =
(height - APP_HEADER_HEIGHT - TAB_BAR_HEIGHT - APP_FOOTER_HEIGHT).max(0.0);
let terminal_height = (workspace_height * 0.60 - TERMINAL_CHROME_HEIGHT).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 lane_gaps = WORK_LANE_GAP * lane_count.saturating_sub(1) as f32;
let lane_width = ((workspace_width - lane_gaps).max(0.0) / lane_count as f32).max(0.0);
let terminal_width = (lane_width - TERMINAL_HORIZONTAL_INSET).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);
let terminal_width = (panel_width - TERMINAL_HORIZONTAL_INSET).max(0.0);
let rows = (terminal_height / TERMINAL_CELL_HEIGHT)
.floor()
.clamp(2.0, f32::from(u16::MAX));
@@ -303,7 +299,7 @@ fn terminal_dimensions_for_window(window_size: Size<Pixels>) -> TerminalDimensio
.expect("geometry clamps terminal dimensions above zero")
}
fn visible_lane_count(window_size: Size<Pixels>) -> usize {
fn visible_panel_count(window_size: Size<Pixels>) -> usize {
let workspace_width = (f32::from(window_size.width) - SIDEBAR_WIDTH).max(0.0);
if workspace_width >= 2_800.0 {
5
@@ -314,8 +310,12 @@ fn visible_lane_count(window_size: Size<Pixels>) -> usize {
}
}
fn visible_lane_range(total: usize, selected: usize, lane_count: usize) -> std::ops::Range<usize> {
let count = lane_count.clamp(1, total.max(1)).min(total);
fn visible_panel_range(
total: usize,
selected: usize,
panel_count: usize,
) -> std::ops::Range<usize> {
let count = panel_count.clamp(1, total.max(1)).min(total);
let start = selected
.saturating_sub(count / 2)
.min(total.saturating_sub(count));
@@ -363,11 +363,6 @@ impl RenderTiming {
impl LumbridgeShell {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let pane_focus = std::array::from_fn(|index| {
cx.focus_handle()
.tab_index(index as isize + 1)
.tab_stop(true)
});
let root_focus = cx.focus_handle();
window.focus(&root_focus);
@@ -433,7 +428,6 @@ impl LumbridgeShell {
terminal_snapshot,
last_runtime_sequence: 0,
root_focus,
pane_focus,
}
}
@@ -707,83 +701,6 @@ impl LumbridgeShell {
cx.notify();
}
fn pane_selector(
pane: &PaneState,
selected: bool,
runtime_status: LiveRuntimeStatus,
focus: FocusHandle,
cx: &mut Context<Self>,
) -> gpui::AnyElement {
let id = pane.id();
let needs_input = pane.needs_input();
let external = pane.output_source() == OutputSource::External;
let label = if external {
runtime_status.badge()
} else {
match pane.kind() {
SurfaceKind::Terminal => pane.status().label(),
_ => pane.fixture().badge,
}
};
let state_color = if needs_input || (external && runtime_status.is_fault()) {
ATTENTION
} else if matches!(pane.kind(), SurfaceKind::Terminal) {
SUCCESS
} else {
ACCENT
};
div()
.id(("pane", id.index()))
.track_focus(&focus)
.tab_index(id.index() as isize + 1)
.flex()
.items_center()
.justify_between()
.gap_3()
.h_full()
.min_w(px(172.0))
.px_3()
.bg(rgb(if selected { PANEL_ACTIVE } else { PANEL_ALT }))
.border_1()
.border_color(rgb(if selected || needs_input {
state_color
} else {
BORDER_QUIET
}))
.rounded(px(5.0))
.on_click(cx.listener(move |shell, _, window, cx| {
shell.select_pane(id, window, cx);
}))
.child(
div()
.flex()
.flex_col()
.min_w_0()
.child(
div()
.truncate()
.text_sm()
.text_color(rgb(TEXT))
.child(if external {
"Terminal · local shell"
} else {
pane.fixture().title
}),
)
.child(
div()
.mt_1()
.truncate()
.text_xs()
.text_color(rgb(MUTED))
.child(pane.fixture().target.to_owned()),
),
)
.child(div().text_xs().text_color(rgb(state_color)).child(label))
.into_any_element()
}
fn terminal_run(run: TerminalPaintRun) -> gpui::AnyElement {
let cursor = run.cursor;
div()
@@ -851,13 +768,7 @@ impl LumbridgeShell {
.into_any_element()
}
fn work_surface(
&self,
pane: &PaneState,
selected: bool,
cx: &mut Context<Self>,
) -> gpui::AnyElement {
let pane_id = pane.id();
fn pane_context(&self, pane: &PaneState, selected: bool) -> gpui::AnyElement {
let external = pane.output_source() == OutputSource::External;
let status = if external {
self.runtime_status.badge()
@@ -909,6 +820,90 @@ impl LumbridgeShell {
.child(label)
})
.collect::<Vec<_>>();
div()
.flex()
.flex_col()
.size_full()
.min_h_0()
.overflow_hidden()
.px_3()
.py_2()
.bg(rgb(if selected { PANEL_ACTIVE } else { PANEL_ALT }))
.border_b_1()
.border_color(rgb(BORDER))
.child(
div()
.flex()
.items_center()
.justify_between()
.gap_2()
.child(
div()
.min_w_0()
.child(
div()
.truncate()
.text_sm()
.text_color(rgb(TEXT))
.child(pane.fixture().title),
)
.child(
div()
.mt_1()
.truncate()
.text_xs()
.text_color(rgb(MUTED))
.child(detail),
),
)
.child(
div()
.flex_none()
.text_xs()
.text_color(rgb(if pane.needs_input() {
ATTENTION
} else if selected {
SUCCESS
} else {
MUTED
}))
.child(surface_status),
),
)
.child(
div()
.flex()
.h(px(28.0))
.flex_none()
.mt_2()
.overflow_hidden()
.border_t_1()
.border_color(rgb(BORDER_QUIET))
.children(tabs),
)
.child(
div()
.flex()
.items_center()
.justify_between()
.mt_2()
.text_xs()
.text_color(rgb(MUTED))
.child("TOOLS · CONTEXT · GOAL")
.child(if pane.needs_input() {
"NEEDS INPUT"
} else if selected {
"KEYBOARD OWNER"
} else {
"RUNNING"
}),
)
.into_any_element()
}
fn pane_work_surface(&self, pane: &PaneState) -> gpui::AnyElement {
let external = pane.output_source() == OutputSource::External;
let content = if external {
self.terminal_view()
} else {
@@ -927,63 +922,15 @@ impl LumbridgeShell {
};
div()
.id(("work-lane", pane_id.index()))
.on_click(cx.listener(move |shell, _, window, cx| {
shell.select_pane(pane_id, window, cx);
}))
.flex()
.flex_col()
.size_full()
.min_w_0()
.min_h_0()
.overflow_hidden()
.bg(rgb(PANEL))
.border_1()
.border_color(rgb(if selected { ACCENT } else { BORDER }))
.child(
div()
.flex()
.items_center()
.justify_between()
.h(px(36.0))
.flex_none()
.px_3()
.bg(rgb(PANEL_ALT))
.child(
div()
.flex()
.items_center()
.gap_2()
.min_w_0()
.text_sm()
.child(div().flex_none().child(pane.fixture().title))
.child(
div()
.truncate()
.text_xs()
.text_color(rgb(MUTED))
.child(detail),
),
)
.child(
div()
.flex_none()
.text_xs()
.text_color(rgb(if selected { SUCCESS } else { MUTED }))
.child(surface_status),
),
)
.child(
div()
.flex()
.h(px(28.0))
.flex_none()
.px_1()
.bg(rgb(PANEL_ALT))
.border_t_1()
.border_color(rgb(BORDER_QUIET))
.children(tabs),
)
.bg(rgb(BG))
.border_y_1()
.border_color(rgb(BORDER))
.child(
div()
.flex_1()
@@ -995,22 +942,36 @@ impl LumbridgeShell {
.into_any_element()
}
fn decision_region(&self) -> gpui::AnyElement {
let choice = |label: &'static str, detail: &'static str| {
fn pane_decision_region(&self, pane: &PaneState) -> gpui::AnyElement {
let choice = |label: &'static str, detail: &'static str, attention: bool| {
div()
.flex()
.flex_col()
.min_w(px(200.0))
.px_3()
.py_2()
.rounded(px(5.0))
.items_center()
.justify_between()
.min_w_0()
.px_2()
.py_1()
.rounded(px(4.0))
.border_1()
.border_color(rgb(BORDER))
.border_color(rgb(if attention { ATTENTION } else { BORDER }))
.bg(rgb(PANEL_ALT))
.text_sm()
.text_xs()
.text_color(rgb(TEXT))
.child(label)
.child(div().mt_1().text_xs().text_color(rgb(MUTED)).child(detail))
.child(div().truncate().text_color(rgb(MUTED)).child(detail))
};
let choices = if pane.needs_input() {
[
("Review request", "inspect scope", true),
("Steer…", "edit response", false),
("Dismiss", "leave inert", false),
]
} else {
[
("Continue", "keep moving", false),
("Review plan", "inspect commands", false),
("Ask…", "refine prompt", false),
]
};
div()
.flex()
@@ -1018,9 +979,11 @@ impl LumbridgeShell {
.size_full()
.min_h_0()
.overflow_hidden()
.px_3()
.px_2()
.py_2()
.bg(rgb(PANEL))
.border_t_1()
.border_color(rgb(BORDER))
.child(
div()
.flex()
@@ -1030,23 +993,78 @@ impl LumbridgeShell {
.child(
div()
.text_color(rgb(MUTED))
.child("DECISION SHELF · answer, choices, approvals"),
.child("DECISION SHELF"),
)
.child(
div()
.text_color(rgb(ATTENTION))
.child("LOCAL ANALYST OFF · suggestions inert"),
.text_color(rgb(if pane.needs_input() {
ATTENTION
} else {
MUTED
}))
.child(if pane.needs_input() {
"REVIEW REQUIRED"
} else {
"SUGGESTIONS INERT"
}),
),
)
.child(
div()
.flex()
.items_center()
.gap_2()
.flex_col()
.gap_1()
.mt_2()
.child(choice("Continue", "Keep the selected agent moving"))
.child(choice("Review plan", "Inspect capability-scoped commands"))
.child(choice("Ask…", "Refine before any action")),
.children(choices.map(|(label, detail, attention)| {
choice(label, detail, attention)
})),
)
.into_any_element()
}
fn workspace_panel(
&self,
pane: &PaneState,
selected: bool,
cx: &mut Context<Self>,
) -> gpui::AnyElement {
let pane_id = pane.id();
div()
.id(("workspace-panel", pane_id.index()))
.on_click(cx.listener(move |shell, _, window, cx| {
shell.select_pane(pane_id, window, cx);
}))
.flex()
.flex_col()
.size_full()
.min_w_0()
.min_h_0()
.overflow_hidden()
.border_1()
.border_color(rgb(if selected { ACCENT } else { BORDER }))
.child(
div()
.h(relative(0.20))
.flex_none()
.min_h_0()
.overflow_hidden()
.child(self.pane_context(pane, selected)),
)
.child(
div()
.h(relative(0.60))
.flex_none()
.min_h_0()
.overflow_hidden()
.child(self.pane_work_surface(pane)),
)
.child(
div()
.h(relative(0.20))
.flex_none()
.min_h_0()
.overflow_hidden()
.child(self.pane_decision_region(pane)),
)
.into_any_element()
}
@@ -1140,6 +1158,7 @@ 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 sidebar = div()
.flex()
.flex_col()
@@ -1303,117 +1322,38 @@ impl Render for LumbridgeShell {
.px_3()
.text_xs()
.text_color(rgb(MUTED))
.child("1 interactive VT · 5 deterministic · 1 waiting"),
.child(format!(
"{panel_count} visible panels · 1 interactive VT · 1 waiting"
)),
);
let pane_selectors = self
.model
.panes()
.iter()
.map(|pane| {
Self::pane_selector(
pane,
self.model.selected_pane() == pane.id(),
self.runtime_status.clone(),
self.pane_focus[pane.id().index()].clone(),
cx,
)
})
.collect::<Vec<_>>();
let selected = self.model.pane(self.model.selected_pane());
let lane_count = visible_lane_count(window.bounds().size);
let lane_range = visible_lane_range(
let panel_range = visible_panel_range(
PaneId::ALL.len(),
self.model.selected_pane().index(),
lane_count,
panel_count,
);
let work_lanes = lane_range
let workspace_panels = panel_range
.map(|index| {
let pane = self.model.pane(PaneId::ALL[index]);
div()
.flex_1()
.min_w_0()
.min_h_0()
.child(self.work_surface(
.child(self.workspace_panel(
pane,
pane.id() == self.model.selected_pane(),
cx,
))
})
.collect::<Vec<_>>();
let workspace_stack = div()
let workspace_row = div()
.flex()
.flex_col()
.gap_1()
.flex_1()
.min_w_0()
.min_h_0()
.overflow_hidden()
.child(
div()
.h(relative(0.20))
.flex_none()
.flex()
.flex_col()
.min_h_0()
.overflow_hidden()
.px_3()
.py_2()
.bg(rgb(PANEL))
.child(
div()
.flex()
.items_center()
.justify_between()
.text_xs()
.child(
div()
.text_color(rgb(MUTED))
.child("CONTEXT · panes, agents, tools, goal"),
)
.child(
div()
.text_color(rgb(if selected.needs_input() {
ATTENTION
} else {
SUCCESS
}))
.child(if selected.needs_input() {
"NEEDS INPUT · decision below"
} else {
"WORKSPACE HEALTHY"
}),
),
)
.child(
div()
.flex()
.items_center()
.gap_2()
.mt_2()
.flex_1()
.min_h_0()
.overflow_hidden()
.children(pane_selectors),
),
)
.child(
div()
.h(relative(0.60))
.flex_none()
.flex()
.gap_1()
.min_h_0()
.overflow_hidden()
.children(work_lanes),
)
.child(
div()
.h(relative(0.20))
.flex_none()
.min_h_0()
.overflow_hidden()
.child(self.decision_region()),
);
.children(workspace_panels);
let counters = self.model.counters();
let footer_left = format!(
@@ -1511,7 +1451,7 @@ impl Render for LumbridgeShell {
.min_h_0()
.overflow_hidden()
.child(tabs)
.child(workspace_stack),
.child(workspace_row),
),
)
.child(
@@ -1658,7 +1598,7 @@ mod tests {
use super::{
KeyModifiers, TerminalEngine, TerminalEngineOptions, TerminalKey, TerminalScroll,
indexed_terminal_color, terminal_dimensions_for_window, terminal_key_from_parts,
terminal_paint_rows, terminal_scroll_from_parts, visible_lane_count, visible_lane_range,
terminal_paint_rows, terminal_scroll_from_parts, visible_panel_count, visible_panel_range,
};
use gpui::{px, size};
@@ -1685,24 +1625,24 @@ mod tests {
}
#[test]
fn terminal_geometry_tracks_the_middle_sixty_percent() {
fn terminal_geometry_tracks_middle_sixty_percent_per_panel() {
let dimensions = terminal_dimensions_for_window(size(px(1500.0), px(960.0)));
assert_eq!(dimensions.rows(), 24);
assert_eq!(dimensions.rows(), 26);
assert_eq!(dimensions.columns(), 45);
let ultrawide = size(px(3440.0), px(1440.0));
assert_eq!(visible_lane_count(ultrawide), 5);
assert_eq!(visible_panel_count(ultrawide), 5);
let dimensions = terminal_dimensions_for_window(ultrawide);
assert_eq!(dimensions.rows(), 40);
assert_eq!(dimensions.rows(), 42);
assert_eq!(dimensions.columns(), 71);
}
#[test]
fn lane_window_keeps_the_selected_pane_visible() {
assert_eq!(visible_lane_range(6, 0, 5), 0..5);
assert_eq!(visible_lane_range(6, 5, 5), 1..6);
assert_eq!(visible_lane_range(6, 3, 3), 2..5);
assert_eq!(visible_lane_range(6, 4, 1), 4..5);
fn panel_window_keeps_the_selected_pane_visible() {
assert_eq!(visible_panel_range(6, 0, 5), 0..5);
assert_eq!(visible_panel_range(6, 5, 5), 1..6);
assert_eq!(visible_panel_range(6, 3, 3), 2..5);
assert_eq!(visible_panel_range(6, 4, 1), 4..5);
}
#[test]