ai-dispatch 10.29.0

Multi-AI CLI team orchestrator
// Task tree view: hierarchical list of tasks and workgroups with status lines.
// Exports render_tree_view; uses tree_duration for per-row duration labels.
// Depends on ratatui, app, tree_data, cost, types, and parent status_to_color.

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,
    );
    // We can't mutate app here (render takes &App), so tree_node_count
    // is updated in tick(). Use nodes.len() for bounds checking.
    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 {
                    // Task line
                    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 };
                    // Success must read as success. Dimming a finished row harder
                    // than a running one made the whole line grey, so a completed
                    // task looked spent rather than good — the green id and route
                    // were not enough to carry it.
                    let dim = Color::Indexed(248);

                    // Tree rows are dense: 22 chars keeps route visible without
                    // crowding status/duration. Truncation drops provider first.
                    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}");
        // inferred attribution is part of the model segment when width allows
        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}");
    }
}