mermaid-cli 0.7.1

Open-source AI pair programmer with agentic capabilities. Local-first with Ollama, native tool calling, and beautiful TUI.
Documentation
use ratatui::{
    buffer::Buffer,
    layout::Rect,
    style::Style,
    text::{Line, Span},
    widgets::{Paragraph, Widget},
};
use unicode_width::UnicodeWidthStr;

use crate::domain::{ContextUsageSnapshot, TokenUsageTotals};
use crate::models::{ReasoningLevel, TokenUsageSource};
use crate::render::theme::Theme;

/// Props for StatusWidget (stateless widget)
pub struct StatusWidget<'a> {
    pub theme: &'a Theme,
    pub working_dir: &'a str,
    pub context_usage: Option<&'a ContextUsageSnapshot>,
    pub last_usage: Option<TokenUsageTotals>,
    pub session_usage: TokenUsageTotals,
    pub model_name: &'a str,
    /// Effective reasoning depth — what the API actually saw after
    /// `nearest_effort` snapping against the model's capabilities. Always
    /// rendered on line 2 left.
    pub reasoning_level: ReasoningLevel,
    /// User-requested level when it differs from `reasoning_level` (the
    /// snap case). `Some(requested)` shows `reasoning: high (max
    /// requested)`; `None` shows just `reasoning: high`.
    pub requested_level: Option<ReasoningLevel>,
}

impl<'a> Widget for StatusWidget<'a> {
    fn render(self, area: Rect, buf: &mut Buffer) {
        // Get hostname and username for directory display
        let hostname = std::env::var("HOSTNAME")
            .or_else(|_| std::env::var("HOST"))
            .unwrap_or_else(|_| "localhost".to_string());
        let username = std::env::var("USER")
            .or_else(|_| std::env::var("USERNAME"))
            .unwrap_or_else(|_| "user".to_string());

        // Line 1: username@hostname:/path (left) | token usage (right, fixed position)
        let directory_text = format!("{}@{}:{}", username, hostname, self.working_dir);
        let token_text =
            format_token_status(self.context_usage, self.last_usage, self.session_usage);

        // Calculate padding to push tokens to right edge. Use display-cell
        // widths so CJK / emoji chars in working_dir or hostname don't
        // misalign the right-anchored token count.
        let available_width = area.width as usize;
        let directory_width = directory_text.width();
        let token_width = token_text.width();
        let padding_width = if available_width > directory_width + token_width + 1 {
            available_width - directory_width - token_width
        } else {
            1
        };

        let line1_spans = vec![
            // Directory (fixed to left)
            Span::styled(
                format!("{}@{}", username, hostname),
                Style::new().fg(ratatui::style::Color::Green).bold(),
            ),
            Span::styled(
                ":",
                Style::new().fg(self.theme.colors.text_primary.to_color()),
            ),
            Span::styled(
                self.working_dir,
                Style::new().fg(ratatui::style::Color::Cyan),
            ),
            // Padding
            Span::raw(" ".repeat(padding_width)),
            // Token count (fixed to right)
            Span::styled(
                token_text,
                Style::new().fg(self.theme.colors.text_disabled.to_color()),
            ),
        ];

        // Line 2: "reasoning: <level>" (or "<level> (<requested> requested)"
        // when the user's requested level got snapped to a lower one by
        // the model's capability ceiling) | model name (right).
        let reasoning_text = match self.requested_level {
            Some(requested) => format!(
                "reasoning: {} ({} requested)",
                self.reasoning_level.as_str(),
                requested.as_str()
            ),
            None => format!("reasoning: {}", self.reasoning_level.as_str()),
        };
        let model_display = self.model_name;

        // Calculate padding between reasoning text and model name (display-cell widths).
        let left_content_width = reasoning_text.width();
        let right_content_width = model_display.width();
        let padding_width_line2 = if available_width > left_content_width + right_content_width {
            available_width - left_content_width - right_content_width
        } else {
            1
        };

        let line2_spans = vec![
            // "reasoning: <level>" text (left, gray, always rendered)
            Span::styled(
                reasoning_text,
                Style::new().fg(self.theme.colors.text_disabled.to_color()),
            ),
            // Padding to right-align model name
            Span::raw(" ".repeat(padding_width_line2)),
            // Model name (right, aligned with tokens above)
            Span::styled(
                model_display,
                Style::new().fg(self.theme.colors.text_disabled.to_color()),
            ),
        ];

        let line1 = Line::from(line1_spans);
        let line2 = Line::from(line2_spans);
        let status_bar = Paragraph::new(vec![line1, line2]);

        status_bar.render(area, buf);
    }
}

pub(crate) fn format_token_status(
    context_usage: Option<&ContextUsageSnapshot>,
    last_usage: Option<TokenUsageTotals>,
    session_usage: TokenUsageTotals,
) -> String {
    let session = format_compact_count(session_usage.total_tokens);
    let context = match context_usage {
        Some(snapshot) => format_context_snapshot(snapshot),
        None => "context: n/a".to_string(),
    };
    match last_usage {
        Some(usage) => format!(
            "{} | last api: {} | session: {}",
            context,
            format_compact_count(usage.total_tokens),
            session
        ),
        None => format!("{} | session: {}", context, session),
    }
}

fn format_context_snapshot(snapshot: &ContextUsageSnapshot) -> String {
    let used = format_compact_count(snapshot.used_tokens);
    let source = match snapshot.source {
        TokenUsageSource::Provider => "",
        TokenUsageSource::Estimate => "~",
    };
    match (snapshot.max_tokens, snapshot.used_percent) {
        (Some(max), Some(percent)) => format!(
            "context: {}{} / {} ({}%)",
            source,
            used,
            format_compact_count(max),
            percent
        ),
        _ => format!("context: {}{} / unknown", source, used),
    }
}

fn format_compact_count(value: usize) -> String {
    if value >= 1_000_000 {
        format_scaled(value, 1_000_000, "m")
    } else if value >= 10_000 {
        format_scaled(value, 1_000, "k")
    } else {
        value.to_string()
    }
}

fn format_scaled(value: usize, divisor: usize, suffix: &str) -> String {
    let whole = value / divisor;
    let decimal = ((value % divisor) * 10) / divisor;
    if decimal == 0 {
        format!("{}{}", whole, suffix)
    } else {
        format!("{}.{}{}", whole, decimal, suffix)
    }
}

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

    #[test]
    fn token_status_labels_last_and_session_usage() {
        let context = ContextUsageSnapshot::from_usage(
            &crate::models::TokenUsage::provider(12_000, 456, 12_456),
            Some(128_000),
        );
        assert_eq!(
            format_token_status(
                Some(&context),
                Some(TokenUsageTotals {
                    prompt_tokens: 12_000,
                    completion_tokens: 456,
                    total_tokens: 12_456,
                    ..TokenUsageTotals::default()
                }),
                TokenUsageTotals {
                    prompt_tokens: 500_000,
                    completion_tokens: 73_443,
                    total_tokens: 573_443,
                    ..TokenUsageTotals::default()
                },
            ),
            "context: 12.4k / 128k (9%) | last api: 12.4k | session: 573.4k"
        );
    }

    #[test]
    fn token_status_handles_missing_last_usage() {
        assert_eq!(
            format_token_status(
                None,
                None,
                TokenUsageTotals {
                    prompt_tokens: 900,
                    completion_tokens: 50,
                    total_tokens: 950,
                    ..TokenUsageTotals::default()
                },
            ),
            "context: n/a | session: 950"
        );
    }

    #[test]
    fn token_status_marks_estimates() {
        let context = ContextUsageSnapshot::from_estimate(
            crate::domain::PromptTokenBreakdown {
                system_tokens: 10,
                instructions_tokens: 0,
                message_tokens: 20,
                tool_schema_tokens: 70,
                image_count: 0,
                message_count: 1,
                tool_count: 4,
            },
            None,
        );

        assert_eq!(
            format_token_status(Some(&context), None, TokenUsageTotals::default()),
            "context: ~100 / unknown | session: 0"
        );
    }
}