diff options
Diffstat (limited to 'src/ui/sidebar.rs')
| -rw-r--r-- | src/ui/sidebar.rs | 940 |
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()); + } +} |
