aboutsummaryrefslogtreecommitdiff
path: root/src/ui/sidebar.rs
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-04-21 16:30:04 +0300
committerGitHub <noreply@github.com>2026-04-21 16:30:04 +0300
commit3397e1ce2922c4ae9b814e8110e81de7d201de81 (patch)
treec457a7cf0af271dea1a412474fad5a33c1b60be8 /src/ui/sidebar.rs
parent2818ff492d45d17fd4f9b15478d703c6c664a8c0 (diff)
feat: add persistent server-client sessions
Make herdr persistent by default. Launching herdr now starts or reattaches to a background session server. Clients can detach and reattach while panes and agent processes keep running. Session mode now supports multi-client attach, auto-detect startup, and a thin-client/headless-server split. This also refactors the large app, ui, input, pane, config, workspace, and persistence modules into smaller focused submodules, while preserving behavior and colocating tests with the code they exercise. Upgrade notes: - persistence mode is now the default - in-app quit detaches the current client instead of stopping the server - use `herdr server stop` to stop the background session - use `--no-session` for the old single-process behavior - default socket paths now live under the config directory
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());
+ }
+}