claude-dashboard 0.2.2

A terminal TUI dashboard that renders Claude Code usage reports with token stats, project breakdowns, and language distribution.
Documentation
use ratatui::{
    style::{Color, Modifier, Style, Stylize},
    text::{Line, Span},
};

/// Parse Markdown text into styled ratatui Lines.
pub fn render_markdown(text: &str) -> Vec<Line<'static>> {
    let mut lines: Vec<Line<'static>> = Vec::new();
    let mut in_code_block = false;
    let mut table_buffer: Vec<Vec<Vec<Span<'static>>>> = Vec::new();

    for raw_line in text.lines() {
        // Fenced code block toggle
        if raw_line.trim_start().starts_with("```") {
            if !table_buffer.is_empty() {
                flush_markdown_table(&mut lines, &mut table_buffer);
            }
            in_code_block = !in_code_block;
            if in_code_block {
                lines.push(Line::from(Span::styled(
                    "\u{2500}".repeat(40),
                    Style::default().fg(Color::DarkGray),
                )));
            } else {
                lines.push(Line::from(Span::styled(
                    "\u{2500}".repeat(40),
                    Style::default().fg(Color::DarkGray),
                )));
                lines.push(Line::from(""));
            }
            continue;
        }

        if in_code_block {
            lines.push(Line::from(Span::styled(
                format!("  {}", raw_line),
                Style::default().fg(Color::Cyan).bg(Color::Reset),
            )));
            continue;
        }

        let trimmed = raw_line.trim_start();

        // Table rows - buffer them for proper rendering
        if trimmed.starts_with('|') {
            let cells: Vec<&str> = trimmed
                .trim_matches('|')
                .split('|')
                .map(|s| s.trim())
                .collect();
            // Skip separator rows
            if cells.iter().all(|c| c.chars().all(|ch| ch == '-' || ch == ':' || ch == ' ')) {
                continue;
            }
            let cell_spans: Vec<Vec<Span<'static>>> = cells
                .iter()
                .map(|cell| render_inline_spans(cell))
                .collect();
            table_buffer.push(cell_spans);
            continue;
        }

        // Flush any pending table
        if !table_buffer.is_empty() {
            flush_markdown_table(&mut lines, &mut table_buffer);
        }

        // Headers
        if let Some(rest) = trimmed.strip_prefix("### ") {
            lines.push(Line::from(""));
            lines.push(Line::from(Span::styled(
                format!("  {}", rest.to_string()),
                Style::default().fg(Color::Cyan).bold(),
            )));
            lines.push(Line::from(""));
            continue;
        }
        if let Some(rest) = trimmed.strip_prefix("## ") {
            lines.push(Line::from(""));
            lines.push(Line::from(Span::styled(
                format!("\u{258e} {}", rest.to_string()),
                Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
            )));
            lines.push(Line::from(""));
            continue;
        }
        if let Some(rest) = trimmed.strip_prefix("# ") {
            lines.push(Line::from(""));
            lines.push(Line::from(Span::styled(
                rest.to_string(),
                Style::default().fg(Color::Green).add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
            )));
            lines.push(Line::from(""));
            continue;
        }

        // Blockquote
        if let Some(rest) = trimmed.strip_prefix("> ") {
            lines.push(Line::from(Span::styled(
                format!("  \u{258c} {}", rest.to_string()),
                Style::default().fg(Color::Magenta).italic(),
            )));
            continue;
        }

        // Unordered list
        if let Some(rest) = trimmed.strip_prefix("- ") {
            let mut list_spans = vec![
                Span::styled("  \u{2022} ", Style::default().fg(Color::Cyan)),
            ];
            list_spans.extend(render_inline_spans(rest));
            lines.push(Line::from(list_spans));
            continue;
        }

        // Ordered list
        if trimmed.len() > 2 && trimmed.as_bytes().get(1) == Some(&b'.') && trimmed.as_bytes().first().map(|b| b.is_ascii_digit()).unwrap_or(false) {
            let num = &trimmed[..2];
            let rest = &trimmed[3..];
            let mut list_spans = vec![
                Span::styled(format!("  {} ", num), Style::default().fg(Color::Yellow)),
            ];
            list_spans.extend(render_inline_spans(rest));
            lines.push(Line::from(list_spans));
            continue;
        }

        // Horizontal rule
        if trimmed == "---" || trimmed == "***" || trimmed == "___" {
            lines.push(Line::from(Span::styled(
                "\u{2500}".repeat(40),
                Style::default().fg(Color::DarkGray),
            )));
            continue;
        }

        // Empty line
        if trimmed.is_empty() {
            lines.push(Line::from(""));
            continue;
        }

        // Normal paragraph - use multi-span rendering
        lines.push(Line::from(render_inline_spans(trimmed)));
    }

    // Flush remaining table
    if !table_buffer.is_empty() {
        flush_markdown_table(&mut lines, &mut table_buffer);
    }

    lines
}

/// Render inline markdown formatting into multiple styled spans.
fn render_inline_spans(text: &str) -> Vec<Span<'static>> {
    let mut segments: Vec<(String, bool, bool, bool)> = Vec::new();
    let mut current = String::new();
    let mut bold = false;
    let mut italic = false;
    let mut code = false;
    let chars: Vec<char> = text.chars().collect();
    let mut i = 0;

    while i < chars.len() {
        if !code && i + 1 < chars.len() && chars[i] == '*' && chars[i + 1] == '*' {
            if !current.is_empty() {
                segments.push((std::mem::take(&mut current), bold, italic, code));
            }
            bold = !bold;
            i += 2;
            continue;
        }
        if !code && i + 1 < chars.len() && chars[i] == '_' && chars[i + 1] == '_' {
            if !current.is_empty() {
                segments.push((std::mem::take(&mut current), bold, italic, code));
            }
            bold = !bold;
            i += 2;
            continue;
        }
        if !code && chars[i] == '*' {
            if !current.is_empty() {
                segments.push((std::mem::take(&mut current), bold, italic, code));
            }
            italic = !italic;
            i += 1;
            continue;
        }
        if chars[i] == '`' {
            if !current.is_empty() {
                segments.push((std::mem::take(&mut current), bold, italic, code));
            }
            code = !code;
            i += 1;
            continue;
        }
        current.push(chars[i]);
        i += 1;
    }
    if !current.is_empty() {
        segments.push((current, bold, italic, code));
    }

    if segments.is_empty() {
        return vec![Span::styled(text.to_string(), Style::default().fg(Color::Reset))];
    }

    segments
        .into_iter()
        .map(|(s, b, it, c)| Span::styled(s, inline_style(b, it, c)))
        .collect()
}

fn inline_style(bold: bool, italic: bool, code: bool) -> Style {
    let mut style = Style::default().fg(Color::Reset);
    if code {
        style = Style::default().fg(Color::Yellow).bg(Color::Reset);
    }
    if bold {
        style = style.bold();
    }
    if italic {
        style = style.italic();
    }
    style
}

/// Flush buffered table rows into properly aligned table lines.
fn flush_markdown_table(
    lines: &mut Vec<Line<'static>>,
    table_buffer: &mut Vec<Vec<Vec<Span<'static>>>>,
) {
    if table_buffer.is_empty() {
        return;
    }

    let rows = std::mem::take(table_buffer);
    let col_count = rows.iter().map(|r| r.len()).max().unwrap_or(0);
    if col_count == 0 {
        return;
    }

    // Calculate column widths using unicode-width
    let mut col_widths = vec![0usize; col_count];
    for row in &rows {
        for (i, cell_spans) in row.iter().enumerate() {
            if i < col_count {
                let w: usize = cell_spans.iter().map(|s| {
                    unicode_width::UnicodeWidthStr::width(s.content.as_ref())
                }).sum();
                col_widths[i] = col_widths[i].max(w);
            }
        }
    }

    // Build border line
    let border_parts: Vec<String> = col_widths.iter().map(|w| "\u{2500}".repeat(w + 2)).collect();
    let top_border = format!("\u{250c}{}\u{2510}", border_parts.join("\u{252c}"));
    let header_sep = format!("\u{251c}{}\u{2524}", border_parts.join("\u{253c}"));
    let bottom_border = format!("\u{2514}{}\u{2518}", border_parts.join("\u{2534}"));

    lines.push(Line::from(Span::styled(
        top_border,
        Style::default().fg(Color::DarkGray),
    )));

    for (row_idx, row) in rows.iter().enumerate() {
        let mut spans: Vec<Span<'static>> = Vec::new();
        spans.push(Span::styled("\u{2502} ", Style::default().fg(Color::DarkGray)));

        for (col_idx, cell_spans) in row.iter().enumerate() {
            if col_idx > 0 {
                spans.push(Span::styled(" \u{2502} ", Style::default().fg(Color::DarkGray)));
            }
            let target_w = col_widths.get(col_idx).copied().unwrap_or(0);
            for s in cell_spans {
                spans.push(s.clone());
            }
            let current_w: usize = cell_spans.iter().map(|s| {
                unicode_width::UnicodeWidthStr::width(s.content.as_ref())
            }).sum();
            if current_w < target_w {
                spans.push(Span::raw(" ".repeat(target_w - current_w)));
            }
        }

        spans.push(Span::styled(" \u{2502}", Style::default().fg(Color::DarkGray)));

        // Bold the header row
        if row_idx == 0 {
            spans = spans.into_iter().map(|s| {
                Span::styled(
                    s.content.to_string(),
                    s.style.add_modifier(Modifier::BOLD),
                )
            }).collect();
        }

        lines.push(Line::from(spans));

        // Add header separator after first row
        if row_idx == 0 {
            lines.push(Line::from(Span::styled(
                header_sep.clone(),
                Style::default().fg(Color::DarkGray),
            )));
        }
    }

    lines.push(Line::from(Span::styled(
        bottom_border,
        Style::default().fg(Color::DarkGray),
    )));
    lines.push(Line::from(""));
}