clauth 0.9.0

Manage multiple Claude Code accounts, monitor 5h/7d usage with configurable auto-switch and delegation with an MCP plugin. CLI + TUI.
//! Top bar: claude glyph on the left; brand and account count in the text
//! column to the right. Three rows always — [`header_height`] keeps
//! `render::draw`'s layout in step.
//!
//! The active-profile usage gauge sits on row 1 to the right of the account
//! count, separated by a middle dot. The collapse ladder drops the usage bar
//! before the name.

use ratatui::Frame;
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;

use super::super::app::{App, Tab};
use super::super::theme;
use super::format::{bar_string_with_cells, fixed_split, name_style};

const VERSION: &str = env!("CARGO_PKG_VERSION");

// ── Account gauge ────────────────────────────────────────────────────────
//
// `name  [███░░░░░] 38%` for the active profile's 5h window. Collapse
// ladder sacrifices the bar before the name (bar shrinks → bar drops →
// name truncates → name drops → hide).

const GAUGE_NAME_GAP: usize = 2;
const GAUGE_BAR_FULL: usize = 10;
const GAUGE_BAR_MIN: usize = 3;
const GAUGE_NAME_MAX: usize = 16;
const GAUGE_NAME_MIN: usize = 3;
const GAUGE_PCT_W: usize = 4;
const GAUGE_DASH_W: usize = 1;

// ── Account-name pulse ───────────────────────────────────────────────────

const PULSE_SWEEP_MS: u64 = 900;
const PULSE_REST_MS: u64 = 1700;
const PULSE_DEPTH: f32 = 0.4;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct GaugeFit {
    name_w: usize,
    bar_cells: usize,
    visible: bool,
}

impl GaugeFit {
    const HIDDEN: GaugeFit = GaugeFit {
        name_w: 0,
        bar_cells: 0,
        visible: false,
    };
}

struct ActiveGauge {
    name: String,
    pct: Option<f64>,
    style: Style,
}

fn active_gauge(app: &App) -> Option<ActiveGauge> {
    let cfg = app.config();
    let name = cfg.state.active_profile.as_deref()?;
    let profile = cfg.find(name)?;
    let pct = if profile.is_oauth() {
        profile
            .usage
            .as_ref()
            .and_then(|u| u.five_hour.as_ref())
            .map(|w| w.utilization.clamp(0.0, 100.0))
    } else {
        None
    };
    Some(ActiveGauge {
        name: name.to_string(),
        pct,
        style: name_style(profile),
    })
}

fn gauge_tail_w(has_pct: bool) -> usize {
    if has_pct { GAUGE_PCT_W } else { GAUGE_DASH_W }
}

fn gauge_total_w(name_w: usize, bar_cells: usize, has_pct: bool) -> usize {
    let mut w = gauge_tail_w(has_pct);
    if bar_cells > 0 {
        w += bar_cells + 2 + 1;
    }
    if name_w > 0 {
        w += name_w + GAUGE_NAME_GAP;
    }
    w
}

/// Collapse ladder: bar shrinks → bar drops → name truncates → name drops → hide.
fn gauge_fit(avail: usize, name_len: usize, has_pct: bool) -> GaugeFit {
    if avail < gauge_tail_w(has_pct) {
        return GaugeFit::HIDDEN;
    }
    let mut name_w = name_len.min(GAUGE_NAME_MAX);
    let mut bar_cells = if has_pct { GAUGE_BAR_FULL } else { 0 };
    let name_floor = GAUGE_NAME_MIN.min(name_len);

    while gauge_total_w(name_w, bar_cells, has_pct) > avail && bar_cells > GAUGE_BAR_MIN {
        bar_cells -= 1;
    }
    if bar_cells > 0 && gauge_total_w(name_w, bar_cells, has_pct) > avail {
        bar_cells = 0;
    }
    while gauge_total_w(name_w, bar_cells, has_pct) > avail && name_w > name_floor {
        name_w -= 1;
    }
    if name_w > 0 && gauge_total_w(name_w, bar_cells, has_pct) > avail {
        name_w = 0;
    }
    if gauge_total_w(name_w, bar_cells, has_pct) > avail {
        return GaugeFit::HIDDEN;
    }
    GaugeFit {
        name_w,
        bar_cells,
        visible: true,
    }
}

fn gauge_spans(
    fit: GaugeFit,
    name: &str,
    style: Style,
    pct: Option<f64>,
    elapsed_ms: u64,
) -> Vec<Span<'static>> {
    let mut spans = Vec::new();
    if fit.name_w > 0 {
        let (nt, _pad) = fixed_split(name, fit.name_w);
        spans.extend(pulse_name_spans(&nt, style, elapsed_ms));
        spans.push(Span::raw("  "));
    }
    match pct {
        Some(pct) if fit.bar_cells > 0 => {
            let style = Style::default().fg(theme::util_color(pct));
            spans.push(Span::styled("[", theme::dim()));
            spans.push(Span::styled(
                bar_string_with_cells(pct, fit.bar_cells),
                style,
            ));
            spans.push(Span::styled("]", theme::dim()));
            spans.push(Span::styled(format!(" {pct:.0}%"), style));
        }
        Some(pct) => {
            spans.push(Span::styled(
                format!("{pct:.0}%"),
                Style::default().fg(theme::util_color(pct)),
            ));
        }
        None => spans.push(Span::styled("", theme::faint())),
    }
    spans
}

fn pulse_name_spans(name: &str, style: Style, elapsed_ms: u64) -> Vec<Span<'static>> {
    use std::f32::consts::{PI, TAU};
    let plain = || vec![Span::styled(name.to_string(), style)];
    if theme::tier() != theme::Tier::Full {
        return plain();
    }
    let t = (elapsed_ms % (PULSE_SWEEP_MS + PULSE_REST_MS)) as f32;
    let sweep = PULSE_SWEEP_MS as f32;
    if t >= sweep {
        return plain();
    }
    let progress = t / sweep;
    let envelope = (PI * progress).sin();
    let head = progress * TAU;
    let len = name.chars().count().max(1) as f32;
    let base = style.fg.unwrap_or_else(theme::text_color);
    name.chars()
        .enumerate()
        .map(|(i, ch)| {
            let col = (i as f32 / len) * TAU;
            let crest = ((col - head).cos() * 0.5 + 0.5).powi(2);
            let lean = f64::from(crest * envelope * PULSE_DEPTH);
            let fg = theme::blend_over(base, theme::accent_2_color(), lean);
            Span::styled(ch.to_string(), style.fg(fg))
        })
        .collect()
}

// ── Height ───────────────────────────────────────────────────────────────

pub(super) fn header_height(_app: &App) -> u16 {
    3
}

// ── Draw ─────────────────────────────────────────────────────────────────

pub(super) fn draw(frame: &mut Frame<'_>, area: Rect, app: &App) {
    let cols = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([Constraint::Length(10), Constraint::Min(20)])
        .split(area);

    draw_logo(frame, cols[0], app);

    let rows = Layout::default()
        .direction(Direction::Vertical)
        .constraints([Constraint::Length(1); 3])
        .split(cols[1]);

    let n = app.config().profiles.len();
    let info_width = rows[0].width as usize;

    let gauge = if app.tab == Tab::Overview || app.compact {
        None
    } else {
        active_gauge(app)
    };

    // ── Row 0: brand + version ────────────────────────────────────────────
    let brand = "clauth";
    let ver = format!("v{VERSION}");
    let gap = info_width.saturating_sub(brand.len() + ver.len());
    let title = Line::from(vec![
        Span::styled(brand, Style::default().fg(theme::text_color()).bold()),
        Span::styled(" ".repeat(gap), theme::base()),
        Span::styled(ver, theme::dim()),
    ]);
    frame.render_widget(Paragraph::new(title).style(theme::base()), rows[0]);

    // ── Row 1: N accounts · [gauge] ... ● status.claude.ai ──────────────
    // The count + gauge are left-aligned together; the status dot is the
    // only thing right-aligned, with an elastic gap in between.
    let row1_width = rows[1].width as usize;
    let prefix = if gauge.is_some() {
        format!("{n} account{} · ", plural(n))
    } else {
        format!("{n} account{}", plural(n))
    };
    let feed = "status.claude.ai";
    let status_head = "";
    let status_w = status_head.chars().count() + feed.chars().count();
    let reserve: usize = 1;

    let mut left_spans: Vec<Span<'static>> = vec![Span::styled(prefix, theme::faint())];
    if let Some(ref g) = gauge {
        let gauge_budget = row1_width.saturating_sub(
            left_spans
                .iter()
                .map(|s| s.content.chars().count())
                .sum::<usize>()
                + status_w
                + reserve,
        );
        let fit = gauge_fit(gauge_budget, g.name.chars().count(), g.pct.is_some());
        if fit.visible {
            let elapsed = app.started_at.elapsed().as_millis() as u64;
            left_spans.extend(gauge_spans(fit, &g.name, g.style, g.pct, elapsed));
        }
    }
    let left_w: usize = left_spans.iter().map(|s| s.content.chars().count()).sum();
    let mut row1_spans = left_spans;
    if row1_width >= left_w + status_w + reserve {
        let gap = row1_width - left_w - status_w;
        row1_spans.push(Span::raw(" ".repeat(gap)));
    }
    row1_spans.push(Span::styled(
        status_head,
        Style::default().fg(status_dot_color(app)),
    ));
    row1_spans.push(Span::styled(feed, theme::dim()));

    frame.render_widget(
        Paragraph::new(Line::from(row1_spans)).style(theme::base()),
        rows[1],
    );

    // ── Row 2: tabs ──────────────────────────────────────────────────────
    super::tabs::draw(frame, rows[2], app);
}

fn plural(n: usize) -> &'static str {
    if n == 1 { "" } else { "s" }
}

fn status_dot_color(app: &App) -> ratatui::style::Color {
    use crate::status::Impact;
    match app.status.worst_active_impact() {
        Impact::Critical | Impact::Major => theme::danger_color(),
        Impact::Minor => theme::warning_color(),
        Impact::Maintenance => theme::text_dim_color(),
        Impact::None | Impact::Other(_) => theme::success_color(),
    }
}

fn draw_logo(frame: &mut Frame<'_>, area: Rect, app: &App) {
    let elapsed = app.started_at.elapsed().as_millis() as u64;
    let blink = (elapsed % 6000) < 200;

    let style = Style::default().fg(theme::accent_2_color());

    let logo_top = if blink {
        " ▐█████▌ "
    } else {
        " ▐▛███▜▌ "
    };
    let logo_mid = "▝▜█████▛▘";
    let logo_eyes = "  ▘▘ ▝▝  ";

    let lines = vec![
        Line::from(Span::styled(logo_top, style)).alignment(Alignment::Left),
        Line::from(Span::styled(logo_mid, style)).alignment(Alignment::Left),
        Line::from(Span::styled(logo_eyes, style)).alignment(Alignment::Left),
    ];

    let para = Paragraph::new(lines).style(theme::base());
    frame.render_widget(para, area);
}

#[cfg(test)]
#[path = "../../../tests/inline/tui_render_header.rs"]
mod gauge_tests;