collet 0.1.1

Relentless agentic coding orchestrator with zero-drop agent loops
Documentation
use ratatui::prelude::*;
use ratatui::widgets::Paragraph;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};

use crate::tui::state::UiState;

/// Build a token budget bar like: `████████░░░░░░░░ 42%`
fn token_budget_bar(used: usize, max: usize, width: usize) -> String {
    if max == 0 {
        return format!("{} 0%", "".repeat(width));
    }
    let pct = ((used as f64 / max as f64) * 100.0).min(100.0);
    let filled = ((pct / 100.0) * width as f64).round() as usize;
    let empty = width.saturating_sub(filled);
    format!("{}{} {:.0}%", "".repeat(filled), "".repeat(empty), pct)
}

pub fn render(state: &UiState, area: Rect, buf: &mut Buffer) {
    if area.height >= 3 {
        render_two_line(state, area, buf);
    } else {
        render_single_line(state, area, buf);
    }
}

/// Rich two-line status bar.
fn render_two_line(state: &UiState, area: Rect, buf: &mut Buffer) {
    let theme = &state.theme;
    let top = Rect { height: 1, ..area };
    // Row 1 (y+1): thin divider — same color as sidebar border
    let mid = Rect {
        y: area.y + 1,
        height: 1,
        ..area
    };
    // Row 2 (y+2): ctx/stats line
    let bot = Rect {
        y: area.y + 2,
        height: 1,
        ..area
    };

    // ── Line 1: spinner + MODE badge + model + status message ──
    // Use the actual agent name (capitalized, max 10 chars) or TEAM:N badge.
    let mode_label_owned: String = if let Some(ref hive) = state.swarm_status {
        let agent_count = hive.agents.len();
        format!("{}:{agent_count}", hive.mode_label)
    } else {
        let raw_mode = state.agent_mode.as_str();
        let mut s = raw_mode[..raw_mode.len().min(10)].to_string();
        if let Some(c) = s.get_mut(0..1) {
            c.make_ascii_uppercase();
        }
        s
    };
    let mode_label = mode_label_owned.as_str();

    let spinner_w: usize = 4;
    let spinner_str = if state.agent_busy {
        let indicator = state.spinner.status_indicator(theme);
        let iw = indicator.chars().count();
        format!("{indicator}{}", " ".repeat(spinner_w.saturating_sub(iw)))
    } else {
        " ".repeat(spinner_w)
    };

    let (line1_bg, status_color) = if state.agent_busy {
        (theme.status_busy_bg, theme.status_busy_fg)
    } else if state.shell_mode {
        (theme.warning, theme.bg)
    } else {
        (theme.status_bg, theme.status_fg)
    };

    let base = Style::default().fg(status_color).bg(line1_bg);
    let mode_style = Style::default().fg(line1_bg).bg(status_color).bold();

    // Left: spinner + MODE + provider/model
    let model_str = format!("  {}/{}", state.provider_name, state.model_name);
    let left_spans: Vec<Span> = vec![
        Span::styled(spinner_str, base),
        Span::styled(format!(" {mode_label} "), mode_style),
        Span::styled(model_str, base),
    ];
    let left_w: usize = left_spans
        .iter()
        .map(|s| UnicodeWidthStr::width(s.content.as_ref()))
        .sum();

    // Right: status message (right-aligned, truncated if needed)
    // When attached to a worker, prepend the attach indicator
    let effective_status =
        if let crate::tui::state::ViewMode::WorkerAttached { ref agent_id } = state.view_mode {
            format!("[attached: {}] Esc to detach", agent_id)
        } else {
            state.status_msg.clone()
        };
    let max_right = (area.width as usize).saturating_sub(left_w + 1);
    let limit = max_right.saturating_sub(1);
    let mut msg = String::new();
    let mut msg_w = 0usize;
    for ch in effective_status.chars() {
        let cw = ch.width().unwrap_or(1);
        if msg_w + cw > limit {
            break;
        }
        msg.push(ch);
        msg_w += cw;
    }
    let right = format!("{msg} ");
    let right_w = UnicodeWidthStr::width(right.as_str());
    let gap = (area.width as usize).saturating_sub(left_w + right_w);

    let mut spans1 = left_spans;
    spans1.push(Span::styled(" ".repeat(gap), base));
    spans1.push(Span::styled(right, base.bold()));

    Paragraph::new(Line::from(spans1)).render(top, buf);

    // Row y+1: blank spacer between line1 and line2 (line1 bg acts as separator)
    let _ = mid;
    Paragraph::new(" ")
        .style(Style::default().bg(theme.status_line2_bg))
        .render(mid, buf);

    // ── Line 2: token budget + cache (left) · iter/time/tool/ckpt (right) ──
    let budget_bar = token_budget_bar(state.context_used_tokens, state.context_max_tokens, 12);

    let cache_str = if state.cache_hit_rate > 0.0 {
        format!(" · cache {:.0}%", state.cache_hit_rate * 100.0)
    } else {
        String::new()
    };

    // Stats moved from line 1 → right side of line 2
    let stats_right = format!(
        "iter:{}  {}s  t:{}{}{}",
        state.iteration,
        state.elapsed_secs,
        state.tool_log.len(),
        if state.checkpoint_count > 0 {
            format!("  ckpt:{}", state.checkpoint_count)
        } else {
            String::new()
        },
        if !state.project_name.is_empty() {
            format!("  📁 {}", state.project_name)
        } else {
            String::new()
        },
    );

    let style2 = Style::default()
        .fg(theme.status_line2_fg)
        .bg(theme.status_line2_bg);

    let left2 = format!("  ctx {}{}", budget_bar, cache_str);
    let left2_w = UnicodeWidthStr::width(left2.as_str());
    let right2_w = UnicodeWidthStr::width(stats_right.as_str());
    let gap2 = (area.width as usize).saturating_sub(left2_w + right2_w);

    let spans2 = vec![
        Span::styled(left2, style2),
        Span::styled(" ".repeat(gap2), style2),
        Span::styled(stats_right, style2),
    ];
    Paragraph::new(Line::from(spans2)).render(bot, buf);
}

/// Compact single-line fallback.
fn render_single_line(state: &UiState, area: Rect, buf: &mut Buffer) {
    let theme = &state.theme;

    let pct = if state.context_max_tokens > 0 {
        (state.context_used_tokens as f64 / state.context_max_tokens as f64 * 100.0).min(100.0)
    } else {
        0.0
    };

    let tokens = state.token_stats.total_tokens();
    let token_str = if tokens > 0 {
        format!(" · tok:{}k({:.0}%)", tokens / 1000, pct)
    } else {
        String::new()
    };

    let spinner_str = if state.agent_busy {
        format!("{} ", state.spinner.status_indicator(theme))
    } else {
        String::new()
    };

    let status = format!(
        " {}{} · iter:{} · {}s · t:{}{}  /help ",
        spinner_str,
        state.status_msg,
        state.iteration,
        state.elapsed_secs,
        state.tool_log.len(),
        token_str,
    );

    let style = if state.agent_busy {
        Style::default()
            .fg(theme.status_busy_fg)
            .bg(theme.status_busy_bg)
    } else if state.shell_mode {
        Style::default().fg(theme.bg).bg(theme.warning)
    } else {
        Style::default().fg(theme.status_fg).bg(theme.status_bg)
    };

    Paragraph::new(status).style(style).render(area, buf);
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_token_budget_bar_empty() {
        let bar = token_budget_bar(0, 120_000, 10);
        assert!(bar.contains("░░░░░░░░░░"));
        assert!(bar.contains("0%"));
    }

    #[test]
    fn test_token_budget_bar_half() {
        let bar = token_budget_bar(60_000, 120_000, 10);
        assert!(bar.contains("█████"));
        assert!(bar.contains("50%"));
    }

    #[test]
    fn test_token_budget_bar_full() {
        let bar = token_budget_bar(120_000, 120_000, 10);
        assert!(bar.contains("██████████"));
        assert!(bar.contains("100%"));
    }

    #[test]
    fn test_token_budget_bar_over() {
        let bar = token_budget_bar(200_000, 120_000, 10);
        assert!(bar.contains("100%"));
    }

    #[test]
    fn test_token_budget_bar_zero_max() {
        let bar = token_budget_bar(0, 0, 10);
        assert!(bar.contains("0%"));
    }
}