From 4138053dd80f1d0130b01beb7ef945a93f8a1c89 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Sun, 26 Apr 2026 00:43:43 -0500 Subject: [PATCH] refactor(tui): extract sidebar rendering into tui/sidebar.rs (P1.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the four sidebar panels (Plan, Todos, Tasks, Agents) plus the shared `render_sidebar_section` wrapper out of `tui/ui.rs` into a new sibling module. `truncate_line_to_width` becomes `pub(crate)` so the new module can reuse it. Drops six imports from `ui.rs` that the sidebar took with it. `ui.rs`: 5450 → ~5070 lines. Workspace tests: 1011/1011 still green. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/tui/src/tui/mod.rs | 1 + crates/tui/src/tui/sidebar.rs | 393 ++++++++++++++++++++++++++++++++++ crates/tui/src/tui/ui.rs | 383 +-------------------------------- 3 files changed, 399 insertions(+), 378 deletions(-) create mode 100644 crates/tui/src/tui/sidebar.rs diff --git a/crates/tui/src/tui/mod.rs b/crates/tui/src/tui/mod.rs index ed41feb0..201da5dd 100644 --- a/crates/tui/src/tui/mod.rs +++ b/crates/tui/src/tui/mod.rs @@ -20,6 +20,7 @@ pub mod plan_prompt; pub mod scrolling; pub mod selection; pub mod session_picker; +pub mod sidebar; pub mod streaming; pub mod transcript; pub mod ui; diff --git a/crates/tui/src/tui/sidebar.rs b/crates/tui/src/tui/sidebar.rs new file mode 100644 index 00000000..a7bee4f9 --- /dev/null +++ b/crates/tui/src/tui/sidebar.rs @@ -0,0 +1,393 @@ +//! Sidebar rendering — Plan / Todos / Tasks / Agents panels. +//! +//! Extracted from `tui/ui.rs` (P1.2). The sidebar appears to the right of +//! the chat transcript when the available width allows it. Each section +//! reads from `App` snapshots; mutation lives in the main app loop. + +use std::fmt::Write; + +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout, Rect}, + style::{Style, Stylize}, + text::{Line, Span}, + widgets::{Block, Paragraph, Wrap}, +}; + +use crate::deepseek_theme::active_theme; +use crate::palette; +use crate::tools::plan::StepStatus; +use crate::tools::subagent::SubAgentStatus; +use crate::tools::todo::TodoStatus; + +use super::app::{App, SidebarFocus}; +use super::ui::truncate_line_to_width; + +pub fn render_sidebar(f: &mut Frame, area: Rect, app: &App) { + if area.width < 24 || area.height < 8 { + return; + } + + match app.sidebar_focus { + SidebarFocus::Auto => { + let sections = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage(25), + Constraint::Percentage(25), + Constraint::Percentage(25), + Constraint::Min(6), + ]) + .split(area); + + render_sidebar_plan(f, sections[0], app); + render_sidebar_todos(f, sections[1], app); + render_sidebar_tasks(f, sections[2], app); + render_sidebar_subagents(f, sections[3], app); + } + SidebarFocus::Plan => render_sidebar_plan(f, area, app), + SidebarFocus::Todos => render_sidebar_todos(f, area, app), + SidebarFocus::Tasks => render_sidebar_tasks(f, area, app), + SidebarFocus::Agents => render_sidebar_subagents(f, area, app), + } +} + +fn render_sidebar_plan(f: &mut Frame, area: Rect, app: &App) { + if area.height < 3 { + return; + } + + let theme = active_theme(); + let content_width = area.width.saturating_sub(4) as usize; + let mut lines: Vec> = Vec::with_capacity(usize::from(area.height).max(4)); + + match app.plan_state.try_lock() { + Ok(plan) => { + if plan.is_empty() { + lines.push(Line::from(Span::styled( + "No active plan", + Style::default().fg(theme.plan_summary_color), + ))); + } else { + let (pending, in_progress, completed) = plan.counts(); + let total = pending + in_progress + completed; + lines.push(Line::from(vec![ + Span::styled( + format!("{}%", plan.progress_percent()), + Style::default().fg(theme.plan_progress_color).bold(), + ), + Span::styled( + format!(" complete ({completed}/{total})"), + Style::default().fg(theme.plan_summary_color), + ), + ])); + + if let Some(explanation) = plan.explanation() { + lines.push(Line::from(Span::styled( + truncate_line_to_width(explanation, content_width.max(1)), + Style::default().fg(theme.plan_explanation_color), + ))); + } + + let usable_rows = area.height.saturating_sub(3) as usize; + let max_steps = usable_rows.saturating_sub(lines.len()); + for step in plan.steps().iter().take(max_steps) { + let (prefix, color) = match &step.status { + StepStatus::Pending => ("[ ]", theme.plan_pending_color), + StepStatus::InProgress => ("[~]", theme.plan_in_progress_color), + StepStatus::Completed => ("[x]", theme.plan_completed_color), + }; + let mut text = format!("{prefix} {}", step.text); + let elapsed = step.elapsed_str(); + if !elapsed.is_empty() { + let _ = write!(text, " ({elapsed})"); + } + lines.push(Line::from(Span::styled( + truncate_line_to_width(&text, content_width.max(1)), + Style::default().fg(color), + ))); + } + + let remaining = plan.steps().len().saturating_sub(max_steps); + if remaining > 0 { + lines.push(Line::from(Span::styled( + format!("+{remaining} more steps"), + Style::default().fg(theme.plan_summary_color), + ))); + } + } + } + Err(_) => { + lines.push(Line::from(Span::styled( + "Plan state updating...", + Style::default().fg(theme.plan_summary_color), + ))); + } + } + + render_sidebar_section(f, area, "Plan", lines); +} + +fn render_sidebar_todos(f: &mut Frame, area: Rect, app: &App) { + if area.height < 3 { + return; + } + + let content_width = area.width.saturating_sub(4) as usize; + let mut lines: Vec> = Vec::with_capacity(usize::from(area.height).max(4)); + + match app.todos.try_lock() { + Ok(todos) => { + let snapshot = todos.snapshot(); + if snapshot.items.is_empty() { + lines.push(Line::from(Span::styled( + "No todos", + Style::default().fg(palette::TEXT_MUTED), + ))); + } else { + let total = snapshot.items.len(); + let completed = snapshot + .items + .iter() + .filter(|item| item.status == TodoStatus::Completed) + .count(); + lines.push(Line::from(vec![ + Span::styled( + format!("{}%", snapshot.completion_pct), + Style::default().fg(palette::STATUS_SUCCESS).bold(), + ), + Span::styled( + format!(" complete ({completed}/{total})"), + Style::default().fg(palette::TEXT_MUTED), + ), + ])); + + let usable_rows = area.height.saturating_sub(3) as usize; + let max_items = usable_rows.saturating_sub(lines.len()); + for item in snapshot.items.iter().take(max_items) { + let (prefix, color) = match item.status { + TodoStatus::Pending => ("[ ]", palette::TEXT_MUTED), + TodoStatus::InProgress => ("[~]", palette::STATUS_WARNING), + TodoStatus::Completed => ("[x]", palette::STATUS_SUCCESS), + }; + let text = format!("{prefix} #{} {}", item.id, item.content); + lines.push(Line::from(Span::styled( + truncate_line_to_width(&text, content_width.max(1)), + Style::default().fg(color), + ))); + } + + let remaining = snapshot.items.len().saturating_sub(max_items); + if remaining > 0 { + lines.push(Line::from(Span::styled( + format!("+{remaining} more todos"), + Style::default().fg(palette::TEXT_MUTED), + ))); + } + } + } + Err(_) => { + lines.push(Line::from(Span::styled( + "Todo list updating...", + Style::default().fg(palette::TEXT_MUTED), + ))); + } + } + + render_sidebar_section(f, area, "Todos", lines); +} + +fn render_sidebar_tasks(f: &mut Frame, area: Rect, app: &App) { + if area.height < 3 { + return; + } + + let content_width = area.width.saturating_sub(4) as usize; + let mut lines: Vec> = Vec::with_capacity(usize::from(area.height).max(4)); + + if let Some(turn_id) = app.runtime_turn_id.as_ref() { + let status = app + .runtime_turn_status + .as_deref() + .unwrap_or("unknown") + .to_string(); + lines.push(Line::from(Span::styled( + truncate_line_to_width( + &format!("turn {} ({status})", truncate_line_to_width(turn_id, 12)), + content_width.max(1), + ), + Style::default().fg(palette::DEEPSEEK_SKY), + ))); + } + + if app.task_panel.is_empty() { + lines.push(Line::from(Span::styled( + "No tasks", + Style::default().fg(palette::TEXT_MUTED), + ))); + } else { + let running = app + .task_panel + .iter() + .filter(|task| task.status == "running") + .count(); + lines.push(Line::from(vec![ + Span::styled( + format!("{running} running"), + Style::default().fg(palette::DEEPSEEK_SKY).bold(), + ), + Span::styled( + format!(" / {}", app.task_panel.len()), + Style::default().fg(palette::TEXT_MUTED), + ), + ])); + + let usable_rows = area.height.saturating_sub(3) as usize; + let max_items = usable_rows.saturating_sub(lines.len()); + for task in app.task_panel.iter().take(max_items) { + let color = match task.status.as_str() { + "queued" => palette::TEXT_MUTED, + "running" => palette::STATUS_WARNING, + "completed" => palette::STATUS_SUCCESS, + "failed" => palette::STATUS_ERROR, + "canceled" => palette::TEXT_DIM, + _ => palette::TEXT_MUTED, + }; + let duration = task + .duration_ms + .map(|ms| format!("{:.1}s", ms as f64 / 1000.0)) + .unwrap_or_else(|| "-".to_string()); + let label = format!( + "{} {} {}", + truncate_line_to_width(&task.id, 10), + task.status, + duration + ); + lines.push(Line::from(Span::styled( + truncate_line_to_width(&label, content_width.max(1)), + Style::default().fg(color), + ))); + lines.push(Line::from(Span::styled( + format!( + " {}", + truncate_line_to_width( + &task.prompt_summary, + content_width.saturating_sub(2).max(1) + ) + ), + Style::default().fg(palette::TEXT_DIM), + ))); + } + } + + render_sidebar_section(f, area, "Tasks", lines); +} + +fn render_sidebar_subagents(f: &mut Frame, area: Rect, app: &App) { + if area.height < 3 { + return; + } + + let content_width = area.width.saturating_sub(4) as usize; + let mut lines: Vec> = Vec::with_capacity(usize::from(area.height).max(4)); + + if app.subagent_cache.is_empty() { + lines.push(Line::from(Span::styled( + "No agents", + Style::default().fg(palette::TEXT_MUTED), + ))); + } else { + let running = app + .subagent_cache + .iter() + .filter(|agent| matches!(agent.status, SubAgentStatus::Running)) + .count(); + let done = app.subagent_cache.len().saturating_sub(running); + // When agents have all finished, "0 running / 1" reads as broken. + // Switch to "1 done" once nothing is in flight; only show the + // running/total split while activity is live. + let header = if running > 0 { + vec![ + Span::styled( + format!("{running} running"), + Style::default().fg(palette::DEEPSEEK_SKY).bold(), + ), + Span::styled( + format!(" / {}", app.subagent_cache.len()), + Style::default().fg(palette::TEXT_MUTED), + ), + ] + } else { + vec![Span::styled( + format!("{done} done"), + Style::default().fg(palette::STATUS_SUCCESS), + )] + }; + lines.push(Line::from(header)); + + let usable_rows = area.height.saturating_sub(3) as usize; + let max_agents = usable_rows.saturating_sub(lines.len()); + for agent in app.subagent_cache.iter().take(max_agents) { + let (status_label, status_color) = match &agent.status { + SubAgentStatus::Running => ("running", palette::STATUS_WARNING), + SubAgentStatus::Completed => ("done", palette::STATUS_SUCCESS), + SubAgentStatus::Interrupted(_) => ("interrupted", palette::STATUS_WARNING), + SubAgentStatus::Failed(_) => ("failed", palette::STATUS_ERROR), + SubAgentStatus::Cancelled => ("cancelled", palette::TEXT_MUTED), + }; + let agent_type = agent.agent_type.as_str(); + let role = agent.assignment.role.as_deref().unwrap_or("default"); + let summary = format!( + "{} {agent_type}/{role} {status_label} ({} steps)", + truncate_line_to_width(&agent.agent_id, 10), + agent.steps_taken + ); + lines.push(Line::from(Span::styled( + truncate_line_to_width(&summary, content_width.max(1)), + Style::default().fg(status_color), + ))); + lines.push(Line::from(Span::styled( + format!( + " {}", + truncate_line_to_width( + &agent.assignment.objective, + content_width.saturating_sub(2).max(1) + ) + ), + Style::default().fg(palette::TEXT_DIM), + ))); + } + + let remaining = app.subagent_cache.len().saturating_sub(max_agents); + if remaining > 0 { + lines.push(Line::from(Span::styled( + format!("+{remaining} more agents"), + Style::default().fg(palette::TEXT_MUTED), + ))); + } + } + + render_sidebar_section(f, area, "Agents", lines); +} + +fn render_sidebar_section(f: &mut Frame, area: Rect, title: &str, lines: Vec>) { + if area.width < 4 || area.height < 3 { + return; + } + + let theme = active_theme(); + let section = Paragraph::new(lines).wrap(Wrap { trim: false }).block( + Block::default() + .title(Line::from(vec![Span::styled( + format!(" {title} "), + Style::default().fg(theme.section_title_color).bold(), + )])) + .borders(theme.section_borders) + .border_type(theme.section_border_type) + .border_style(Style::default().fg(theme.section_border_color)) + .style(Style::default().bg(theme.section_bg)) + .padding(theme.section_padding), + ); + + f.render_widget(section, area); +} diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 9665d791..2a9a1b1c 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -1,6 +1,5 @@ //! TUI event loop and rendering logic for `DeepSeek` CLI. -use std::fmt::Write; use std::io::{self, Stdout}; use std::path::{Path, PathBuf}; use std::process::Command; @@ -20,9 +19,9 @@ use ratatui::{ Frame, Terminal, backend::CrosstermBackend, layout::{Constraint, Direction, Layout, Rect}, - style::{Style, Stylize}, - text::{Line, Span}, - widgets::{Block, Paragraph, Wrap}, + style::Style, + text::Span, + widgets::Block, }; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; @@ -35,7 +34,6 @@ use crate::core::coherence::CoherenceState; use crate::core::engine::{EngineConfig, EngineHandle, spawn_engine}; use crate::core::events::Event as EngineEvent; use crate::core::ops::Op; -use crate::deepseek_theme::active_theme; use crate::hooks::HookEvent; use crate::models::{ContentBlock, Message, SystemPrompt, context_window_for_model}; use crate::palette; @@ -49,10 +47,8 @@ use crate::task_manager::{ TaskSummary, }; use crate::tools::ReviewOutput; -use crate::tools::plan::StepStatus; use crate::tools::spec::{ToolError, ToolResult}; use crate::tools::subagent::{SubAgentResult, SubAgentStatus}; -use crate::tools::todo::TodoStatus; use crate::tui::command_palette::{ CommandPaletteView, build_entries as build_command_palette_entries, }; @@ -2758,7 +2754,7 @@ fn render(f: &mut Frame, app: &mut App) { chat_widget.render(chat_area, buf); if let Some(sidebar_area) = sidebar_area { - render_sidebar(f, sidebar_area, app); + super::sidebar::render_sidebar(f, sidebar_area, app); } } @@ -2787,375 +2783,6 @@ fn render(f: &mut Frame, app: &mut App) { } } -fn render_sidebar(f: &mut Frame, area: Rect, app: &App) { - if area.width < 24 || area.height < 8 { - return; - } - - match app.sidebar_focus { - SidebarFocus::Auto => { - let sections = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Percentage(25), - Constraint::Percentage(25), - Constraint::Percentage(25), - Constraint::Min(6), - ]) - .split(area); - - render_sidebar_plan(f, sections[0], app); - render_sidebar_todos(f, sections[1], app); - render_sidebar_tasks(f, sections[2], app); - render_sidebar_subagents(f, sections[3], app); - } - SidebarFocus::Plan => render_sidebar_plan(f, area, app), - SidebarFocus::Todos => render_sidebar_todos(f, area, app), - SidebarFocus::Tasks => render_sidebar_tasks(f, area, app), - SidebarFocus::Agents => render_sidebar_subagents(f, area, app), - } -} - -fn render_sidebar_plan(f: &mut Frame, area: Rect, app: &App) { - if area.height < 3 { - return; - } - - let theme = active_theme(); - let content_width = area.width.saturating_sub(4) as usize; - let mut lines: Vec> = Vec::with_capacity(usize::from(area.height).max(4)); - - match app.plan_state.try_lock() { - Ok(plan) => { - if plan.is_empty() { - lines.push(Line::from(Span::styled( - "No active plan", - Style::default().fg(theme.plan_summary_color), - ))); - } else { - let (pending, in_progress, completed) = plan.counts(); - let total = pending + in_progress + completed; - lines.push(Line::from(vec![ - Span::styled( - format!("{}%", plan.progress_percent()), - Style::default().fg(theme.plan_progress_color).bold(), - ), - Span::styled( - format!(" complete ({completed}/{total})"), - Style::default().fg(theme.plan_summary_color), - ), - ])); - - if let Some(explanation) = plan.explanation() { - lines.push(Line::from(Span::styled( - truncate_line_to_width(explanation, content_width.max(1)), - Style::default().fg(theme.plan_explanation_color), - ))); - } - - let usable_rows = area.height.saturating_sub(3) as usize; - let max_steps = usable_rows.saturating_sub(lines.len()); - for step in plan.steps().iter().take(max_steps) { - let (prefix, color) = match &step.status { - StepStatus::Pending => ("[ ]", theme.plan_pending_color), - StepStatus::InProgress => ("[~]", theme.plan_in_progress_color), - StepStatus::Completed => ("[x]", theme.plan_completed_color), - }; - let mut text = format!("{prefix} {}", step.text); - let elapsed = step.elapsed_str(); - if !elapsed.is_empty() { - let _ = write!(text, " ({elapsed})"); - } - lines.push(Line::from(Span::styled( - truncate_line_to_width(&text, content_width.max(1)), - Style::default().fg(color), - ))); - } - - let remaining = plan.steps().len().saturating_sub(max_steps); - if remaining > 0 { - lines.push(Line::from(Span::styled( - format!("+{remaining} more steps"), - Style::default().fg(theme.plan_summary_color), - ))); - } - } - } - Err(_) => { - lines.push(Line::from(Span::styled( - "Plan state updating...", - Style::default().fg(theme.plan_summary_color), - ))); - } - } - - render_sidebar_section(f, area, "Plan", lines); -} - -fn render_sidebar_todos(f: &mut Frame, area: Rect, app: &App) { - if area.height < 3 { - return; - } - - let content_width = area.width.saturating_sub(4) as usize; - let mut lines: Vec> = Vec::with_capacity(usize::from(area.height).max(4)); - - match app.todos.try_lock() { - Ok(todos) => { - let snapshot = todos.snapshot(); - if snapshot.items.is_empty() { - lines.push(Line::from(Span::styled( - "No todos", - Style::default().fg(palette::TEXT_MUTED), - ))); - } else { - let total = snapshot.items.len(); - let completed = snapshot - .items - .iter() - .filter(|item| item.status == TodoStatus::Completed) - .count(); - lines.push(Line::from(vec![ - Span::styled( - format!("{}%", snapshot.completion_pct), - Style::default().fg(palette::STATUS_SUCCESS).bold(), - ), - Span::styled( - format!(" complete ({completed}/{total})"), - Style::default().fg(palette::TEXT_MUTED), - ), - ])); - - let usable_rows = area.height.saturating_sub(3) as usize; - let max_items = usable_rows.saturating_sub(lines.len()); - for item in snapshot.items.iter().take(max_items) { - let (prefix, color) = match item.status { - TodoStatus::Pending => ("[ ]", palette::TEXT_MUTED), - TodoStatus::InProgress => ("[~]", palette::STATUS_WARNING), - TodoStatus::Completed => ("[x]", palette::STATUS_SUCCESS), - }; - let text = format!("{prefix} #{} {}", item.id, item.content); - lines.push(Line::from(Span::styled( - truncate_line_to_width(&text, content_width.max(1)), - Style::default().fg(color), - ))); - } - - let remaining = snapshot.items.len().saturating_sub(max_items); - if remaining > 0 { - lines.push(Line::from(Span::styled( - format!("+{remaining} more todos"), - Style::default().fg(palette::TEXT_MUTED), - ))); - } - } - } - Err(_) => { - lines.push(Line::from(Span::styled( - "Todo list updating...", - Style::default().fg(palette::TEXT_MUTED), - ))); - } - } - - render_sidebar_section(f, area, "Todos", lines); -} - -fn render_sidebar_tasks(f: &mut Frame, area: Rect, app: &App) { - if area.height < 3 { - return; - } - - let content_width = area.width.saturating_sub(4) as usize; - let mut lines: Vec> = Vec::with_capacity(usize::from(area.height).max(4)); - - if let Some(turn_id) = app.runtime_turn_id.as_ref() { - let status = app - .runtime_turn_status - .as_deref() - .unwrap_or("unknown") - .to_string(); - lines.push(Line::from(Span::styled( - truncate_line_to_width( - &format!("turn {} ({status})", truncate_line_to_width(turn_id, 12)), - content_width.max(1), - ), - Style::default().fg(palette::DEEPSEEK_SKY), - ))); - } - - if app.task_panel.is_empty() { - lines.push(Line::from(Span::styled( - "No tasks", - Style::default().fg(palette::TEXT_MUTED), - ))); - } else { - let running = app - .task_panel - .iter() - .filter(|task| task.status == "running") - .count(); - lines.push(Line::from(vec![ - Span::styled( - format!("{running} running"), - Style::default().fg(palette::DEEPSEEK_SKY).bold(), - ), - Span::styled( - format!(" / {}", app.task_panel.len()), - Style::default().fg(palette::TEXT_MUTED), - ), - ])); - - let usable_rows = area.height.saturating_sub(3) as usize; - let max_items = usable_rows.saturating_sub(lines.len()); - for task in app.task_panel.iter().take(max_items) { - let color = match task.status.as_str() { - "queued" => palette::TEXT_MUTED, - "running" => palette::STATUS_WARNING, - "completed" => palette::STATUS_SUCCESS, - "failed" => palette::STATUS_ERROR, - "canceled" => palette::TEXT_DIM, - _ => palette::TEXT_MUTED, - }; - let duration = task - .duration_ms - .map(|ms| format!("{:.1}s", ms as f64 / 1000.0)) - .unwrap_or_else(|| "-".to_string()); - let label = format!( - "{} {} {}", - truncate_line_to_width(&task.id, 10), - task.status, - duration - ); - lines.push(Line::from(Span::styled( - truncate_line_to_width(&label, content_width.max(1)), - Style::default().fg(color), - ))); - lines.push(Line::from(Span::styled( - format!( - " {}", - truncate_line_to_width( - &task.prompt_summary, - content_width.saturating_sub(2).max(1) - ) - ), - Style::default().fg(palette::TEXT_DIM), - ))); - } - } - - render_sidebar_section(f, area, "Tasks", lines); -} - -fn render_sidebar_subagents(f: &mut Frame, area: Rect, app: &App) { - if area.height < 3 { - return; - } - - let content_width = area.width.saturating_sub(4) as usize; - let mut lines: Vec> = Vec::with_capacity(usize::from(area.height).max(4)); - - if app.subagent_cache.is_empty() { - lines.push(Line::from(Span::styled( - "No agents", - Style::default().fg(palette::TEXT_MUTED), - ))); - } else { - let running = app - .subagent_cache - .iter() - .filter(|agent| matches!(agent.status, SubAgentStatus::Running)) - .count(); - let done = app.subagent_cache.len().saturating_sub(running); - // When agents have all finished, "0 running / 1" reads as broken. - // Switch to "1 done" once nothing is in flight; only show the - // running/total split while activity is live. - let header = if running > 0 { - vec![ - Span::styled( - format!("{running} running"), - Style::default().fg(palette::DEEPSEEK_SKY).bold(), - ), - Span::styled( - format!(" / {}", app.subagent_cache.len()), - Style::default().fg(palette::TEXT_MUTED), - ), - ] - } else { - vec![Span::styled( - format!("{done} done"), - Style::default().fg(palette::STATUS_SUCCESS), - )] - }; - lines.push(Line::from(header)); - - let usable_rows = area.height.saturating_sub(3) as usize; - let max_agents = usable_rows.saturating_sub(lines.len()); - for agent in app.subagent_cache.iter().take(max_agents) { - let (status_label, status_color) = match &agent.status { - SubAgentStatus::Running => ("running", palette::STATUS_WARNING), - SubAgentStatus::Completed => ("done", palette::STATUS_SUCCESS), - SubAgentStatus::Interrupted(_) => ("interrupted", palette::STATUS_WARNING), - SubAgentStatus::Failed(_) => ("failed", palette::STATUS_ERROR), - SubAgentStatus::Cancelled => ("cancelled", palette::TEXT_MUTED), - }; - let agent_type = agent.agent_type.as_str(); - let role = agent.assignment.role.as_deref().unwrap_or("default"); - let summary = format!( - "{} {agent_type}/{role} {status_label} ({} steps)", - truncate_line_to_width(&agent.agent_id, 10), - agent.steps_taken - ); - lines.push(Line::from(Span::styled( - truncate_line_to_width(&summary, content_width.max(1)), - Style::default().fg(status_color), - ))); - lines.push(Line::from(Span::styled( - format!( - " {}", - truncate_line_to_width( - &agent.assignment.objective, - content_width.saturating_sub(2).max(1) - ) - ), - Style::default().fg(palette::TEXT_DIM), - ))); - } - - let remaining = app.subagent_cache.len().saturating_sub(max_agents); - if remaining > 0 { - lines.push(Line::from(Span::styled( - format!("+{remaining} more agents"), - Style::default().fg(palette::TEXT_MUTED), - ))); - } - } - - render_sidebar_section(f, area, "Agents", lines); -} - -fn render_sidebar_section(f: &mut Frame, area: Rect, title: &str, lines: Vec>) { - if area.width < 4 || area.height < 3 { - return; - } - - let theme = active_theme(); - let section = Paragraph::new(lines).wrap(Wrap { trim: false }).block( - Block::default() - .title(Line::from(vec![Span::styled( - format!(" {title} "), - Style::default().fg(theme.section_title_color).bold(), - )])) - .borders(theme.section_borders) - .border_type(theme.section_border_type) - .border_style(Style::default().fg(theme.section_border_color)) - .style(Style::default().bg(theme.section_bg)) - .padding(theme.section_padding), - ); - - f.render_widget(section, area); -} - async fn handle_view_events( app: &mut App, config: &mut Config, @@ -4065,7 +3692,7 @@ fn history_has_live_motion(history: &[HistoryCell]) -> bool { }) } -fn truncate_line_to_width(text: &str, max_width: usize) -> String { +pub(crate) fn truncate_line_to_width(text: &str, max_width: usize) -> String { if max_width == 0 { return String::new(); }