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, pub agent_label: Option, 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 { 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 { 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 { 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 { 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 { 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, 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 { 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::() + 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()); } }