opencrabs 0.3.68

The autonomous, self-improving AI agent. Single Rust binary. Every channel. Install with: cargo install opencrabs
//! Plan Checklist Widget
//!
//! Renders a live-updating checklist of plan tasks above the input box.

use super::super::app::App;
use super::plan_window::{current_task_index, pick_visible_window};
use crate::tui::plan::TaskStatus;
use ratatui::{
    Frame,
    layout::Rect,
    style::{Color, Modifier, Style},
    text::{Line, Span},
    widgets::{Block, Borders, Paragraph},
};

/// Render the plan checklist panel.
pub(super) fn render_plan_checklist(f: &mut Frame, app: &App, area: Rect) {
    let plan = match app.plan_document.as_ref() {
        Some(p) => p,
        None => return,
    };

    if area.height == 0 {
        return;
    }

    // Seed window (Building checklist… machine, locked): an Active plan
    // with no tasks yet is an approved design whose checklist is still
    // being seeded. Show Building checklist… while the agent is working;
    // once idle, the seed failed and the strip carries the retry hint.
    if plan.tasks.is_empty() {
        let (text, style) = if app.is_processing {
            (
                "⏳ Building checklist…".to_string(),
                Style::default().fg(Color::Yellow),
            )
        } else {
            (
                "⚠️ Checklist seed incomplete. Retry with /execute, or /discard.".to_string(),
                Style::default().fg(Color::Red),
            )
        };
        let title = format!(" 📋 {} · Active ", plan.title);
        let para = Paragraph::new(Line::from(Span::styled(text, style)))
            .block(Block::default().borders(Borders::ALL).title(title));
        f.render_widget(para, area);
        return;
    }

    let total = plan.tasks.len();
    let completed = plan
        .tasks
        .iter()
        .filter(|t| matches!(t.status, TaskStatus::Completed | TaskStatus::Skipped))
        .count();

    let percent = (completed * 100).checked_div(total).unwrap_or(0);

    // Progress bar: 10 chars wide
    let filled = (completed * 10).checked_div(total).unwrap_or(0);
    let bar: String = "".repeat(filled) + &"".repeat(10 - filled);

    // Truncate title to fit header
    let max_title_len = area.width.saturating_sub(40) as usize;
    let title = if plan.title.len() > max_title_len && max_title_len > 3 {
        format!("{}", &plan.title[..max_title_len - 1])
    } else {
        plan.title.clone()
    };

    let header = Line::from(vec![
        Span::styled(
            format!(" Plan: {}  ·  {}/{}  ", title, completed, total),
            Style::default()
                .fg(Color::Rgb(160, 160, 160))
                .add_modifier(Modifier::BOLD),
        ),
        Span::styled(bar, Style::default().fg(Color::Rgb(80, 175, 175))),
        Span::styled(
            format!("  {}%", percent),
            Style::default().fg(Color::Rgb(160, 160, 160)),
        ),
    ]);

    // Dynamic visible count: area height minus header (1), top border (1).
    // For plans that fit we just show everything. For overflowing plans
    // we scroll a window centered on the current InProgress task and
    // emit a leading and/or trailing "… (N more)" indicator that
    // consumes a visible slot in each direction it applies.
    let chrome_lines: usize = 2; // header + top border
    let available = (area.height as usize).saturating_sub(chrome_lines);
    let current_idx = current_task_index(&plan.tasks);
    let window = pick_visible_window(total, available, current_idx);

    let visible: Vec<&crate::tui::plan::PlanTask> = plan
        .tasks
        .iter()
        .skip(window.start)
        .take(window.len)
        .collect();
    let hidden_before = window.start;
    let hidden_after = total.saturating_sub(window.start + window.len);

    let mut lines: Vec<Line> = vec![header];

    if hidden_before > 0 {
        lines.push(Line::from(Span::styled(
            format!("  … ({} above)", hidden_before),
            Style::default().fg(Color::DarkGray),
        )));
    }

    for task in &visible {
        let (icon, color) = match &task.status {
            TaskStatus::Completed => ("", Color::Rgb(60, 165, 165)),
            TaskStatus::Skipped => ("", Color::Rgb(60, 165, 165)),
            TaskStatus::InProgress => ("", Color::Rgb(215, 100, 20)),
            TaskStatus::Failed => ("", Color::Red),
            TaskStatus::Blocked(_) => ("·", Color::DarkGray),
            TaskStatus::Pending => ("·", Color::DarkGray),
        };

        // Truncate task title to 60 chars
        let task_title = if task.title.len() > 60 {
            format!("{}", task.title.chars().take(59).collect::<String>())
        } else {
            task.title.clone()
        };

        lines.push(Line::from(vec![
            Span::styled(
                format!("  {}  #{:<2}  ", icon, task.order),
                Style::default().fg(color),
            ),
            Span::styled(task_title, Style::default().fg(color)),
        ]));
    }

    if hidden_after > 0 {
        lines.push(Line::from(Span::styled(
            format!("  … ({} below)", hidden_after),
            Style::default().fg(Color::DarkGray),
        )));
    }

    let border_style = Style::default().fg(Color::Rgb(50, 50, 50));
    let block = Block::default()
        .borders(Borders::TOP)
        .border_style(border_style);

    let paragraph = Paragraph::new(lines).block(block);
    f.render_widget(paragraph, area);
}