use std::{
io,
path::{Path, PathBuf},
sync::mpsc,
thread,
time::{Duration, Instant},
};
use chrono::{DateTime, Datelike, Local, SecondsFormat, Utc};
use crossterm::{
event::{self, Event, KeyCode, KeyEventKind},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::CrosstermBackend,
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
symbols::Marker,
text::{Line, Span},
widgets::{
canvas::{Canvas, Points},
Block, BorderType, Borders, Paragraph, Wrap,
},
Frame, Terminal,
};
const BASE_INTERVAL: Duration = Duration::from_secs(60);
const MAX_BACKOFF: Duration = Duration::from_secs(15 * 60);
const MANUAL_SPACING: Duration = Duration::from_secs(5);
const USER_AGENT: &str = "clui/0.1.0";
const CODEX_USAGE_URL: &str = "https://chatgpt.com/backend-api/wham/usage";
const CODEX_REFRESH_URL: &str = "https://auth.openai.com/oauth/token";
const CODEX_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
#[derive(Clone)]
struct Theme {
name: String,
gauge_low: Color, gauge_mid: Color, gauge_high: Color, ring_bg: Color,
accent: Color,
dim: Color,
error: Color,
border: Color,
}
impl Theme {
fn gauge_color(&self, pct: f64) -> Color {
if pct >= 90.0 {
self.gauge_high
} else if pct >= 70.0 {
self.gauge_mid
} else {
self.gauge_low
}
}
}
fn rgb(hex: u32) -> Color {
Color::Rgb((hex >> 16) as u8, (hex >> 8) as u8, hex as u8)
}
fn builtin_themes() -> Vec<Theme> {
vec![
Theme {
name: "default".into(),
gauge_low: Color::Cyan,
gauge_mid: Color::Gray,
gauge_high: Color::Red,
ring_bg: Color::Indexed(237),
accent: Color::Cyan,
dim: Color::DarkGray,
error: Color::Red,
border: Color::DarkGray,
},
Theme {
name: "dracula".into(),
gauge_low: rgb(0x50fa7b),
gauge_mid: rgb(0xf1fa8c),
gauge_high: rgb(0xff5555),
ring_bg: rgb(0x44475a),
accent: rgb(0xbd93f9),
dim: rgb(0x6272a4),
error: rgb(0xff5555),
border: rgb(0x6272a4),
},
Theme {
name: "gruvbox".into(),
gauge_low: rgb(0xb8bb26),
gauge_mid: rgb(0xfabd2f),
gauge_high: rgb(0xfb4934),
ring_bg: rgb(0x3c3836),
accent: rgb(0x83a598),
dim: rgb(0x928374),
error: rgb(0xfb4934),
border: rgb(0x665c54),
},
Theme {
name: "nord".into(),
gauge_low: rgb(0x88c0d0),
gauge_mid: rgb(0xebcb8b),
gauge_high: rgb(0xbf616a),
ring_bg: rgb(0x3b4252),
accent: rgb(0x81a1c1),
dim: rgb(0x4c566a),
error: rgb(0xbf616a),
border: rgb(0x4c566a),
},
Theme {
name: "solarized".into(),
gauge_low: rgb(0x2aa198),
gauge_mid: rgb(0xb58900),
gauge_high: rgb(0xdc322f),
ring_bg: rgb(0x073642),
accent: rgb(0x268bd2),
dim: rgb(0x586e75),
error: rgb(0xdc322f),
border: rgb(0x586e75),
},
Theme {
name: "mono".into(),
gauge_low: Color::Gray,
gauge_mid: Color::White,
gauge_high: Color::White,
ring_bg: Color::Indexed(236),
accent: Color::White,
dim: Color::DarkGray,
error: Color::White,
border: Color::DarkGray,
},
]
}
fn parse_color(v: &serde_json::Value) -> Option<Color> {
if let Some(n) = v.as_u64() {
return u8::try_from(n).ok().map(Color::Indexed);
}
v.as_str()?.trim().parse::<Color>().ok()
}
fn load_custom_themes(path: &Path, themes: &mut Vec<Theme>) -> Result<(), String> {
let raw = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
let json: serde_json::Value =
serde_json::from_str(&raw).map_err(|e| format!("{}: {e}", path.display()))?;
let map = json
.as_object()
.ok_or_else(|| format!("{}: top level must be an object", path.display()))?;
for (name, spec) in map {
let spec = spec
.as_object()
.ok_or_else(|| format!("theme `{name}` must be an object"))?;
let base_name = spec
.get("base")
.and_then(|v| v.as_str())
.unwrap_or("default");
let base = themes
.iter()
.find(|t| t.name == base_name)
.ok_or_else(|| format!("theme `{name}`: unknown base `{base_name}`"))?
.clone();
let field = |key: &str, fallback: Color| match spec.get(key) {
None => Ok(fallback),
Some(v) => parse_color(v)
.ok_or_else(|| format!("theme `{name}`: invalid color for `{key}`: {v}")),
};
let theme = Theme {
name: name.clone(),
gauge_low: field("gauge_low", base.gauge_low)?,
gauge_mid: field("gauge_mid", base.gauge_mid)?,
gauge_high: field("gauge_high", base.gauge_high)?,
ring_bg: field("ring_bg", base.ring_bg)?,
accent: field("accent", base.accent)?,
dim: field("dim", base.dim)?,
error: field("error", base.error)?,
border: field("border", base.border)?,
};
match themes.iter_mut().find(|t| t.name == *name) {
Some(existing) => *existing = theme,
None => themes.push(theme),
}
}
Ok(())
}
fn default_themes_path() -> PathBuf {
dirs::config_dir()
.unwrap_or_default()
.join("clui/themes.json")
}
#[derive(Clone, Debug)]
struct UsageBlock {
percent_used: f64,
resets_at: Option<DateTime<Utc>>,
}
#[derive(Clone, Debug, Default)]
struct ClaudeUsage {
five_hour: Option<UsageBlock>,
seven_day: Option<UsageBlock>,
seven_day_opus: Option<UsageBlock>,
seven_day_sonnet: Option<UsageBlock>,
}
#[derive(Clone, Debug, Default)]
struct CodexUsage {
session: Option<UsageBlock>,
weekly: Option<UsageBlock>,
plan: Option<String>,
}
enum Msg {
Claude(Result<ClaudeUsage, String>),
Codex(Result<CodexUsage, String>),
}
struct FetchFailure {
message: String,
rate_limited: bool,
retry_after: Option<Duration>,
}
impl FetchFailure {
fn other(message: impl Into<String>) -> Self {
Self {
message: message.into(),
rate_limited: false,
retry_after: None,
}
}
}
fn failure_from_ureq(e: ureq::Error) -> FetchFailure {
match e {
ureq::Error::Status(429, resp) => FetchFailure {
message: "Rate limited (HTTP 429)".into(),
rate_limited: true,
retry_after: resp
.header("retry-after")
.and_then(|s| s.trim().parse::<u64>().ok())
.map(Duration::from_secs),
},
ureq::Error::Status(code, _) => {
FetchFailure::other(format!("Failed to load usage data: HTTP {code}"))
}
other => FetchFailure::other(format!("Failed to load usage data: {other}")),
}
}
fn claude_token_from_json(raw: &str) -> Option<String> {
let json = serde_json::from_str::<serde_json::Value>(raw).ok()?;
let token = json
.pointer("/claudeAiOauth/accessToken")?
.as_str()?
.to_string();
token.starts_with("sk-ant-oat").then_some(token)
}
fn read_claude_token() -> Result<String, String> {
let path = dirs::home_dir()
.ok_or("no home directory")?
.join(".claude/.credentials.json");
if let Ok(raw) = std::fs::read_to_string(&path) {
if let Some(token) = claude_token_from_json(&raw) {
return Ok(token);
}
}
if let Ok(out) = std::process::Command::new("secret-tool")
.args(["lookup", "service", "Claude Code"])
.output()
{
if out.status.success() {
if let Some(token) = claude_token_from_json(String::from_utf8_lossy(&out.stdout).trim())
{
return Ok(token);
}
}
}
Err("Not available. Make sure you're logged in to Claude Code.".into())
}
fn parse_claude_block(value: Option<&serde_json::Value>) -> Option<UsageBlock> {
let v = value?;
let percent_used = v.get("utilization")?.as_f64()?;
let resets_at = v
.get("resets_at")
.and_then(|r| r.as_str())
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.with_timezone(&Utc));
Some(UsageBlock {
percent_used,
resets_at,
})
}
fn fetch_claude() -> Result<ClaudeUsage, FetchFailure> {
let token = read_claude_token().map_err(FetchFailure::other)?;
let resp = ureq::get("https://api.anthropic.com/api/oauth/usage")
.set("Content-Type", "application/json")
.set("User-Agent", USER_AGENT)
.set("Authorization", &format!("Bearer {token}"))
.set("anthropic-beta", "oauth-2025-04-20")
.timeout(Duration::from_secs(10))
.call()
.map_err(failure_from_ureq)?;
let json: serde_json::Value = resp
.into_json()
.map_err(|e| FetchFailure::other(format!("Failed to load usage data: {e}")))?;
Ok(ClaudeUsage {
five_hour: parse_claude_block(json.get("five_hour")),
seven_day: parse_claude_block(json.get("seven_day")),
seven_day_opus: parse_claude_block(json.get("seven_day_opus")),
seven_day_sonnet: parse_claude_block(json.get("seven_day_sonnet")),
})
}
fn codex_auth_path() -> PathBuf {
std::env::var("CODEX_HOME")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.map(PathBuf::from)
.unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join(".codex"))
.join("auth.json")
}
fn parse_codex_window(value: Option<&serde_json::Value>) -> Option<(i64, UsageBlock)> {
let v = value?;
let percent_used = v.get("used_percent")?.as_f64()?;
let window_secs = v
.get("limit_window_seconds")
.and_then(|w| w.as_i64())
.unwrap_or(0);
let resets_at = v
.get("reset_at")
.and_then(|r| r.as_i64())
.and_then(DateTime::from_timestamp_secs);
Some((
window_secs,
UsageBlock {
percent_used,
resets_at,
},
))
}
enum CodexFetchError {
Unauthorized,
Failure(FetchFailure),
}
impl CodexFetchError {
fn into_failure(self) -> FetchFailure {
match self {
CodexFetchError::Unauthorized => {
FetchFailure::other("Unauthorized. Run `codex` to re-authenticate.")
}
CodexFetchError::Failure(f) => f,
}
}
}
fn request_codex_usage(
token: &str,
account_id: Option<&str>,
) -> Result<CodexUsage, CodexFetchError> {
let mut req = ureq::get(CODEX_USAGE_URL)
.set("Authorization", &format!("Bearer {token}"))
.set("Accept", "application/json")
.set("User-Agent", USER_AGENT)
.timeout(Duration::from_secs(10));
if let Some(account) = account_id.filter(|a| !a.is_empty()) {
req = req.set("ChatGPT-Account-Id", account);
}
let resp = req.call().map_err(|e| match e {
ureq::Error::Status(401 | 403, _) => CodexFetchError::Unauthorized,
other => CodexFetchError::Failure(failure_from_ureq(other)),
})?;
let json: serde_json::Value = resp.into_json().map_err(|e| {
CodexFetchError::Failure(FetchFailure::other(format!(
"Failed to load usage data: {e}"
)))
})?;
let mut windows: Vec<(i64, UsageBlock)> = [
parse_codex_window(json.pointer("/rate_limit/primary_window")),
parse_codex_window(json.pointer("/rate_limit/secondary_window")),
]
.into_iter()
.flatten()
.collect();
windows.sort_by_key(|(secs, _)| *secs);
let mut usage = CodexUsage {
plan: json
.get("plan_type")
.and_then(|p| p.as_str())
.map(format_codex_plan),
..Default::default()
};
match windows.len() {
2 => {
let mut it = windows.into_iter();
usage.session = Some(it.next().unwrap().1);
usage.weekly = Some(it.next().unwrap().1);
}
1 => {
let (secs, block) = windows.pop().unwrap();
if secs <= 6 * 3600 {
usage.session = Some(block);
} else {
usage.weekly = Some(block);
}
}
_ => {}
}
Ok(usage)
}
fn format_codex_plan(plan: &str) -> String {
match plan {
"pro" => "Pro 20x".into(),
"pro_lite" | "prolite" => "Pro 5x".into(),
other => other
.split(['_', ' '])
.filter(|w| !w.is_empty())
.map(|w| {
let mut c = w.chars();
match c.next() {
Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
None => String::new(),
}
})
.collect::<Vec<_>>()
.join(" "),
}
}
fn refresh_codex_tokens(
path: &PathBuf,
auth: &mut serde_json::Value,
) -> Result<String, String> {
let refresh_token = auth
.pointer("/tokens/refresh_token")
.and_then(|v| v.as_str())
.ok_or("no refresh token in auth.json")?
.to_string();
let resp = ureq::post(CODEX_REFRESH_URL)
.set("Content-Type", "application/json")
.set("User-Agent", USER_AGENT)
.timeout(Duration::from_secs(10))
.send_json(serde_json::json!({
"client_id": CODEX_CLIENT_ID,
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"scope": "openid profile email",
}))
.map_err(|e| format!("token refresh failed: {e}"))?;
let json: serde_json::Value = resp
.into_json()
.map_err(|e| format!("token refresh failed: {e}"))?;
let access = json
.get("access_token")
.and_then(|v| v.as_str())
.ok_or("token refresh returned no access_token")?
.to_string();
if let Some(tokens) = auth.get_mut("tokens") {
tokens["access_token"] = serde_json::Value::String(access.clone());
for key in ["refresh_token", "id_token"] {
if let Some(v) = json.get(key).and_then(|v| v.as_str()) {
tokens[key] = serde_json::Value::String(v.to_string());
}
}
}
auth["last_refresh"] = serde_json::Value::String(
Utc::now().to_rfc3339_opts(SecondsFormat::Micros, true),
);
if let Ok(serialized) = serde_json::to_string_pretty(auth) {
let _ = std::fs::write(path, serialized);
}
Ok(access)
}
fn fetch_codex() -> Result<CodexUsage, FetchFailure> {
let path = codex_auth_path();
let raw = std::fs::read_to_string(&path)
.map_err(|_| FetchFailure::other("Not available. Run `codex` to sign in."))?;
let mut auth: serde_json::Value = serde_json::from_str(&raw)
.map_err(|e| FetchFailure::other(format!("bad auth.json: {e}")))?;
if let Some(key) = auth
.get("OPENAI_API_KEY")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
{
return request_codex_usage(key, None).map_err(CodexFetchError::into_failure);
}
let mut access = auth
.pointer("/tokens/access_token")
.and_then(|v| v.as_str())
.ok_or_else(|| FetchFailure::other("Not available. Run `codex` to sign in."))?
.to_string();
let account_id = auth
.pointer("/tokens/account_id")
.and_then(|v| v.as_str())
.map(str::to_string);
let stale = auth
.get("last_refresh")
.and_then(|v| v.as_str())
.and_then(|s| DateTime::parse_from_rfc3339(s).ok())
.map(|t| Utc::now() - t.with_timezone(&Utc) > chrono::Duration::days(8))
.unwrap_or(true);
let mut refreshed = false;
if stale {
if let Ok(new_access) = refresh_codex_tokens(&path, &mut auth) {
access = new_access;
refreshed = true;
}
}
match request_codex_usage(&access, account_id.as_deref()) {
Ok(usage) => Ok(usage),
Err(CodexFetchError::Unauthorized) if !refreshed => {
let new_access = refresh_codex_tokens(&path, &mut auth).map_err(|_| {
FetchFailure::other("Unauthorized. Run `codex` to re-authenticate.")
})?;
request_codex_usage(&new_access, account_id.as_deref())
.map_err(CodexFetchError::into_failure)
}
Err(e) => Err(e.into_failure()),
}
}
fn format_time_remaining(reset: DateTime<Utc>) -> String {
let mins = (reset - Utc::now()).num_minutes().max(0);
if mins < 60 {
format!("{mins}m")
} else {
format!("{}h {}m", mins / 60, mins % 60)
}
}
fn format_reset_date(reset: DateTime<Utc>) -> String {
let local = reset.with_timezone(&Local);
let now = Local::now();
let days = (local.date_naive() - now.date_naive()).num_days();
let time = local.format("%H:%M");
if days <= 0 {
format!("{time}")
} else if days == 1 {
format!("Tomorrow {time}")
} else if days < 7 {
format!("{} {time}", local.format("%a"))
} else {
format!("{} {} {time}", local.format("%b"), local.day())
}
}
fn week_progress_percent(reset: DateTime<Utc>) -> i64 {
let now = Utc::now();
let week = chrono::Duration::days(7);
let week_ms = week.num_milliseconds() as f64;
if now > reset {
(((now - reset).num_milliseconds() as f64 / week_ms) * 100.0).round() as i64
} else {
let start = reset - week;
let pct = ((now - start).num_milliseconds() as f64 / week_ms) * 100.0;
(pct.round() as i64).clamp(0, 100)
}
}
struct Card {
label: &'static str,
subtitle: String,
percent: f64,
}
fn build_claude_cards(data: &ClaudeUsage) -> Vec<Card> {
let mut cards = Vec::new();
if let Some(b) = &data.five_hour {
cards.push(Card {
label: "5-Hour Block",
subtitle: b
.resets_at
.map(|r| format!("Resets in {}", format_time_remaining(r)))
.unwrap_or_default(),
percent: b.percent_used,
});
}
if let Some(b) = &data.seven_day {
cards.push(Card {
label: "7-Day Rolling",
subtitle: b
.resets_at
.map(|r| {
format!(
"Resets {} · {}% through week",
format_reset_date(r),
week_progress_percent(r)
)
})
.unwrap_or_default(),
percent: b.percent_used,
});
}
if let Some(b) = &data.seven_day_opus {
cards.push(Card {
label: "Opus Weekly",
subtitle: b
.resets_at
.map(|r| format!("Resets {}", format_reset_date(r)))
.unwrap_or_default(),
percent: b.percent_used,
});
}
if let Some(b) = &data.seven_day_sonnet {
cards.push(Card {
label: "Sonnet Weekly",
subtitle: b
.resets_at
.map(|r| format!("Resets {}", format_reset_date(r)))
.unwrap_or_default(),
percent: b.percent_used,
});
}
cards
}
fn build_codex_cards(data: &CodexUsage) -> Vec<Card> {
let mut cards = Vec::new();
if let Some(b) = &data.session {
cards.push(Card {
label: "Session",
subtitle: b
.resets_at
.map(|r| format!("Resets in {}", format_time_remaining(r)))
.unwrap_or_default(),
percent: b.percent_used,
});
}
if let Some(b) = &data.weekly {
cards.push(Card {
label: "Weekly",
subtitle: b
.resets_at
.map(|r| format!("Resets {}", format_reset_date(r)))
.unwrap_or_default(),
percent: b.percent_used,
});
}
cards
}
fn render_gauge(frame: &mut Frame, area: Rect, percent: f64, color: Color, ring_bg: Color) {
let canvas = Canvas::default()
.marker(Marker::Braille)
.x_bounds([-1.15, 1.15])
.y_bounds([-1.15, 1.15])
.paint(|ctx| {
let steps = 720usize;
let radii = [0.80, 0.86, 0.92, 0.98];
let sweep = (percent.clamp(0.0, 100.0) / 100.0) * 360.0;
let mut bg: Vec<(f64, f64)> = Vec::new();
let mut fg: Vec<(f64, f64)> = Vec::new();
for i in 0..steps {
let t = i as f64 * 360.0 / steps as f64; let theta = (90.0 - t).to_radians();
for r in radii {
let point = (r * theta.cos(), r * theta.sin());
if t < sweep {
fg.push(point);
} else {
bg.push(point);
}
}
}
ctx.draw(&Points {
coords: &bg,
color: ring_bg,
});
ctx.draw(&Points { coords: &fg, color });
});
frame.render_widget(canvas, area);
let text = format!("{:.0}%", percent);
let w = (text.len() as u16).min(area.width);
let label_area = Rect {
x: area.x + (area.width.saturating_sub(w)) / 2,
y: area.y + area.height / 2,
width: w,
height: 1,
};
frame.render_widget(
Paragraph::new(text).style(Style::default().fg(color).add_modifier(Modifier::BOLD)),
label_area,
);
}
fn render_card(frame: &mut Frame, area: Rect, card: &Card, theme: &Theme) {
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme.border));
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.width < 10 || inner.height < 3 {
return;
}
let gauge_w = (inner.height * 2).clamp(8, inner.width / 2);
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Length(gauge_w),
Constraint::Length(1),
Constraint::Min(0),
])
.split(inner);
render_gauge(
frame,
chunks[0],
card.percent,
theme.gauge_color(card.percent),
theme.ring_bg,
);
let text_area = chunks[2];
let pad = text_area.height.saturating_sub(3) / 2;
let mut lines: Vec<Line> = (0..pad).map(|_| Line::raw("")).collect();
lines.push(Line::from(Span::styled(
card.label,
Style::default().add_modifier(Modifier::BOLD),
)));
lines.push(Line::raw(""));
lines.push(Line::from(Span::styled(
card.subtitle.clone(),
Style::default().fg(theme.dim),
)));
frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: true }), text_area);
}
enum Row {
Title(Line<'static>),
Cards(Vec<Card>),
Notice(String, Color),
}
struct Provider {
error: Option<String>,
cards: Option<Vec<Card>>,
title: Line<'static>,
}
fn provider_rows(p: Provider, rows: &mut Vec<Row>, theme: &Theme) {
let mut title = p.title;
if p.cards.is_some() {
if let Some(err) = &p.error {
title.push_span(Span::styled(
format!(" ({err})"),
Style::default().fg(theme.error),
));
}
}
rows.push(Row::Title(title));
match (p.cards, p.error) {
(Some(cards), _) if cards.is_empty() => {
rows.push(Row::Notice("No usage limits reported.".into(), theme.dim));
}
(Some(cards), _) => {
let mut cards = cards;
while !cards.is_empty() {
let rest = cards.split_off(cards.len().min(2));
rows.push(Row::Cards(cards));
cards = rest;
}
}
(None, Some(err)) => rows.push(Row::Notice(err, theme.error)),
(None, None) => rows.push(Row::Notice("Loading…".into(), theme.dim)),
}
}
struct App {
claude: Option<ClaudeUsage>,
claude_error: Option<String>,
codex: Option<CodexUsage>,
codex_error: Option<String>,
last_updated: Option<DateTime<Local>>,
themes: Vec<Theme>,
theme_idx: usize,
}
impl App {
fn theme(&self) -> &Theme {
&self.themes[self.theme_idx]
}
fn cycle_theme(&mut self, step: isize) {
let len = self.themes.len() as isize;
self.theme_idx = (self.theme_idx as isize + step).rem_euclid(len) as usize;
}
}
fn render_app(frame: &mut Frame, app: &App) {
let theme = app.theme();
let outer = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(1)])
.split(frame.area());
let codex_title = match app.codex.as_ref().and_then(|c| c.plan.as_deref()) {
Some(plan) => Line::from(vec![
Span::styled(" Codex ", Style::default().add_modifier(Modifier::BOLD)),
Span::styled(format!("· {plan}"), Style::default().fg(theme.dim)),
]),
None => Line::from(Span::styled(
" Codex",
Style::default().add_modifier(Modifier::BOLD),
)),
};
let mut rows = Vec::new();
provider_rows(
Provider {
cards: app.claude.as_ref().map(build_claude_cards),
error: app.claude_error.clone(),
title: Line::from(vec![
Span::styled(
" Claude Code ",
Style::default().add_modifier(Modifier::BOLD),
),
Span::styled("· Usage Limits", Style::default().fg(theme.dim)),
]),
},
&mut rows,
theme,
);
provider_rows(
Provider {
cards: app.codex.as_ref().map(build_codex_cards),
error: app.codex_error.clone(),
title: codex_title,
},
&mut rows,
theme,
);
let constraints: Vec<Constraint> = rows
.iter()
.map(|r| match r {
Row::Title(_) => Constraint::Length(1),
Row::Cards(_) => Constraint::Min(8),
Row::Notice(..) => Constraint::Length(3),
})
.collect();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(outer[0]);
for (row, chunk) in rows.into_iter().zip(chunks.iter()) {
match row {
Row::Title(line) => frame.render_widget(Paragraph::new(line), *chunk),
Row::Cards(cards) => {
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(*chunk);
for (i, card) in cards.iter().enumerate() {
render_card(frame, cols[i], card, theme);
}
}
Row::Notice(text, color) => frame.render_widget(
Paragraph::new(text)
.style(Style::default().fg(color))
.wrap(Wrap { trim: true })
.alignment(Alignment::Center),
*chunk,
),
}
}
let mut footer = vec![
Span::styled(" q ", Style::default().fg(theme.accent)),
Span::styled("quit ", Style::default().fg(theme.dim)),
Span::styled("r ", Style::default().fg(theme.accent)),
Span::styled("refresh ", Style::default().fg(theme.dim)),
Span::styled("t ", Style::default().fg(theme.accent)),
Span::styled(format!("theme: {}", theme.name), Style::default().fg(theme.dim)),
];
if let Some(t) = app.last_updated {
footer.push(Span::styled(
format!(" · updated {}", t.format("%H:%M:%S")),
Style::default().fg(theme.dim),
));
}
frame.render_widget(Paragraph::new(Line::from(footer)), outer[1]);
}
struct Poller {
next: Instant,
backoff: Duration,
rate_limited: bool,
last_attempt: Option<Instant>,
}
impl Poller {
fn new() -> Self {
Self {
next: Instant::now(),
backoff: BASE_INTERVAL,
rate_limited: false,
last_attempt: None,
}
}
fn poll<T>(&mut self, fetch: impl FnOnce() -> Result<T, FetchFailure>) -> Option<Result<T, String>> {
if Instant::now() < self.next {
return None;
}
self.last_attempt = Some(Instant::now());
match fetch() {
Ok(data) => {
self.backoff = BASE_INTERVAL;
self.rate_limited = false;
self.next = Instant::now() + BASE_INTERVAL;
Some(Ok(data))
}
Err(failure) => {
self.rate_limited = failure.rate_limited;
let delay = if failure.rate_limited {
self.backoff = (self.backoff * 2).min(MAX_BACKOFF);
self.backoff.max(failure.retry_after.unwrap_or(Duration::ZERO))
} else {
self.backoff = BASE_INTERVAL;
BASE_INTERVAL
};
self.next = Instant::now() + delay;
Some(Err(format!(
"{} — retrying in {}",
failure.message,
format_delay(delay)
)))
}
}
}
fn request_now(&mut self) {
if self.rate_limited {
return;
}
let earliest = self
.last_attempt
.map(|t| t + MANUAL_SPACING)
.unwrap_or_else(Instant::now)
.max(Instant::now());
self.next = self.next.min(earliest);
}
}
fn format_delay(d: Duration) -> String {
let secs = d.as_secs();
if secs < 120 {
format!("{secs}s")
} else {
format!("{}m", secs.div_ceil(60))
}
}
const HELP: &str = "\
clui — TUI for Claude Code and Codex usage limits
USAGE
clui [OPTIONS]
OPTIONS
-t, --theme <NAME> Color theme to start with (default: default)
--themes-file <PATH> Custom themes JSON (default: ~/.config/clui/themes.json)
--list-themes Print available themes and exit
-h, --help Show this help
-V, --version Show version
KEYS
q/Esc quit · r refresh · t/T next/previous theme
CUSTOM THEMES
The themes file maps theme names to color overrides. Colors are ratatui
color names (\"cyan\", \"darkgray\"), hex (\"#ff5555\"), or 256-color indexes
(237). Unset fields inherit from `base` (default: \"default\"). Fields:
gauge_low, gauge_mid, gauge_high, ring_bg, accent, dim, error, border.
{ \"neon\": { \"base\": \"mono\", \"gauge_low\": \"#39ff14\", \"accent\": 213 } }
";
struct Cli {
theme: Option<String>,
themes_file: Option<PathBuf>,
list_themes: bool,
}
fn parse_cli() -> Result<Cli, String> {
let mut cli = Cli {
theme: None,
themes_file: None,
list_themes: false,
};
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
let (flag, inline) = match arg.split_once('=') {
Some((f, v)) => (f.to_string(), Some(v.to_string())),
None => (arg, None),
};
let mut value = |inline: Option<String>| {
inline
.or_else(|| args.next())
.ok_or_else(|| format!("{flag} requires a value"))
};
match flag.as_str() {
"-t" | "--theme" => cli.theme = Some(value(inline)?),
"--themes-file" => cli.themes_file = Some(PathBuf::from(value(inline)?)),
"--list-themes" => cli.list_themes = true,
"-h" | "--help" => {
print!("{HELP}");
std::process::exit(0);
}
"-V" | "--version" => {
println!("clui {}", env!("CARGO_PKG_VERSION"));
std::process::exit(0);
}
other => return Err(format!("unknown argument `{other}` (try --help)")),
}
}
Ok(cli)
}
fn setup_themes(cli: &Cli) -> Result<(Vec<Theme>, usize), String> {
let mut themes = builtin_themes();
let path = cli.themes_file.clone().unwrap_or_else(default_themes_path);
if path.exists() {
load_custom_themes(&path, &mut themes)?;
} else if cli.themes_file.is_some() {
return Err(format!("themes file not found: {}", path.display()));
}
let idx = match &cli.theme {
Some(name) => themes
.iter()
.position(|t| t.name.eq_ignore_ascii_case(name))
.ok_or_else(|| {
let names: Vec<&str> = themes.iter().map(|t| t.name.as_str()).collect();
format!("unknown theme `{name}` (available: {})", names.join(", "))
})?,
None => 0,
};
Ok((themes, idx))
}
fn main() -> io::Result<()> {
let result = parse_cli().and_then(|cli| setup_themes(&cli).map(|t| (cli, t)));
let (cli, (themes, theme_idx)) = match result {
Ok(v) => v,
Err(e) => {
eprintln!("clui: {e}");
std::process::exit(2);
}
};
if cli.list_themes {
for t in &themes {
println!("{}", t.name);
}
return Ok(());
}
let (tx_msg, rx_msg) = mpsc::channel::<Msg>();
let (tx_cmd, rx_cmd) = mpsc::channel::<()>();
thread::spawn(move || {
let mut claude = Poller::new();
let mut codex = Poller::new();
loop {
if let Some(result) = claude.poll(fetch_claude) {
if tx_msg.send(Msg::Claude(result)).is_err() {
return;
}
}
if let Some(result) = codex.poll(fetch_codex) {
if tx_msg.send(Msg::Codex(result)).is_err() {
return;
}
}
let sleep = claude
.next
.min(codex.next)
.saturating_duration_since(Instant::now())
.max(Duration::from_millis(100));
match rx_cmd.recv_timeout(sleep) {
Ok(()) => {
claude.request_now();
codex.request_now();
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
Err(mpsc::RecvTimeoutError::Disconnected) => return,
}
}
});
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let mut terminal = Terminal::new(CrosstermBackend::new(stdout))?;
let mut app = App {
claude: None,
claude_error: None,
codex: None,
codex_error: None,
last_updated: None,
themes,
theme_idx,
};
let result = loop {
let mut fresh_data = false;
while let Ok(msg) = rx_msg.try_recv() {
fresh_data = true;
match msg {
Msg::Claude(Ok(data)) => {
app.claude = Some(data);
app.claude_error = None;
app.last_updated = Some(Local::now());
}
Msg::Claude(Err(e)) => app.claude_error = Some(e),
Msg::Codex(Ok(data)) => {
app.codex = Some(data);
app.codex_error = None;
app.last_updated = Some(Local::now());
}
Msg::Codex(Err(e)) => app.codex_error = Some(e),
}
}
if fresh_data {
let _ = terminal.clear();
}
if let Err(e) = terminal.draw(|f| render_app(f, &app)) {
break Err(e);
}
match event::poll(Duration::from_millis(250)) {
Ok(true) => match event::read() {
Ok(Event::Key(key)) if key.kind == KeyEventKind::Press => match key.code {
KeyCode::Char('q') | KeyCode::Esc => break Ok(()),
KeyCode::Char('r') => {
let _ = terminal.clear();
let _ = tx_cmd.send(());
}
KeyCode::Char('t') => {
app.cycle_theme(1);
let _ = terminal.clear();
}
KeyCode::Char('T') => {
app.cycle_theme(-1);
let _ = terminal.clear();
}
_ => {}
},
Ok(_) => {}
Err(e) => break Err(e),
},
Ok(false) => {}
Err(e) => break Err(e),
}
};
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
result
}