refactor(tui): extract sidebar rendering into tui/sidebar.rs (P1.2)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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<Line<'static>> = 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<Line<'static>> = 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<Line<'static>> = 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<Line<'static>> = 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<Line<'static>>) {
|
||||
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);
|
||||
}
|
||||
+5
-378
@@ -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<Line<'static>> = 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<Line<'static>> = 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<Line<'static>> = 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<Line<'static>> = 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<Line<'static>>) {
|
||||
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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user