use std::collections::HashMap; use ratatui::{ buffer::Buffer, layout::Rect, style::{Modifier, Style}, text::Line, widgets::{Paragraph, Widget}, }; use super::*; struct AgentRow { pane_id: String, status: crate::api::schema::AgentStatus, focused: bool, rows: Vec>, } pub(super) fn ordered_agent_pane_ids( snapshot: &ClientShellSnapshot, sort: crate::config::AgentPanelSortConfig, ) -> Vec { if snapshot.agent_view_label.is_some() { return snapshot .agent_order .iter() .filter(|pane_id| { snapshot .agents .iter() .any(|agent| agent.pane_id == pane_id.as_str()) }) .cloned() .collect(); } let mut agents = snapshot.agents.iter().collect::>(); if sort == crate::config::AgentPanelSortConfig::Priority { agents.sort_by_key(|agent| { ( std::cmp::Reverse(status_priority(agent.agent_status)), std::cmp::Reverse(agent.state_change_seq), ) }); } agents .into_iter() .map(|agent| agent.pane_id.clone()) .collect() } pub(super) fn render_agent_panel( buffer: &mut Buffer, area: Rect, snapshot: &ClientShellSnapshot, config: &ClientShellConfig, agent_scroll: &mut usize, hits: &mut ShellHitMap, ) { if area.height == 0 { return; } put_text( buffer, area.x, area.y, area.width, &"─".repeat(area.width as usize), Style::default().fg(config.palette.surface_dim), ); if area.height < 2 { return; } put_text( buffer, area.x, area.y + 1, area.width, " agents", Style::default() .fg(config.palette.overlay0) .add_modifier(Modifier::BOLD), ); let sort_label = snapshot .agent_view_label .as_deref() .unwrap_or(match config.agent_panel_sort { crate::config::AgentPanelSortConfig::Spaces => "grouped", crate::config::AgentPanelSortConfig::Priority => "priority", }); let sort_width = display_width(sort_label).min(area.width as usize) as u16; let sort_rect = Rect::new( area.right().saturating_sub(sort_width), area.y + 1, sort_width, 1, ); hits.agent_sort_toggle = if config.mouse_capture && snapshot.agent_view_label.is_none() { sort_rect } else { Rect::default() }; put_text( buffer, sort_rect.x, sort_rect.y, sort_rect.width, sort_label, Style::default() .fg(if snapshot.agent_view_label.is_some() { config.palette.accent } else { config.palette.overlay0 }) .add_modifier(Modifier::BOLD), ); let rows = agent_rows(snapshot, config); let body = Rect::new( area.x, area.y.saturating_add(3), area.width, area.height.saturating_sub(3), ); hits.agent_body = body; if body.is_empty() || rows.is_empty() { *agent_scroll = 0; if !body.is_empty() && snapshot.agent_view_label.is_some() { put_text( buffer, body.x, body.y, body.width, " no matching agents", Style::default() .fg(config.palette.overlay0) .add_modifier(Modifier::DIM), ); } return; } let row_heights = rows .iter() .map(|row| row.rows.len().max(1).min(u16::MAX as usize) as u16) .collect::>(); let gaps = rows .iter() .enumerate() .map(|(index, _)| { if index + 1 < rows.len() { config.agents.row_gap } else { 0 } }) .collect::>(); let metrics = super::scroll::list_scroll_metrics(&row_heights, &gaps, body.height, *agent_scroll); hits.agent_max_scroll = metrics.max_offset_from_bottom; hits.agent_scroll_metrics = Some(metrics); *agent_scroll = metrics .max_offset_from_bottom .saturating_sub(metrics.offset_from_bottom); let show_scrollbar = metrics.max_offset_from_bottom > 0 && body.width > 1; let content_width = body.width.saturating_sub(u16::from(show_scrollbar)); let mut y = body.y; for (index, row) in rows.iter().enumerate().skip(*agent_scroll) { let height = (row.rows.len().max(1).min(u16::MAX as usize) as u16).min(body.height); if y.saturating_add(height) > body.bottom() { break; } let rect = Rect::new(body.x, y, content_width, height); hits.agents.push((rect, row.pane_id.clone())); render_agent_row(buffer, rect, row, config); y = y .saturating_add(height) .saturating_add(if index + 1 < rows.len() { config.agents.row_gap } else { 0 }); } if show_scrollbar { let track = Rect::new(body.right().saturating_sub(1), body.y, 1, body.height); hits.agent_scrollbar = track; super::scroll::render_list_scrollbar(buffer, track, metrics, &config.palette); } } fn agent_rows(snapshot: &ClientShellSnapshot, config: &ClientShellConfig) -> Vec { ordered_agent_pane_ids(snapshot, config.agent_panel_sort) .into_iter() .filter_map(|pane_id| { let agent = snapshot .agents .iter() .find(|agent| agent.pane_id == pane_id)?; let workspace = snapshot .workspaces .iter() .find(|workspace| workspace.workspace_id == agent.workspace_id)?; let tab = snapshot.tabs.iter().find(|tab| tab.tab_id == agent.tab_id); let pane = snapshot .panes .iter() .find(|pane| pane.pane_id == agent.pane_id); let tab_count = snapshot .tabs .iter() .filter(|candidate| candidate.workspace_id == agent.workspace_id) .count(); let tab_label = tab .filter(|tab| tab_count > 1 || tab.custom_label) .map(|tab| tab.label.as_str()); let agent_label = agent .display_agent .as_deref() .or(agent.name.as_deref()) .or(agent.agent.as_deref()) .or(agent.title.as_deref()); let labels = agent .state_labels .iter() .cloned() .collect::>(); let tokens = agent.tokens.iter().cloned().collect::>(); let state_text = labels .get(status_text(agent.agent_status)) .map(String::as_str) .unwrap_or_else(|| sidebar_status_text(agent.agent_status)); let canonical_agent = agent .agent .as_deref() .and_then(crate::detect::parse_agent_label); let rows = crate::ui::sidebar_agent_rows( &config.agents, crate::ui::AgentTokenContext { workspace: &workspace.label, tab: tab_label, pane: agent .title .as_deref() .or_else(|| pane.and_then(|pane| pane.label.as_deref())), agent_label, terminal_title: agent.terminal_title.as_deref(), terminal_title_stripped: agent.terminal_title_stripped.as_deref(), canonical_agent, tokens: &tokens, }, state_text, ); Some(AgentRow { pane_id: agent.pane_id.clone(), status: agent.agent_status, focused: agent.focused, rows, }) }) .collect() } fn render_agent_row(buffer: &mut Buffer, rect: Rect, row: &AgentRow, config: &ClientShellConfig) { let palette = &config.palette; let row_style = if row.focused { Style::default().bg(palette.active_row_bg) } else { Style::default() }; let name_style = if row.focused { Style::default() .fg(palette.text) .add_modifier(Modifier::BOLD) } else { Style::default() .fg(palette.subtext0) .add_modifier(Modifier::BOLD) }; let status_style = Style::default() .fg(status_color(row.status, palette)) .add_modifier(if row.focused { Modifier::empty() } else { Modifier::DIM }); let secondary = Style::default() .fg(palette.overlay0) .add_modifier(Modifier::DIM); let icon = ( status_icon(row.status, config.status_indicators), Style::default().fg(status_color(row.status, palette)), ); let rows = if row.rows.is_empty() { vec![vec![crate::ui::ResolvedToken { kind: crate::ui::ResolvedTokenKind::StateIcon, style: Default::default(), }]] } else { row.rows.clone() }; for (index, tokens) in rows.iter().take(rect.height as usize).enumerate() { let indent = if index == 0 { 1 } else { 3 }; let mut spans = vec![ratatui::text::Span::raw(" ".repeat(indent))]; spans.extend(crate::ui::resolved_token_spans( tokens, icon, status_style, name_style, secondary, secondary, palette, rect.width.saturating_sub(indent as u16) as usize, )); Paragraph::new(Line::from(spans)).style(row_style).render( Rect::new(rect.x, rect.y + index as u16, rect.width, 1), buffer, ); } } fn put_text(buffer: &mut Buffer, x: u16, y: u16, width: u16, text: &str, style: Style) { for (offset, character) in text.chars().take(width as usize).enumerate() { if let Some(cell) = buffer.cell_mut((x + offset as u16, y)) { cell.set_char(character).set_style(style); } } } fn display_width(text: &str) -> usize { unicode_width::UnicodeWidthStr::width(text) } fn sidebar_status_text(status: crate::api::schema::AgentStatus) -> &'static str { use crate::api::schema::AgentStatus; match status { AgentStatus::Blocked => "blocked", AgentStatus::Done => "done", AgentStatus::Working => "working", AgentStatus::Idle | AgentStatus::Unknown => "idle", } }