aboutsummaryrefslogtreecommitdiff
path: root/src/ui/sidebar.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/ui/sidebar.rs')
-rw-r--r--src/ui/sidebar.rs940
1 files changed, 940 insertions, 0 deletions
diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs
new file mode 100644
index 00000000..36d1385a
--- /dev/null
+++ b/src/ui/sidebar.rs
@@ -0,0 +1,940 @@
+use ratatui::{
+ layout::{Alignment, Rect},
+ style::{Modifier, Style},
+ text::{Line, Span},
+ widgets::Paragraph,
+ Frame,
+};
+
+use super::scrollbar::{render_scrollbar, should_show_scrollbar};
+use super::status::{agent_icon, state_dot, state_label, state_label_color};
+use crate::app::state::{AgentPanelScope, Palette};
+use crate::app::{AppState, Mode};
+use crate::detect::AgentState;
+
+const WORKSPACE_SECTION_HEADER_ROWS: u16 = 2;
+const AGENT_PANEL_HEADER_ROWS: u16 = 3;
+
+pub(crate) struct AgentPanelEntry {
+ pub ws_idx: usize,
+ pub tab_idx: usize,
+ pub pane_id: crate::layout::PaneId,
+ pub primary_label: String,
+ pub primary_tab_label: Option<String>,
+ pub agent_label: Option<String>,
+ pub state: AgentState,
+ pub seen: bool,
+}
+
+fn sidebar_section_heights(total_h: u16, split_ratio: f32) -> (u16, u16) {
+ if total_h == 0 {
+ return (0, 0);
+ }
+
+ if total_h < 6 {
+ let ws_h = (total_h + 1) / 2;
+ return (ws_h, total_h.saturating_sub(ws_h));
+ }
+
+ let ratio = split_ratio.clamp(0.1, 0.9);
+ let ws_h = ((total_h as f32) * ratio).round() as u16;
+ let ws_h = ws_h.clamp(3, total_h.saturating_sub(3));
+ let detail_h = total_h.saturating_sub(ws_h);
+ (ws_h, detail_h)
+}
+
+pub(crate) fn expanded_sidebar_sections(area: Rect, split_ratio: f32) -> (Rect, Rect) {
+ let content = Rect::new(area.x, area.y, area.width.saturating_sub(1), area.height);
+ if content.width == 0 || content.height == 0 {
+ return (Rect::default(), Rect::default());
+ }
+
+ let (ws_h, detail_h) = sidebar_section_heights(content.height, split_ratio);
+ let ws_area = Rect::new(content.x, content.y, content.width, ws_h);
+ let detail_area = Rect::new(content.x, content.y + ws_h, content.width, detail_h);
+ (ws_area, detail_area)
+}
+
+pub(crate) fn sidebar_section_divider_rect(area: Rect, split_ratio: f32) -> Rect {
+ let content = Rect::new(area.x, area.y, area.width.saturating_sub(1), area.height);
+ if content.width == 0 || content.height < 6 {
+ return Rect::default();
+ }
+
+ let (ws_h, _) = sidebar_section_heights(content.height, split_ratio);
+ Rect::new(content.x, content.y + ws_h, content.width, 1)
+}
+
+fn agent_panel_current_workspace_idx(app: &AppState) -> Option<usize> {
+ if matches!(
+ app.mode,
+ Mode::Navigate
+ | Mode::RenameWorkspace
+ | Mode::Resize
+ | Mode::ConfirmClose
+ | Mode::ContextMenu
+ | Mode::Settings
+ | Mode::GlobalMenu
+ | Mode::KeybindHelp
+ ) {
+ Some(app.selected)
+ } else {
+ app.active
+ }
+}
+
+fn agent_panel_toggle_label(scope: AgentPanelScope) -> &'static str {
+ match scope {
+ AgentPanelScope::CurrentWorkspace => "current",
+ AgentPanelScope::AllWorkspaces => "all",
+ }
+}
+
+pub(crate) fn agent_panel_toggle_rect(area: Rect, scope: AgentPanelScope) -> Rect {
+ if area.width == 0 || area.height < 2 {
+ return Rect::default();
+ }
+
+ let label = agent_panel_toggle_label(scope);
+ let width = label.chars().count() as u16;
+ Rect::new(
+ area.x + area.width.saturating_sub(width),
+ area.y + 1,
+ width,
+ 1,
+ )
+}
+
+pub(crate) fn agent_panel_entries(app: &AppState) -> Vec<AgentPanelEntry> {
+ match app.agent_panel_scope {
+ AgentPanelScope::CurrentWorkspace => {
+ let Some(ws_idx) = agent_panel_current_workspace_idx(app) else {
+ return Vec::new();
+ };
+ let Some(ws) = app.workspaces.get(ws_idx) else {
+ return Vec::new();
+ };
+ ws.pane_details()
+ .into_iter()
+ .map(|detail| AgentPanelEntry {
+ ws_idx,
+ tab_idx: detail.tab_idx,
+ pane_id: detail.pane_id,
+ primary_label: detail.label,
+ primary_tab_label: None,
+ agent_label: None,
+ state: detail.state,
+ seen: detail.seen,
+ })
+ .collect()
+ }
+ AgentPanelScope::AllWorkspaces => app
+ .workspaces
+ .iter()
+ .enumerate()
+ .flat_map(|(ws_idx, ws)| {
+ let multi_tab = ws.tabs.len() > 1;
+ let workspace_label = ws.display_name();
+ ws.pane_details()
+ .into_iter()
+ .map(move |detail| AgentPanelEntry {
+ ws_idx,
+ tab_idx: detail.tab_idx,
+ pane_id: detail.pane_id,
+ primary_label: workspace_label.clone(),
+ primary_tab_label: multi_tab.then_some(detail.tab_label),
+ agent_label: Some(detail.agent_label),
+ state: detail.state,
+ seen: detail.seen,
+ })
+ })
+ .collect(),
+ }
+}
+
+fn truncate_text(text: &str, max_width: usize) -> String {
+ let len = text.chars().count();
+ if len <= max_width {
+ return text.to_string();
+ }
+ if max_width == 0 {
+ return String::new();
+ }
+ if max_width == 1 {
+ return "…".to_string();
+ }
+ let prefix: String = text.chars().take(max_width.saturating_sub(1)).collect();
+ format!("{prefix}…")
+}
+
+fn format_agent_panel_primary_label(entry: &AgentPanelEntry, max_width: usize) -> String {
+ let Some(tab_label) = entry.primary_tab_label.as_deref() else {
+ return truncate_text(&entry.primary_label, max_width);
+ };
+
+ let separator = " · ";
+ let separator_width = separator.chars().count();
+ if max_width <= separator_width + 2 {
+ return truncate_text(
+ &format!("{}{}{}", entry.primary_label, separator, tab_label),
+ max_width,
+ );
+ }
+
+ let available = max_width.saturating_sub(separator_width);
+ let min_tab = 4.min(available.saturating_sub(1)).max(1);
+ let preferred_workspace = ((available * 2) / 3).max(1);
+ let mut workspace_budget = preferred_workspace
+ .min(available.saturating_sub(min_tab))
+ .max(1);
+ let mut tab_budget = available.saturating_sub(workspace_budget);
+
+ let workspace_len = entry.primary_label.chars().count();
+ let tab_len = tab_label.chars().count();
+
+ if workspace_len < workspace_budget {
+ let spare = workspace_budget - workspace_len;
+ workspace_budget = workspace_len;
+ tab_budget = (tab_budget + spare).min(available.saturating_sub(workspace_budget));
+ }
+ if tab_len < tab_budget {
+ let spare = tab_budget - tab_len;
+ tab_budget = tab_len;
+ workspace_budget = (workspace_budget + spare).min(available.saturating_sub(tab_budget));
+ }
+
+ format!(
+ "{}{}{}",
+ truncate_text(&entry.primary_label, workspace_budget),
+ separator,
+ truncate_text(tab_label, tab_budget)
+ )
+}
+
+fn workspace_row_height(ws: &crate::workspace::Workspace) -> u16 {
+ if ws.branch().is_some() {
+ 2
+ } else {
+ 1
+ }
+}
+
+pub(crate) fn workspace_list_rect(area: Rect, split_ratio: f32) -> Rect {
+ let (ws_area, _) = expanded_sidebar_sections(area, split_ratio);
+ ws_area
+}
+
+pub(crate) fn workspace_list_body_rect(area: Rect, has_scrollbar: bool) -> Rect {
+ if area.width == 0 || area.height <= WORKSPACE_SECTION_HEADER_ROWS {
+ return Rect::default();
+ }
+
+ let body_y = area.y.saturating_add(WORKSPACE_SECTION_HEADER_ROWS);
+ let footer_y = area.y + area.height.saturating_sub(1);
+ let body_height = footer_y.saturating_sub(body_y);
+ let body_width = area.width.saturating_sub(u16::from(has_scrollbar));
+ Rect::new(area.x, body_y, body_width, body_height)
+}
+
+fn workspace_list_visible_count(app: &AppState, area: Rect, scroll: usize) -> usize {
+ let body = workspace_list_body_rect(area, false);
+ if body.width == 0 || body.height == 0 {
+ return 0;
+ }
+
+ let mut used_rows = 0u16;
+ let mut visible = 0usize;
+ for ws in app.workspaces.iter().skip(scroll) {
+ let needed = workspace_row_height(ws).saturating_add(1);
+ if used_rows.saturating_add(needed) > body.height {
+ break;
+ }
+ used_rows = used_rows.saturating_add(needed);
+ visible += 1;
+ }
+ visible
+}
+
+pub(crate) fn workspace_list_scroll_metrics(
+ app: &AppState,
+ area: Rect,
+) -> crate::pane::ScrollMetrics {
+ let viewport_rows = workspace_list_visible_count(app, area, app.workspace_scroll);
+ let total_rows = app.workspaces.len();
+ let max_offset_from_bottom = total_rows.saturating_sub(viewport_rows);
+ let offset_from_bottom = total_rows
+ .saturating_sub(app.workspace_scroll)
+ .saturating_sub(viewport_rows);
+
+ crate::pane::ScrollMetrics {
+ offset_from_bottom,
+ max_offset_from_bottom,
+ viewport_rows,
+ }
+}
+
+pub(crate) fn workspace_list_scrollbar_rect(app: &AppState, area: Rect) -> Option<Rect> {
+ let metrics = workspace_list_scroll_metrics(app, area);
+ let body = workspace_list_body_rect(area, true);
+ (should_show_scrollbar(metrics) && body.width > 0 && body.height > 0).then_some(Rect::new(
+ area.x + area.width.saturating_sub(1),
+ body.y,
+ 1,
+ body.height,
+ ))
+}
+
+pub(crate) fn agent_panel_body_rect(area: Rect, has_scrollbar: bool) -> Rect {
+ if area.width == 0 || area.height <= AGENT_PANEL_HEADER_ROWS {
+ return Rect::default();
+ }
+
+ let body_y = area.y.saturating_add(AGENT_PANEL_HEADER_ROWS);
+ let body_height = (area.y + area.height).saturating_sub(body_y);
+ let body_width = area.width.saturating_sub(u16::from(has_scrollbar));
+ Rect::new(area.x, body_y, body_width, body_height)
+}
+
+fn agent_panel_visible_count(area: Rect) -> usize {
+ let body = agent_panel_body_rect(area, false);
+ if body.width == 0 || body.height < 2 {
+ return 0;
+ }
+
+ let mut used_rows = 0u16;
+ let mut visible = 0usize;
+ while used_rows.saturating_add(2) <= body.height {
+ used_rows = used_rows.saturating_add(2);
+ visible += 1;
+ if used_rows < body.height {
+ used_rows = used_rows.saturating_add(1);
+ }
+ }
+ visible
+}
+
+pub(crate) fn agent_panel_scroll_metrics(app: &AppState, area: Rect) -> crate::pane::ScrollMetrics {
+ let viewport_rows = agent_panel_visible_count(area);
+ let total_rows = agent_panel_entries(app).len();
+ let max_offset_from_bottom = total_rows.saturating_sub(viewport_rows);
+ let offset_from_bottom = total_rows
+ .saturating_sub(app.agent_panel_scroll)
+ .saturating_sub(viewport_rows);
+
+ crate::pane::ScrollMetrics {
+ offset_from_bottom,
+ max_offset_from_bottom,
+ viewport_rows,
+ }
+}
+
+pub(crate) fn agent_panel_scrollbar_rect(app: &AppState, area: Rect) -> Option<Rect> {
+ let metrics = agent_panel_scroll_metrics(app, area);
+ let body = agent_panel_body_rect(area, true);
+ (should_show_scrollbar(metrics) && body.width > 0 && body.height > 0).then_some(Rect::new(
+ area.x + area.width.saturating_sub(1),
+ body.y,
+ 1,
+ body.height,
+ ))
+}
+
+pub(crate) fn compute_workspace_card_areas(
+ app: &AppState,
+ area: Rect,
+) -> Vec<crate::app::state::WorkspaceCardArea> {
+ let ws_area = workspace_list_rect(area, app.sidebar_section_split);
+ if ws_area == Rect::default() {
+ return Vec::new();
+ }
+
+ let metrics = workspace_list_scroll_metrics(app, ws_area);
+ let body = workspace_list_body_rect(ws_area, should_show_scrollbar(metrics));
+ if body.width == 0 || body.height == 0 {
+ return Vec::new();
+ }
+
+ let mut row_y = body.y;
+ let body_bottom = body.y + body.height;
+ let mut cards = Vec::new();
+
+ for (ws_idx, ws) in app.workspaces.iter().enumerate().skip(app.workspace_scroll) {
+ let row_height = workspace_row_height(ws);
+ if row_y.saturating_add(row_height).saturating_add(1) > body_bottom {
+ break;
+ }
+ cards.push(crate::app::state::WorkspaceCardArea {
+ ws_idx,
+ rect: Rect::new(body.x, row_y, body.width, row_height),
+ });
+ row_y = row_y.saturating_add(row_height + 1);
+ }
+
+ cards
+}
+
+/// Auto-scale sidebar width based on workspace identity + agent summary.
+pub(crate) fn collapsed_sidebar_sections(area: Rect) -> (Rect, Option<u16>, Rect) {
+ let content = Rect::new(area.x, area.y, area.width.saturating_sub(1), area.height);
+ if content.width == 0 || content.height == 0 {
+ return (Rect::default(), None, Rect::default());
+ }
+
+ if content.height < 7 {
+ return (content, None, Rect::default());
+ }
+
+ let total_h = content.height as usize;
+ let ws_h = (total_h + 1) / 2;
+ let detail_h = total_h.saturating_sub(ws_h + 1);
+ if ws_h == 0 || detail_h == 0 {
+ return (content, None, Rect::default());
+ }
+
+ let divider_y = content.y + ws_h as u16;
+ let ws_area = Rect::new(content.x, content.y, content.width, ws_h as u16);
+ let detail_area = Rect::new(content.x, divider_y + 1, content.width, detail_h as u16);
+ (ws_area, Some(divider_y), detail_area)
+}
+
+/// Collapsed sidebar: workspace glance on top, compact agent list below.
+pub(super) fn render_sidebar_collapsed(app: &AppState, frame: &mut Frame, area: Rect) {
+ let is_navigating = matches!(app.mode, Mode::Navigate);
+
+ let p = &app.palette;
+ let sep_style = if is_navigating {
+ Style::default().fg(p.accent)
+ } else {
+ Style::default().fg(p.surface_dim)
+ };
+ let sep_x = area.x + area.width.saturating_sub(1);
+ let buf = frame.buffer_mut();
+ for y in area.y..area.y + area.height {
+ buf[(sep_x, y)].set_symbol("│");
+ buf[(sep_x, y)].set_style(sep_style);
+ }
+
+ let (ws_area, divider_y, detail_area) = collapsed_sidebar_sections(area);
+ if ws_area == Rect::default() {
+ render_sidebar_toggle(app, frame, area, true, p);
+ return;
+ }
+
+ for (visible_idx, ws) in app.workspaces.iter().enumerate() {
+ let y = ws_area.y + visible_idx as u16;
+ if y >= ws_area.y + ws_area.height {
+ break;
+ }
+ let (agg_state, agg_seen) = ws.aggregate_state();
+ let (icon, icon_style) = state_dot(agg_state, agg_seen, p);
+ let is_selected = visible_idx == app.selected && is_navigating;
+ let is_active = Some(visible_idx) == app.active;
+ let row_style = if is_selected {
+ Style::default().bg(p.surface0)
+ } else if is_active {
+ Style::default().bg(p.surface_dim)
+ } else {
+ Style::default()
+ };
+ let num_style = if is_selected {
+ Style::default().fg(p.overlay1).bg(p.surface0)
+ } else if is_active {
+ Style::default().fg(p.text).bg(p.surface_dim)
+ } else {
+ Style::default().fg(p.overlay0)
+ };
+
+ if is_selected || is_active {
+ let buf = frame.buffer_mut();
+ for x in ws_area.x..ws_area.x + ws_area.width {
+ buf[(x, y)].set_style(row_style);
+ }
+ }
+
+ frame.render_widget(
+ Paragraph::new(Line::from(vec![
+ Span::styled(format!("{}", visible_idx + 1), num_style),
+ Span::styled(" ", row_style),
+ Span::styled(icon, icon_style),
+ ])),
+ Rect::new(ws_area.x, y, ws_area.width, 1),
+ );
+ }
+
+ if let Some(divider_y) = divider_y {
+ let buf = frame.buffer_mut();
+ for x in ws_area.x..ws_area.x + ws_area.width {
+ buf[(x, divider_y)].set_symbol("─");
+ buf[(x, divider_y)].set_style(Style::default().fg(p.surface_dim));
+ }
+ }
+
+ let detail_ws_idx = if is_navigating {
+ Some(app.selected)
+ } else {
+ app.active
+ };
+ let detail_content_area = Rect::new(
+ detail_area.x,
+ detail_area.y,
+ detail_area.width,
+ detail_area.height.saturating_sub(1),
+ );
+ if detail_content_area != Rect::default() {
+ if let Some(ws_idx) = detail_ws_idx {
+ if let Some(ws) = app.workspaces.get(ws_idx) {
+ for (detail_idx, detail) in ws.pane_details().iter().enumerate() {
+ let y = detail_content_area.y + detail_idx as u16;
+ if y >= detail_content_area.y + detail_content_area.height {
+ break;
+ }
+ let pane_num = ws
+ .public_pane_number(detail.pane_id)
+ .unwrap_or(detail_idx + 1);
+ let pane_style = Style::default().fg(p.overlay0);
+ let (icon, icon_style) =
+ agent_icon(detail.state, detail.seen, app.spinner_tick, p);
+ frame.render_widget(
+ Paragraph::new(Line::from(vec![
+ Span::styled(format!("{pane_num}"), pane_style),
+ Span::styled(" ", pane_style),
+ Span::styled(icon, icon_style),
+ ])),
+ Rect::new(detail_content_area.x, y, detail_content_area.width, 1),
+ );
+ }
+ }
+ }
+ }
+
+ render_sidebar_toggle(app, frame, area, true, p);
+}
+
+pub(crate) fn workspace_drop_indicator_row(
+ cards: &[crate::app::state::WorkspaceCardArea],
+ area: Rect,
+ insert_idx: usize,
+) -> Option<u16> {
+ if area.height == 0 {
+ return None;
+ }
+ let list_bottom = area.y + area.height.saturating_sub(1);
+
+ let first = cards.first()?;
+ if insert_idx == first.ws_idx {
+ return first.rect.y.checked_sub(1).filter(|y| *y < list_bottom);
+ }
+
+ if let Some(card) = cards.iter().find(|card| card.ws_idx == insert_idx) {
+ return card.rect.y.checked_sub(1).filter(|y| *y < list_bottom);
+ }
+
+ cards
+ .last()
+ .filter(|card| insert_idx == card.ws_idx.saturating_add(1))
+ .map(|card| card.rect.y.saturating_add(card.rect.height))
+ .filter(|y| *y < list_bottom)
+}
+
+pub(super) fn render_sidebar(app: &AppState, frame: &mut Frame, area: Rect) {
+ let p = &app.palette;
+ let is_navigating = matches!(app.mode, Mode::Navigate);
+ let sep_style = if is_navigating {
+ Style::default().fg(p.accent)
+ } else {
+ Style::default().fg(p.surface_dim)
+ };
+
+ let sep_x = area.x + area.width.saturating_sub(1);
+ let buf = frame.buffer_mut();
+ for y in area.y..area.y + area.height {
+ buf[(sep_x, y)].set_symbol("│");
+ buf[(sep_x, y)].set_style(sep_style);
+ }
+
+ let (ws_area, detail_area) = expanded_sidebar_sections(area, app.sidebar_section_split);
+
+ render_workspace_list(app, frame, ws_area, is_navigating);
+ render_agent_detail(app, frame, detail_area);
+ render_sidebar_toggle(app, frame, area, false, p);
+}
+
+fn render_workspace_list(app: &AppState, frame: &mut Frame, area: Rect, is_navigating: bool) {
+ let p = &app.palette;
+ let dragged_ws_idx = match app.drag.as_ref().map(|drag| &drag.target) {
+ Some(crate::app::state::DragTarget::WorkspaceReorder { source_ws_idx, .. }) => {
+ Some(*source_ws_idx)
+ }
+ _ => None,
+ };
+ let insertion_row = match app.drag.as_ref().map(|drag| &drag.target) {
+ Some(crate::app::state::DragTarget::WorkspaceReorder {
+ insert_idx: Some(insert_idx),
+ ..
+ }) => workspace_drop_indicator_row(&app.view.workspace_card_areas, area, *insert_idx),
+ _ => None,
+ };
+
+ let list_bottom = area.y + area.height.saturating_sub(1);
+ if area.height > 0 {
+ frame.render_widget(
+ Paragraph::new(Line::from(vec![Span::styled(
+ " spaces",
+ Style::default().fg(p.overlay0).add_modifier(Modifier::BOLD),
+ )])),
+ Rect::new(area.x, area.y, area.width, 1),
+ );
+ }
+
+ let metrics = workspace_list_scroll_metrics(app, area);
+ let scrollbar_rect = workspace_list_scrollbar_rect(app, area);
+ let cards = &app.view.workspace_card_areas;
+
+ for card in cards {
+ let i = card.ws_idx;
+ let ws = &app.workspaces[i];
+ let row_y = card.rect.y;
+ let row_height = card.rect.height;
+ let selected = i == app.selected && is_navigating;
+ let is_active = Some(i) == app.active;
+ let is_dragged = dragged_ws_idx == Some(i);
+ let highlighted = selected || is_active || is_dragged;
+ let (agg_state, agg_seen) = ws.aggregate_state();
+
+ if highlighted {
+ let bg = if selected {
+ p.surface0
+ } else if is_dragged {
+ p.surface1
+ } else {
+ p.surface_dim
+ };
+ let buf = frame.buffer_mut();
+ for y in row_y..row_y + row_height {
+ if y >= list_bottom {
+ break;
+ }
+ for x in card.rect.x..card.rect.x + card.rect.width {
+ buf[(x, y)].set_style(Style::default().bg(bg));
+ }
+ }
+ }
+
+ if is_active {
+ let buf = frame.buffer_mut();
+ for y in row_y..row_y + row_height {
+ if y >= list_bottom {
+ break;
+ }
+ buf[(card.rect.x, y)].set_symbol("▌");
+ buf[(card.rect.x, y)].set_style(Style::default().fg(p.accent));
+ }
+ }
+
+ let name_style = if selected || is_active || is_dragged {
+ Style::default().fg(p.text).add_modifier(Modifier::BOLD)
+ } else {
+ Style::default().fg(p.subtext0)
+ };
+
+ let (icon, icon_style) = state_dot(agg_state, agg_seen, p);
+ let line1 = vec![
+ Span::styled(" ", Style::default()),
+ Span::styled(icon, icon_style),
+ Span::styled(" ", Style::default()),
+ Span::styled(ws.display_name(), name_style),
+ ];
+
+ frame.render_widget(
+ Paragraph::new(Line::from(line1)),
+ Rect::new(card.rect.x, row_y, card.rect.width, 1),
+ );
+
+ if row_height > 1 && row_y + 1 < list_bottom {
+ if let Some(branch) = ws.branch() {
+ let upstream_label = ws.git_ahead_behind().and_then(|(ahead, behind)| {
+ let mut parts = Vec::new();
+ if ahead > 0 {
+ parts.push((format!("↑{}", ahead), p.green));
+ }
+ if behind > 0 {
+ parts.push((format!("↓{}", behind), p.red));
+ }
+ (!parts.is_empty()).then_some(parts)
+ });
+ let reserved = upstream_label
+ .as_ref()
+ .map(|parts| {
+ parts.iter().map(|(label, _)| label.len()).sum::<usize>() + parts.len()
+ })
+ .unwrap_or(0);
+ let max_branch_len = (card.rect.width as usize).saturating_sub(5 + reserved);
+ let branch_display = if branch.len() > max_branch_len {
+ format!("{}…", &branch[..max_branch_len.saturating_sub(1)])
+ } else {
+ branch
+ };
+ let branch_color = if selected || is_active {
+ p.mauve
+ } else {
+ p.overlay0
+ };
+ let mut spans = vec![
+ Span::styled(" ", Style::default()),
+ Span::styled(branch_display, Style::default().fg(branch_color)),
+ ];
+ if let Some(parts) = upstream_label {
+ spans.push(Span::styled(" ", Style::default()));
+ for (idx, (label, color)) in parts.into_iter().enumerate() {
+ if idx > 0 {
+ spans.push(Span::styled(" ", Style::default()));
+ }
+ spans.push(Span::styled(label, Style::default().fg(color)));
+ }
+ }
+ frame.render_widget(
+ Paragraph::new(Line::from(spans)),
+ Rect::new(card.rect.x, row_y + 1, card.rect.width, 1),
+ );
+ }
+ }
+ }
+
+ if let Some(y) = insertion_row.filter(|y| *y < list_bottom) {
+ let indicator_right = scrollbar_rect
+ .map(|rect| rect.x)
+ .unwrap_or(area.x + area.width);
+ let buf = frame.buffer_mut();
+ for x in area.x..indicator_right {
+ buf[(x, y)].set_symbol("─");
+ buf[(x, y)].set_style(Style::default().fg(p.accent));
+ }
+ }
+
+ if let Some(track) = scrollbar_rect {
+ render_scrollbar(frame, metrics, track, p.surface_dim, p.overlay0, "▕");
+ }
+
+ if list_bottom > area.y {
+ let new_rect = app.sidebar_new_button_rect();
+ frame.render_widget(
+ Paragraph::new(Span::styled("new", Style::default().fg(p.overlay0))),
+ new_rect,
+ );
+
+ let menu_rect = app.global_launcher_rect();
+ let menu_line = if app.update_available.is_some() {
+ Line::from(vec![
+ Span::styled("menu", Style::default().fg(p.overlay0)),
+ Span::styled(
+ " ●",
+ Style::default().fg(p.accent).add_modifier(Modifier::BOLD),
+ ),
+ ])
+ } else {
+ Line::from(vec![Span::styled("menu", Style::default().fg(p.overlay0))])
+ };
+ frame.render_widget(
+ Paragraph::new(menu_line).alignment(Alignment::Right),
+ menu_rect,
+ );
+ }
+}
+
+fn render_agent_detail(app: &AppState, frame: &mut Frame, area: Rect) {
+ let p = &app.palette;
+
+ if area.height < 3 {
+ return;
+ }
+
+ let sep_line = "─".repeat(area.width as usize);
+ frame.render_widget(
+ Paragraph::new(Span::styled(&sep_line, Style::default().fg(p.surface_dim))),
+ Rect::new(area.x, area.y, area.width, 1),
+ );
+
+ frame.render_widget(
+ Paragraph::new(Line::from(vec![Span::styled(
+ " agents",
+ Style::default().fg(p.overlay0).add_modifier(Modifier::BOLD),
+ )])),
+ Rect::new(area.x, area.y + 1, area.width, 1),
+ );
+ let toggle_rect = agent_panel_toggle_rect(area, app.agent_panel_scope);
+ if toggle_rect != Rect::default() {
+ frame.render_widget(
+ Paragraph::new(Span::styled(
+ agent_panel_toggle_label(app.agent_panel_scope),
+ Style::default().fg(p.overlay0).add_modifier(Modifier::BOLD),
+ ))
+ .alignment(Alignment::Right),
+ toggle_rect,
+ );
+ }
+
+ let details = agent_panel_entries(app);
+ let metrics = agent_panel_scroll_metrics(app, area);
+ let scrollbar_rect = agent_panel_scrollbar_rect(app, area);
+ let body = agent_panel_body_rect(area, should_show_scrollbar(metrics));
+ if body == Rect::default() {
+ return;
+ }
+
+ let mut row_y = body.y;
+ let body_bottom = body.y + body.height;
+ for detail in details.iter().skip(app.agent_panel_scroll) {
+ if row_y.saturating_add(1) >= body_bottom {
+ break;
+ }
+
+ let (icon, icon_style) = agent_icon(detail.state, detail.seen, app.spinner_tick, p);
+ let label_color = state_label_color(detail.state, detail.seen, p);
+ let label = state_label(detail.state, detail.seen);
+
+ let name_style = Style::default().fg(p.subtext0).add_modifier(Modifier::BOLD);
+ let status_style = Style::default().fg(label_color).add_modifier(Modifier::DIM);
+ let agent_style = Style::default().fg(p.overlay0).add_modifier(Modifier::DIM);
+
+ let primary_label =
+ format_agent_panel_primary_label(detail, body.width.saturating_sub(3) as usize);
+ let name_line = Line::from(vec![
+ Span::styled(" ", Style::default()),
+ Span::styled(icon, icon_style),
+ Span::styled(" ", Style::default()),
+ Span::styled(primary_label, name_style),
+ ]);
+ frame.render_widget(
+ Paragraph::new(name_line),
+ Rect::new(body.x, row_y, body.width, 1),
+ );
+ row_y += 1;
+
+ let mut status_spans = vec![
+ Span::styled(" ", Style::default()),
+ Span::styled(label, status_style),
+ ];
+ if let Some(agent_label) = &detail.agent_label {
+ status_spans.push(Span::styled(" · ", agent_style));
+ status_spans.push(Span::styled(agent_label, agent_style));
+ }
+ frame.render_widget(
+ Paragraph::new(Line::from(status_spans)),
+ Rect::new(body.x, row_y, body.width, 1),
+ );
+ row_y += 1;
+
+ if row_y < body_bottom {
+ row_y += 1;
+ }
+ }
+
+ if let Some(track) = scrollbar_rect {
+ render_scrollbar(frame, metrics, track, p.surface_dim, p.overlay0, "▕");
+ }
+}
+
+pub(crate) fn collapsed_sidebar_toggle_rect(area: Rect) -> Rect {
+ let bottom_y = area.y + area.height.saturating_sub(1);
+ let content_w = area.width.saturating_sub(1);
+ if content_w == 0 || area.height == 0 {
+ return Rect::default();
+ }
+ let x = area.x + content_w / 2;
+ Rect::new(x, bottom_y, 1, 1)
+}
+
+fn render_sidebar_toggle(
+ app: &AppState,
+ frame: &mut Frame,
+ area: Rect,
+ collapsed: bool,
+ p: &Palette,
+) {
+ if !collapsed {
+ return;
+ }
+ let toggle_area = collapsed_sidebar_toggle_rect(area);
+ if toggle_area == Rect::default() {
+ return;
+ }
+ let icon_style = if app.update_available.is_some() {
+ Style::default().fg(p.accent).add_modifier(Modifier::BOLD)
+ } else {
+ Style::default().fg(p.overlay0)
+ };
+ frame.render_widget(Paragraph::new(Span::styled("»", icon_style)), toggle_area);
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::{detect::Agent, workspace::Workspace};
+
+ #[test]
+ fn all_workspaces_agent_panel_entries_use_workspace_and_optional_tab_labels() {
+ let mut app = crate::app::state::AppState::test_new();
+ let mut first = Workspace::test_new("one");
+ let first_pane = first.tabs[0].root_pane;
+ first.tabs[0]
+ .panes
+ .get_mut(&first_pane)
+ .unwrap()
+ .detected_agent = Some(Agent::Pi);
+
+ let mut second = Workspace::test_new("two");
+ let second_tab = second.test_add_tab(Some("logs"));
+ let second_pane = second.tabs[second_tab].root_pane;
+ second.tabs[second_tab]
+ .panes
+ .get_mut(&second_pane)
+ .unwrap()
+ .detected_agent = Some(Agent::Claude);
+
+ app.workspaces = vec![first, second];
+ app.active = Some(0);
+ app.selected = 0;
+ app.agent_panel_scope = AgentPanelScope::AllWorkspaces;
+
+ let entries = agent_panel_entries(&app);
+ assert_eq!(entries[0].primary_label, "one");
+ assert!(entries[0].primary_tab_label.is_none());
+ assert_eq!(entries[0].agent_label.as_deref(), Some("pi"));
+ assert_eq!(entries[1].primary_label, "two");
+ assert_eq!(entries[1].primary_tab_label.as_deref(), Some("logs"));
+ assert_eq!(entries[1].agent_label.as_deref(), Some("claude"));
+ }
+
+ #[test]
+ fn all_workspaces_primary_label_truncates_workspace_and_tab() {
+ let entry = AgentPanelEntry {
+ ws_idx: 0,
+ tab_idx: 0,
+ pane_id: crate::layout::PaneId::from_raw(1),
+ primary_label: "agent-browser".into(),
+ primary_tab_label: Some("test-escalation".into()),
+ agent_label: Some("claude".into()),
+ state: AgentState::Idle,
+ seen: true,
+ };
+
+ let label = format_agent_panel_primary_label(&entry, 18);
+
+ assert_eq!(label, "agent-bro… · test…");
+ }
+
+ #[test]
+ fn expanded_sidebar_sections_handle_tiny_heights() {
+ let (ws_area, detail_area) = expanded_sidebar_sections(Rect::new(0, 0, 20, 5), 0.9);
+
+ assert_eq!(ws_area, Rect::new(0, 0, 19, 3));
+ assert_eq!(detail_area, Rect::new(0, 3, 19, 2));
+ }
+
+ #[test]
+ fn sidebar_section_divider_is_hidden_for_tiny_heights() {
+ let divider = sidebar_section_divider_rect(Rect::new(0, 0, 20, 5), 0.5);
+
+ assert_eq!(divider, Rect::default());
+ }
+}