use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::prelude::{Alignment, Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph};
use crate::tui::app::App;
use super::status_to_color;
use super::ui_helpers::{task_status_label, truncate};
use crate::cost;
use crate::tui::route_display::format_route_fit;
use crate::tui::tree_data;
use crate::types::{Task, TaskStatus};
pub(super) fn render_tree_view(frame: &mut ratatui::Frame<'_>, app: &App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Min(1),
Constraint::Length(1),
])
.split(frame.area());
let done = app.tasks.iter().filter(|t| t.status.is_terminal()).count();
let running = app.tasks.iter().filter(|t| matches!(t.status, TaskStatus::Running)).count();
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled("aid tree ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
Span::styled(format!("[{}]", app.scope_label()), Style::default().fg(Color::Indexed(250))),
Span::raw(" "),
Span::styled(format!("{done}✓ "), Style::default().fg(Color::Green)),
Span::styled(format!("{running}▶"), Style::default().fg(Color::Yellow)),
]))
.alignment(Alignment::Center),
chunks[0],
);
let nodes = tree_data::build_task_tree_with_state(
&app.tasks,
&app.wg_creators,
&app.collapsed_projects,
);
if nodes.is_empty() {
frame.render_widget(Paragraph::new(app.empty_message()), chunks[1]);
} else {
let items: Vec<ListItem> = nodes
.iter()
.enumerate()
.map(|(i, node)| {
let task = &node.task;
let status_color = status_to_color(task.status);
let is_selected = i == app.tree_selected;
if node.is_group_header {
let header = fit_group_header(&node.prefix, chunks[1].width as usize);
let item = ListItem::new(Span::styled(
header,
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
));
if is_selected {
item.style(Style::default().bg(Color::Indexed(237)).add_modifier(Modifier::BOLD))
} else {
item
}
} else {
let duration = tree_duration(task);
let milestone = app.get_milestone(task.id.as_str()).map(|m| truncate(m, 25));
let cost_str = cost::format_cost_label(task.cost_usd, task.agent);
let prompt_width = if milestone.is_some() { 25 } else { 40 };
let prompt_preview = truncate(&task.prompt, prompt_width);
let is_done = matches!(task.status, TaskStatus::Done | TaskStatus::Merged);
let id_color = if is_done { Color::Green } else { Color::White };
let dim = Color::Indexed(248);
let route = format_route_fit(task, 22);
let mut spans = vec![
Span::styled(node.prefix.clone(), Style::default().fg(Color::Indexed(240))),
Span::styled(task.id.as_str(), Style::default().fg(id_color).add_modifier(Modifier::BOLD)),
Span::raw(" "),
Span::styled(route, Style::default().fg(if is_done { Color::Green } else { Color::Cyan })),
Span::raw(" "),
Span::styled(task_status_label(task), Style::default().fg(status_color)),
Span::raw(" "),
Span::styled(duration, Style::default().fg(dim)),
];
if matches!(task.status, TaskStatus::Running | TaskStatus::AwaitingInput) {
spans.push(Span::raw(" "));
spans.push(Span::styled(
truncate(&app.task_activity(task), 42),
Style::default().fg(if task.status == TaskStatus::AwaitingInput {
Color::Magenta
} else {
Color::Indexed(245)
}),
));
}
if cost_str != "—" && cost_str != "-" {
spans.push(Span::raw(" "));
spans.push(Span::styled(
cost_str,
Style::default().fg(if is_done { Color::Green } else { Color::Indexed(243) }),
));
}
if let Some(ms) = milestone {
spans.push(Span::raw(" "));
spans.push(Span::styled(ms, Style::default().fg(Color::Green)));
} else {
spans.push(Span::raw(" "));
spans.push(Span::styled(prompt_preview, Style::default().fg(Color::Indexed(245))));
}
let item = ListItem::new(Line::from(spans));
if is_selected {
item.style(Style::default().bg(Color::Indexed(237)).add_modifier(Modifier::BOLD))
} else {
item
}
}
})
.collect();
frame.render_widget(
List::new(items).block(Block::default().title("Task Tree").borders(Borders::ALL)),
chunks[1],
);
}
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(" j/k", Style::default().fg(Color::Yellow)),
Span::raw(":tasks "),
Span::styled("h/l", Style::default().fg(Color::Yellow)),
Span::raw(":groups "),
Span::styled("Space", Style::default().fg(Color::Yellow)),
Span::raw(":collapse "),
Span::styled("/", Style::default().fg(Color::Yellow)),
Span::raw(":find "),
Span::styled("Enter", Style::default().fg(Color::Yellow)),
Span::raw(":detail "),
Span::styled("t", Style::default().fg(Color::Yellow)),
Span::raw(":table "),
Span::styled("d", Style::default().fg(Color::Yellow)),
Span::raw(":dashboard "),
Span::styled("m", Style::default().fg(Color::Yellow)),
Span::raw(":multipane "),
Span::styled("q", Style::default().fg(Color::Yellow)),
Span::raw(":quit"),
])),
chunks[2],
);
}
fn fit_group_header(prefix: &str, width: usize) -> String {
let value = prefix.trim_end();
let Some((label, count)) = value.rsplit_once(" (") else {
return truncate(value, width);
};
let suffix = format!(" ({count}");
let label_width = width.saturating_sub(suffix.chars().count());
let visible_label: String = label.chars().take(label_width).collect();
format!("{visible_label}{suffix}")
}
fn tree_duration(task: &Task) -> String {
if let Some(ms) = task.duration_ms {
let secs = ms / 1000;
if secs < 60 { format!("{secs}s") }
else if secs < 3600 { format!("{}m{:02}s", secs / 60, secs % 60) }
else { format!("{}h{:02}m", secs / 3600, (secs % 3600) / 60) }
} else if matches!(
task.status,
TaskStatus::Running | TaskStatus::AwaitingInput | TaskStatus::Stalled
) {
let elapsed = (chrono::Local::now() - task.created_at).num_seconds();
if elapsed < 60 { format!("{elapsed}s") }
else if elapsed < 3600 { format!("{}m{:02}s", elapsed / 60, elapsed % 60) }
else { format!("{}h{:02}m", elapsed / 3600, (elapsed % 3600) / 60) }
} else {
"-".to_string()
}
}
#[cfg(test)]
mod tests {
use super::{fit_group_header, tree_duration};
use crate::types::{AgentKind, Task, TaskId, TaskStatus, VerifyStatus};
use chrono::Local;
fn base_task() -> Task {
Task {
id: TaskId("t-tree".to_string()),
agent: AgentKind::Codex,
custom_agent_name: None,
prompt: "p".to_string(),
resolved_prompt: None,
category: None,
status: TaskStatus::Done,
parent_task_id: None,
workgroup_id: None,
caller_kind: None,
caller_session_id: None,
agent_session_id: None,
repo_path: None, project_id: crate::project::current_project_id(),
worktree_path: None, effective_dir: None,
worktree_branch: None,
final_head_sha: None,
final_branch: None,
start_sha: None,
log_path: None,
output_path: None,
tokens: None,
prompt_tokens: None,
duration_ms: None,
requested_model: None,
observed_model: None,
attribution_source: None,
cost_usd: None,
exit_code: None,
created_at: Local::now(),
completed_at: None,
verify: None,
verify_status: VerifyStatus::Skipped,
pending_reason: None,
read_only: false,
budget: false,
audit_verdict: None,
audit_report_path: None,
delivery_assessment: None,
}
}
#[test]
fn tree_route_fit_uses_provider_not_just_cli() {
use crate::tui::route_display::format_route_fit;
use crate::types::AttributionSource;
let mut t = base_task();
t.requested_model = Some("gpt-5.6".to_string());
t.observed_model = Some("gpt-5.6".to_string());
t.attribution_source = Some(AttributionSource::ConfirmedBySuccess);
let shown = format_route_fit(&t, 22);
assert!(shown.starts_with("codex/"), "{shown}");
assert!(shown.contains("openai") || shown.contains("..."), "{shown}");
let wide = format_route_fit(&t, 80);
assert!(wide.contains("(inferred)"), "{wide}");
}
#[test]
fn tree_duration_formats_completed_seconds() {
let mut t = base_task();
t.duration_ms = Some(45_000);
assert_eq!(tree_duration(&t), "45s");
}
#[test]
fn tree_duration_formats_completed_minutes() {
let mut t = base_task();
t.duration_ms = Some(125_000);
assert_eq!(tree_duration(&t), "2m05s");
}
#[test]
fn tree_duration_non_running_without_duration_is_dash() {
let mut t = base_task();
t.status = TaskStatus::Done;
t.duration_ms = None;
assert_eq!(tree_duration(&t), "-");
}
#[test]
fn group_header_fit_preserves_count_after_long_project_label() {
let shown = fit_group_header("▸ project-with-a-long-id (1/42)", 12);
assert!(shown.contains("(1/42)"), "{shown}");
assert!(shown.chars().count() <= 12, "{shown}");
}
}