use anyhow::Result;
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
use std::path::PathBuf;
const QUOTA_REFRESH_SECS: u64 = 45;
const VIOLET: Color = Color::Rgb(157, 107, 255); const DEXGRAY: Color = Color::Rgb(150, 150, 160); const MUTED: Color = Color::Rgb(139, 138, 149);
fn list_block_titled(title: &str) -> Block<'static> {
Block::default()
.borders(Borders::ALL)
.border_type(ratatui::widgets::BorderType::Rounded)
.border_style(Style::default().fg(Color::Rgb(96, 94, 116)))
.title(Span::styled(
title.to_string(),
Style::default().fg(VIOLET).add_modifier(Modifier::BOLD),
))
}
fn list_block(title: &'static str) -> Block<'static> {
Block::default()
.borders(Borders::ALL)
.border_type(ratatui::widgets::BorderType::Rounded)
.border_style(Style::default().fg(Color::Rgb(96, 94, 116)))
.title(Span::styled(
title,
Style::default().fg(VIOLET).add_modifier(Modifier::BOLD),
))
}
fn logo_lines() -> Vec<Line<'static>> {
crate::banner::SWAP
.iter()
.zip(crate::banner::DEX.iter())
.map(|(sw, dx)| {
Line::from(vec![
Span::raw(" "),
Span::styled(
*sw,
Style::default().fg(VIOLET).add_modifier(Modifier::BOLD),
),
Span::styled(*dx, Style::default().fg(DEXGRAY)),
])
})
.collect()
}
fn clamp_selection(state: &mut ListState, len: usize) {
match (state.selected(), len) {
(_, 0) => state.select(None),
(Some(i), n) if i >= n => state.select(Some(n - 1)),
(None, n) if n > 0 => state.select(Some(0)),
_ => {}
}
}
fn click_row_index(offset: usize, click_row: u16, top: u16, per: u16) -> usize {
offset + (click_row.saturating_sub(top) / per.max(1)) as usize
}
fn key_hints(pairs: &[(&'static str, &'static str)]) -> Line<'static> {
let mut spans = vec![Span::raw(" ")];
for (i, (key, label)) in pairs.iter().enumerate() {
if i > 0 {
spans.push(Span::styled(" ", Style::default().fg(MUTED)));
}
spans.push(Span::styled(
*key,
Style::default().fg(VIOLET).add_modifier(Modifier::BOLD),
));
spans.push(Span::raw(" "));
spans.push(Span::styled(*label, Style::default().fg(MUTED)));
}
Line::from(spans)
}
const TOOL_ORDER: &[&str] = &["claude-code", "codex", "gemini", "antigravity"];
fn group_of(tools: &str) -> &'static str {
TOOL_ORDER
.iter()
.find(|t| tools.contains(*t))
.copied()
.unwrap_or("other")
}
pub fn dedupe_by_identity(rows: Vec<Row>) -> Vec<Row> {
let key = |r: &Row| {
r.ident
.split_whitespace()
.next()
.filter(|e| e.contains('@'))
.map(|e| format!("{}\u{0}{}", group_of(&r.tools), e))
};
let rank = |r: &Row| {
let usable = match (r.needs_login, r.active) {
(false, true) => 0,
(false, false) => 1,
(true, _) => 2,
};
(usable, u8::from(!r.is_slot))
};
let by_name = |r: &Row| format!("{}\u{0}name\u{0}{}", group_of(&r.tools), r.name);
let matches = |row: &Row, k: &Option<String>, members: &[usize], rows: &[Row]| -> bool {
if let (Some(a), Some(b)) = (key(row), k.as_ref()) {
if a == *b {
return true;
}
}
members.iter().any(|i| by_name(&rows[*i]) == by_name(row))
};
let mut slots: Vec<(Option<String>, usize, Vec<usize>)> = Vec::new();
let mut rows = rows;
for i in 0..rows.len() {
let k = key(&rows[i]);
match slots
.iter()
.position(|(s, _, members)| matches(&rows[i], s, members, &rows))
{
Some(pos) => {
if rank(&rows[i]) < rank(&rows[slots[pos].1]) {
slots[pos].1 = i;
}
slots[pos].2.push(i);
}
None => slots.push((k, i, vec![i])),
}
}
let mut out = Vec::with_capacity(slots.len());
for (_, winner, members) in slots {
let also: Vec<String> = members
.iter()
.filter(|i| **i != winner)
.map(|i| rows[*i].name.clone())
.collect();
let mut row = std::mem::replace(
&mut rows[winner],
Row {
name: String::new(),
ident: String::new(),
tools: String::new(),
active: false,
warn: None,
disabled: false,
needs_login: false,
stale: false,
is_slot: false,
also: Vec::new(),
},
);
row.also = also;
out.push(row);
}
out
}
pub fn group_sorted(mut rows: Vec<Row>) -> Vec<Row> {
let rank = |r: &Row| {
TOOL_ORDER
.iter()
.position(|t| r.tools.contains(*t))
.unwrap_or(TOOL_ORDER.len())
};
rows.sort_by_key(rank);
rows
}
fn group_heads(rows: &[Row]) -> Vec<bool> {
let mut out = Vec::with_capacity(rows.len());
let mut prev: Option<&str> = None;
for r in rows {
let g = group_of(&r.tools);
out.push(prev != Some(g));
prev = Some(g);
}
out
}
fn click_item_index(offset: usize, click_row: u16, top: u16, heights: &[u16]) -> usize {
let mut y = top;
let mut i = offset;
while i < heights.len() {
let h = heights[i].max(1);
if click_row < y + h {
return i;
}
y += h;
i += 1;
}
heights.len().saturating_sub(1)
}
fn usage_bar_column(rows: &[Row]) -> usize {
const NUM: usize = 3; const DOT: usize = 2; const GAP: usize = 2;
const STATUS: usize = 8; let name_w = rows
.iter()
.map(|r| r.name.chars().count())
.max()
.unwrap_or(0);
let ident_w = rows
.iter()
.map(|r| r.ident.chars().count())
.max()
.unwrap_or(0);
NUM + DOT + name_w + GAP + ident_w + GAP + STATUS + GAP
}
const ALL_KEYS: &[KeyHint] = &[
("\u{21b5}", "switch to it"),
("1-9", "switch by number"),
("o", "open a chat"),
("r", "back to last account"),
("a", "add account"),
("q", "quit"),
("l", "sign in / re-login"),
("e", "pause / resume"),
("n", "rename"),
("d", "delete"),
("u", "tokens used"),
("%", "quota detail"),
("?", "health check"),
];
const SPENT: f64 = 99.0;
fn account_status(r: &Row, u: Option<&Usage>) -> (&'static str, Color) {
if r.disabled {
return ("paused", Color::Rgb(110, 108, 128));
}
if r.needs_login {
return ("no login", Color::Rgb(200, 150, 90));
}
if r.stale {
return ("expired", Color::Rgb(200, 150, 90));
}
if let Some(w) = r.warn {
return (w, Color::Rgb(200, 150, 90));
}
let spent = u.is_some_and(|u| {
!u.on_credits
&& (u.five_h.is_some_and(|p| p >= SPENT) || u.seven_d.is_some_and(|p| p >= SPENT))
});
if !spent
&& u.is_some_and(|u| {
u.on_credits
&& (u.five_h.is_some_and(|p| p >= SPENT) || u.seven_d.is_some_and(|p| p >= SPENT))
})
{
return (ON_CREDITS, Color::Rgb(200, 150, 90));
}
match (spent, r.active) {
(true, _) => ("spent", Color::Rgb(196, 92, 96)),
(false, true) => ("active", VIOLET),
(false, false) => ("ready", Color::Rgb(120, 118, 140)),
}
}
fn mark_selected(mut spans: Vec<Span<'static>>, upto: usize, selected: bool) -> Vec<Span<'static>> {
if !selected {
return spans;
}
for sp in spans.iter_mut().take(upto) {
sp.style = sp.style.bg(SELECT_BG).add_modifier(Modifier::BOLD);
}
spans
}
const SELECT_BG: Color = Color::Rgb(48, 42, 78);
fn centered(area: ratatui::layout::Rect, w: u16, h: u16) -> ratatui::layout::Rect {
let w = w.min(area.width);
let h = h.min(area.height);
ratatui::layout::Rect {
x: area.x + (area.width - w) / 2,
y: area.y + (area.height - h) / 2,
width: w,
height: h,
}
}
fn suspended<T>(terminal: &mut ratatui::DefaultTerminal, f: impl FnOnce() -> T) -> T {
let _ = ratatui::crossterm::execute!(
std::io::stdout(),
ratatui::crossterm::event::DisableMouseCapture
);
ratatui::restore();
let out = f();
if let Ok(t) = ratatui::try_init() {
*terminal = t;
}
let _ = ratatui::crossterm::execute!(
std::io::stdout(),
ratatui::crossterm::event::EnableMouseCapture
);
let _ = terminal.clear();
out
}
fn observed_note(observed_at: Option<i64>) -> String {
const FRESH: i64 = 15 * 60;
let Some(t) = observed_at else {
return String::new();
};
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let age = now - t;
if age < FRESH {
return String::new();
}
format!("as of {}", fmt_reset(age))
}
type KeyHint = (&'static str, &'static str);
fn hint_rows() -> (&'static [KeyHint], &'static [KeyHint]) {
ALL_KEYS.split_at(5)
}
#[derive(Clone, Default)]
pub struct Usage {
pub five_h: Option<f64>,
pub five_h_reset: Option<i64>,
pub seven_d: Option<f64>,
pub seven_d_reset: Option<i64>,
pub observed_at: Option<i64>,
pub note: Option<String>,
pub on_credits: bool,
}
pub const ON_CREDITS: &str = "credits";
fn trailing_note(u: &Usage, checking: bool) -> String {
match &u.note {
Some(n) if !n.is_empty() => n.clone(),
_ => {
let age = observed_note(u.observed_at);
match (checking, u.observed_at.is_some()) {
(true, true) if age.is_empty() => "checking\u{2026}".to_string(),
(true, true) => format!("{age}, checking\u{2026}"),
_ => age,
}
}
}
}
pub struct Row {
pub name: String,
pub ident: String,
pub tools: String,
pub active: bool,
pub warn: Option<&'static str>,
pub disabled: bool,
pub needs_login: bool,
pub stale: bool,
pub is_slot: bool,
pub also: Vec<String>,
}
pub fn usage_for<'a>(
map: &'a std::collections::HashMap<String, Usage>,
r: &Row,
) -> Option<&'a Usage> {
let has_numbers = |u: &&Usage| u.five_h.is_some() || u.seven_d.is_some();
let names = || std::iter::once(&r.name).chain(r.also.iter());
names()
.filter_map(|n| map.get(n))
.find(has_numbers)
.or_else(|| names().find_map(|n| map.get(n)))
}
pub struct SessionEntry {
pub line: String,
}
pub trait TuiCtx {
fn rows(&mut self) -> Vec<Row>;
fn switch(&mut self, name: &str) -> (bool, String);
fn delete(&mut self, name: &str) -> String;
fn toggle_rotation(&mut self, _name: &str) -> String {
String::new()
}
fn sessions(&mut self, name: &str) -> (String, Vec<SessionEntry>, Vec<&'static str>);
fn rename(&mut self, old: &str, new: &str) -> (bool, String);
fn sign_in(&mut self, name: &str) -> (bool, String);
fn save_current(&mut self, name: &str) -> (bool, String);
fn doctor(&mut self) -> Vec<String>;
fn usage(&mut self) -> Vec<String>;
fn quota(&mut self) -> Vec<String>;
fn quota_pct(&mut self) -> Vec<(String, Usage)> {
Vec::new()
}
fn cached_quota(&mut self) -> Vec<(String, Usage)> {
Vec::new()
}
fn quota_pct_async(&mut self) -> std::sync::mpsc::Receiver<Vec<(String, Usage)>> {
let (tx, rx) = std::sync::mpsc::channel();
let _ = tx.send(self.quota_pct());
rx
}
fn proxy_running(&mut self) -> bool {
false
}
fn sessionwiki_present(&mut self) -> bool;
fn live_tools(&mut self) -> Vec<String>;
}
pub enum Outcome {
Quit,
OpenSession(usize),
NewConv {
tool: &'static str,
dir: Option<PathBuf>,
},
AddAccount(&'static str),
}
const NEW_CONV: [(&str, &str); 4] = [
("open a NEW Claude Code conversation", "claude-code"),
("open a NEW Codex conversation", "codex"),
("open a NEW Gemini conversation", "gemini"),
("open a NEW Antigravity conversation", "antigravity"),
];
fn new_conv_for(tools: &[&str]) -> Vec<(&'static str, &'static str)> {
if tools.is_empty() {
return NEW_CONV[..2].to_vec();
}
NEW_CONV
.iter()
.filter(|(_, t)| tools.contains(t))
.map(|&(l, t)| (l, t))
.collect()
}
enum InputKind {
Rename(String), SaveCurrent, }
enum FolderRow {
OpenHere, Up, Home, Into(PathBuf), }
fn folder_rows(cwd: &std::path::Path) -> Vec<FolderRow> {
let mut rows = vec![FolderRow::OpenHere];
if cwd.parent().is_some() {
rows.push(FolderRow::Up);
}
if dirs::home_dir().is_some_and(|h| h != cwd) {
rows.push(FolderRow::Home);
}
let mut subs: Vec<PathBuf> = std::fs::read_dir(cwd)
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| {
p.is_dir()
&& !p
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with('.'))
})
.collect();
subs.sort();
rows.extend(subs.into_iter().map(FolderRow::Into));
rows
}
fn tildify(p: &std::path::Path) -> String {
if let Some(home) = dirs::home_dir() {
if let Ok(rest) = p.strip_prefix(&home) {
return if rest.as_os_str().is_empty() {
"~".to_string()
} else {
format!("~/{}", rest.display())
};
}
}
p.display().to_string()
}
enum Screen {
Main,
Open {
label: String,
entries: Vec<SessionEntry>,
new_conv: Vec<(&'static str, &'static str)>,
},
Folder {
tool: &'static str,
cwd: PathBuf,
rows: Vec<FolderRow>,
back: (String, Vec<SessionEntry>, Vec<(&'static str, &'static str)>),
},
ToolPick,
Input {
kind: InputKind,
value: String,
},
Doctor {
lines: Vec<String>,
scroll: u16,
pending: bool,
},
Usage {
lines: Vec<String>,
scroll: u16,
pending: bool,
},
Quota {
lines: Vec<String>,
scroll: u16,
pending: bool,
},
}
fn quota_bar(pct: Option<f64>, reset_secs: Option<i64>, width: usize) -> Vec<Span<'static>> {
let empty_bg = Color::Rgb(52, 50, 64);
let Some(pct) = pct else {
return vec![Span::styled(
" ".repeat(width),
Style::default().bg(empty_bg),
)];
};
let pct = pct.clamp(0.0, 100.0);
let left = 100.0 - pct;
let reset = reset_secs.map(fmt_reset).filter(|r| !r.is_empty());
let mut label = format!("{left:.0}%");
for candidate in [
reset.as_ref().map(|r| format!("{left:.0}% left {r}")),
Some(format!("{left:.0}% left")),
]
.into_iter()
.flatten()
{
if candidate.chars().count() <= width {
label = candidate;
break;
}
}
let lw = label.chars().count().min(width);
let left_pad = (width - lw) / 2;
let text: String = " ".repeat(left_pad)
+ &label.chars().take(lw).collect::<String>()
+ &" ".repeat(width - left_pad - lw);
let filled = ((left / 100.0) * width as f64)
.round()
.clamp(0.0, width as f64) as usize;
let head: String = text.chars().take(filled).collect();
let tail: String = text.chars().skip(filled).collect();
vec![
Span::styled(
head,
Style::default()
.bg(quota_fill(pct))
.fg(Color::Rgb(24, 20, 34)) .add_modifier(Modifier::BOLD),
),
Span::styled(tail, Style::default().bg(empty_bg).fg(DEXGRAY)),
]
}
fn quota_fill(pct: f64) -> Color {
if pct >= 90.0 {
Color::Rgb(196, 92, 96) } else if pct >= 65.0 {
Color::Rgb(157, 107, 255) } else if pct >= 30.0 {
Color::Rgb(124, 92, 196) } else {
Color::Rgb(88, 74, 138) }
}
fn fmt_reset(resets_at_secs: i64) -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let left = if resets_at_secs > now {
resets_at_secs - now
} else if resets_at_secs > 0 && resets_at_secs < 60 * 60 * 24 * 30 {
resets_at_secs
} else {
return String::new();
};
let mins = left / 60;
if mins < 60 {
return format!("{mins}m");
}
let (h, m) = (mins / 60, mins % 60);
if h < 24 {
return if m > 0 {
format!("{h}h{m}m")
} else {
format!("{h}h")
};
}
let (d, rh) = (h / 24, h % 24);
if rh > 0 {
format!("{d}d{rh}h")
} else {
format!("{d}d")
}
}
struct Timing {
start: std::time::Instant,
on: bool,
marks: std::cell::RefCell<Vec<String>>,
}
impl Timing {
fn new() -> Self {
Self {
start: std::time::Instant::now(),
on: std::env::var_os("SWAPDEX_TIMING").is_some(),
marks: std::cell::RefCell::new(Vec::new()),
}
}
fn mark(&self, what: &str) {
if self.on {
self.marks.borrow_mut().push(format!(
"[timing] {:>6} ms {what}",
self.start.elapsed().as_millis()
));
}
}
fn report(&self) {
for line in self.marks.borrow().iter() {
eprintln!("{line}");
}
}
}
pub fn run(ctx: &mut dyn TuiCtx) -> Result<Outcome> {
let timing = Timing::new();
timing.mark("start");
let mut terminal = ratatui::try_init()?;
timing.mark("terminal ready");
let _ = ratatui::crossterm::execute!(
std::io::stdout(),
ratatui::crossterm::event::EnableMouseCapture
);
let mut rows = ctx.rows();
timing.mark("accounts read");
let mut state = ListState::default();
state.select(Some(rows.iter().position(|r| r.active).unwrap_or(0)));
let mut open_state = ListState::default();
let mut status = String::new();
let mut confirm_delete: Option<usize> = None;
let wiki_present = ctx.sessionwiki_present();
let mut screen = Screen::Main;
let mut onboard_live: Vec<String> = if rows.is_empty() {
ctx.live_tools()
} else {
Vec::new()
};
let mut main_area = Rect::default();
let cached = ctx.cached_quota();
timing.mark("remembered usage read");
let mut quota_pct: Option<std::collections::HashMap<String, Usage>> =
(!cached.is_empty()).then(|| cached.into_iter().collect());
let mut first_frame = true;
let mut fetch_marked = false;
let mut first_key_marked = false;
let mut quota_rx: Option<std::sync::mpsc::Receiver<Vec<(String, Usage)>>> = None;
let mut quota_fetched: Option<std::time::Instant> = None;
let outcome = 'ui: loop {
terminal.draw(|f| {
let [main, foot, help] = Layout::vertical([
Constraint::Min(3),
Constraint::Length(1),
Constraint::Length(2),
])
.areas(f.area());
main_area = main;
match &screen {
Screen::Main => {
let show_logo = main.height >= 14;
let head_h = if show_logo { 8 } else { 0 };
let [header, body] =
Layout::vertical([Constraint::Length(head_h), Constraint::Min(3)])
.areas(main);
if show_logo {
let mut lines = logo_lines();
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" Claude Code \u{b7} Codex \u{b7} Gemini \u{b7} Antigravity - one command, all local",
Style::default().fg(MUTED),
)));
f.render_widget(Paragraph::new(lines), header);
}
let name_w = rows.iter().map(|r| r.name.chars().count()).max().unwrap_or(0);
let ident_w = rows
.iter()
.map(|r| r.ident.chars().count())
.max()
.unwrap_or(0);
let bar_col = usage_bar_column(&rows);
let heads = group_heads(&rows);
let items: Vec<ListItem> = rows
.iter()
.enumerate()
.map(|(ri, r)| {
let (glyph, gstyle) = if r.active {
("\u{25cf} ", Style::default().fg(VIOLET))
} else {
("\u{25cb} ", Style::default().fg(Color::DarkGray))
};
let name_style = if r.active {
Style::default().fg(VIOLET).add_modifier(Modifier::BOLD)
} else {
Style::default().add_modifier(Modifier::BOLD)
};
let u_now = quota_pct.as_ref().and_then(|q| usage_for(q, r));
let (st, st_color) = account_status(r, u_now);
let selected = state.selected() == Some(ri);
let text_cols;
let mut top = vec![
Span::styled(
if selected { "\u{258c} " } else { " " },
Style::default().fg(VIOLET).add_modifier(Modifier::BOLD),
),
Span::styled(
format!("{:>2} ", ri + 1),
Style::default().fg(Color::Rgb(96, 94, 116)),
),
Span::styled(glyph, gstyle),
Span::styled(format!("{:<name_w$}", r.name), name_style),
Span::raw(" "),
Span::styled(
format!("{:<ident_w$}", r.ident),
Style::default().fg(DEXGRAY),
),
Span::raw(" "),
Span::styled(format!("{st:<8}"), Style::default().fg(st_color)),
];
{
let u = quota_pct
.as_ref()
.and_then(|q| usage_for(q, r).cloned())
.unwrap_or_default();
text_cols = top.len();
let left_w: usize =
top.iter().map(|s| s.content.chars().count()).sum();
let inner = (body.width as usize).saturating_sub(4);
let bw = if inner.saturating_sub(bar_col) >= 34 {
12
} else {
7
};
let needed = 3 + bw + 5 + bw;
let start = bar_col.min(inner.saturating_sub(needed));
top.push(
Span::raw(" ".repeat(start.saturating_sub(left_w).max(1))),
);
top.push(Span::styled("5h ", Style::default().fg(MUTED)));
top.extend(quota_bar(u.five_h, u.five_h_reset, bw));
top.push(Span::styled(" 7d ", Style::default().fg(MUTED)));
top.extend(quota_bar(u.seven_d, u.seven_d_reset, bw));
let note = trailing_note(&u, quota_rx.is_some());
if !note.is_empty() {
top.push(Span::styled(
format!(" {note}"),
Style::default().fg(Color::Rgb(96, 94, 116)),
));
}
}
let top = mark_selected(top, text_cols, selected);
let mut lines = Vec::with_capacity(4);
if heads.get(ri).copied().unwrap_or(false) {
if ri > 0 {
lines.push(Line::from(""));
}
let g = group_of(&r.tools);
let title: String = g
.to_uppercase()
.chars()
.flat_map(|c| [c, ' '])
.collect();
let fleet = fleet_of(
rows.iter().filter(|x| group_of(&x.tools) == g),
|x| quota_pct.as_ref().and_then(|q| usage_for(q, x)),
);
let summary = fleet_line(&fleet);
let rule_w = bar_col.saturating_sub(
title.chars().count() + 3 + summary.chars().count(),
);
lines.push(Line::from(vec![
Span::styled(
format!(" {title}"),
Style::default().fg(VIOLET).add_modifier(Modifier::BOLD),
),
Span::styled(
"\u{2500}".repeat(rule_w.clamp(2, 40)),
Style::default().fg(Color::Rgb(72, 70, 88)),
),
Span::styled(
summary,
Style::default().fg(Color::Rgb(140, 138, 160)),
),
]));
lines.push(Line::from(""));
}
lines.push(Line::from(top));
ListItem::new(lines)
})
.collect();
if rows.is_empty() {
let mut lines = vec![
Line::from(""),
Line::from(Span::styled(
" Welcome to swapdex.",
Style::default().fg(VIOLET).add_modifier(Modifier::BOLD),
)),
Line::from(""),
];
if onboard_live.is_empty() {
lines.push(Line::from(Span::styled(
" You're not logged into any tool yet. Sign in to Claude Code,",
Style::default().fg(MUTED),
)));
lines.push(Line::from(Span::styled(
" Codex, Gemini, or Antigravity first, then come back.",
Style::default().fg(MUTED),
)));
lines.push(Line::from(""));
lines.push(key_hints(&[
("a", "log in to a new account"),
("q", "quit"),
]));
} else {
lines.push(Line::from(vec![
Span::styled(" You're logged into ", Style::default().fg(MUTED)),
Span::styled(
onboard_live.join(", "),
Style::default().fg(Color::Reset).add_modifier(Modifier::BOLD),
),
Span::styled(".", Style::default().fg(MUTED)),
]));
lines.push(Line::from(""));
lines.push(key_hints(&[
("s", "save these as your first profile"),
("a", "add a different account"),
("q", "quit"),
]));
}
f.render_widget(
Paragraph::new(lines).block(list_block(" welcome ")),
body,
);
} else {
let list = List::new(items)
.block(list_block(" accounts "))
.highlight_style(Style::default().add_modifier(Modifier::BOLD));
f.render_stateful_widget(list, body, &mut state);
}
let foot_line = if let Some(i) = confirm_delete {
Line::from(Span::styled(
format!(
" stop managing '{}'? its login and folder stay. y / N",
rows[i].name
),
Style::default().fg(Color::Rgb(200, 150, 90)),
))
} else {
Line::from(Span::styled(
format!(" {}", status),
Style::default().fg(MUTED),
))
};
f.render_widget(Paragraph::new(foot_line), foot);
if rows.is_empty() {
f.render_widget(
Paragraph::new(key_hints(&[("?", "health"), ("q", "quit")])),
help,
);
} else {
let (a, b) = hint_rows();
f.render_widget(Paragraph::new(vec![key_hints(a), key_hints(b)]), help);
}
}
Screen::Open { label, entries, new_conv } => {
let mut items: Vec<ListItem> = new_conv
.iter()
.map(|(nlabel, _)| {
ListItem::new(Line::from(Span::styled(
*nlabel,
Style::default().fg(VIOLET),
)))
})
.collect();
items.extend(
entries
.iter()
.map(|e| ListItem::new(Line::from(e.line.clone()))),
);
let list = List::new(items)
.block(list_block_titled(&format!(" {label} ")))
.highlight_style(
Style::default()
.bg(Color::Rgb(50, 47, 68))
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("\u{2503} ");
f.render_stateful_widget(list, main, &mut open_state);
let foot_line = if wiki_present {
Span::styled(format!(" {status}"), Style::default().fg(MUTED))
} else {
Span::styled(
" tip: install sessionwiki to search these, trace a file to its \
session, and group by account",
Style::default().fg(MUTED),
)
};
f.render_widget(Paragraph::new(Line::from(foot_line)), foot);
f.render_widget(
Paragraph::new(key_hints(&[("\u{21b5}", "open"), ("esc", "back")])),
help,
);
}
Screen::Folder { tool, cwd, rows: frows, .. } => {
let name = NEW_CONV
.iter()
.find(|(_, t)| t == tool)
.map(|(l, _)| *l)
.unwrap_or("open");
let items: Vec<ListItem> = frows
.iter()
.map(|r| match r {
FolderRow::OpenHere => ListItem::new(Line::from(Span::styled(
"\u{25b8} open here",
Style::default().fg(VIOLET).add_modifier(Modifier::BOLD),
))),
FolderRow::Up => ListItem::new(Line::from(Span::styled(
"\u{2191} ..",
Style::default().fg(DEXGRAY),
))),
FolderRow::Home => ListItem::new(Line::from(Span::styled(
"\u{2302} ~ (home)",
Style::default().fg(DEXGRAY),
))),
FolderRow::Into(p) => {
let leaf = p
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("?");
ListItem::new(Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled(
format!("{leaf}/"),
Style::default().fg(Color::Reset),
),
]))
}
})
.collect();
let list = List::new(items)
.block(list_block_titled(&format!(
" {name} \u{2014} {} ",
tildify(cwd)
)))
.highlight_style(
Style::default()
.bg(Color::Rgb(50, 47, 68))
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("\u{2503} ");
f.render_stateful_widget(list, main, &mut open_state);
f.render_widget(Paragraph::new(""), foot);
f.render_widget(
Paragraph::new(key_hints(&[
("\u{21b5}", "enter / open here"),
("\u{2191}\u{2193}", "move"),
("esc", "back"),
])),
help,
);
}
Screen::ToolPick => {
let items: Vec<ListItem> =
["Claude Code", "Codex", "Gemini CLI", "Antigravity"]
.iter()
.map(|l| ListItem::new(Line::from(*l)))
.collect();
let list = List::new(items)
.block(list_block(" add an account - which tool? "))
.highlight_style(
Style::default()
.bg(Color::Rgb(50, 47, 68))
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("\u{2503} ");
f.render_stateful_widget(list, main, &mut open_state);
f.render_widget(Paragraph::new(""), foot);
f.render_widget(
Paragraph::new(key_hints(&[("\u{21b5}", "choose"), ("esc", "back")])),
help,
);
}
Screen::Input { kind, value } => {
let (title, prompt) = match kind {
InputKind::Rename(old) => (
format!(" rename {old} "),
"type a new name, then press Enter".to_string(),
),
InputKind::SaveCurrent => (
" save the accounts you are signed into ".to_string(),
"name them, then press Enter".to_string(),
),
};
let dialog = centered(main, 60.min(main.width.saturating_sub(4)), 5);
f.render_widget(ratatui::widgets::Clear, dialog);
f.render_widget(
Paragraph::new(vec![
Line::from(vec![Span::styled(
format!(" {prompt}"),
Style::default().fg(Color::White),
)]),
Line::from(""),
Line::from(vec![
Span::raw(" "),
Span::styled(
format!("{value}\u{2588}"),
Style::default().fg(VIOLET).add_modifier(Modifier::BOLD),
),
]),
])
.block(list_block_titled(&title)),
dialog,
);
f.render_widget(
Paragraph::new(Line::from(Span::styled(
format!(" {status}"),
Style::default().fg(MUTED),
))),
foot,
);
f.render_widget(
Paragraph::new(key_hints(&[("\u{21b5}", "confirm"), ("esc", "cancel")])),
help,
);
}
Screen::Doctor { lines, scroll, .. } => {
let mut text: Vec<Line> = vec![Line::from(Span::styled(
" keys",
Style::default().fg(VIOLET).add_modifier(Modifier::BOLD),
))];
for chunk in ALL_KEYS.chunks(2) {
let mut spans = vec![Span::raw(" ")];
for (k, label) in chunk {
spans.push(Span::styled(
format!("{k:>2} "),
Style::default().fg(VIOLET).add_modifier(Modifier::BOLD),
));
spans.push(Span::styled(
format!("{label:<22}"),
Style::default().fg(MUTED),
));
}
text.push(Line::from(spans));
}
text.push(Line::from(""));
text.push(Line::from(Span::styled(
" health",
Style::default().fg(VIOLET).add_modifier(Modifier::BOLD),
)));
let checks: Vec<Line> = lines
.iter()
.map(|l| {
let style = if l.contains("problem") {
Style::default().fg(Color::Rgb(210, 140, 90))
} else if l.contains(" ok ") || l.contains("healthy") {
Style::default().fg(Color::Rgb(120, 190, 140))
} else {
Style::default().fg(DEXGRAY)
};
Line::from(Span::styled(format!(" {l}"), style))
})
.collect();
text.extend(checks);
f.render_widget(
Paragraph::new(text)
.scroll((*scroll, 0))
.block(list_block(" keys and health ")),
main,
);
f.render_widget(Paragraph::new(""), foot);
f.render_widget(
Paragraph::new(key_hints(&[
("\u{2191}\u{2193}", "scroll"),
("esc", "back"),
])),
help,
);
}
Screen::Usage { lines, scroll, .. } => {
let text: Vec<Line> = lines
.iter()
.map(|l| {
let style = if l.trim_start().starts_with('@') {
Style::default().fg(VIOLET)
} else if l.contains("note:") || l.contains("(") {
Style::default().fg(MUTED)
} else {
Style::default().fg(DEXGRAY)
};
Line::from(Span::styled(format!(" {l}"), style))
})
.collect();
f.render_widget(
Paragraph::new(text)
.scroll((*scroll, 0))
.block(list_block(" usage - tokens used (local, this machine) ")),
main,
);
f.render_widget(Paragraph::new(""), foot);
f.render_widget(
Paragraph::new(key_hints(&[
("\u{2191}\u{2193}", "scroll"),
("esc", "back"),
])),
help,
);
}
Screen::Quota { lines, scroll, .. } => {
let text: Vec<Line> = lines
.iter()
.map(|l| {
let style = if l.contains("% left") {
Style::default().fg(VIOLET)
} else if l.contains("expired")
|| l.contains("rejected")
|| l.contains("unexpected")
|| l.contains("could not reach")
{
Style::default().fg(Color::Rgb(200, 150, 90))
} else if l.starts_with(' ') || l.contains("network") || l.contains("(") {
Style::default().fg(MUTED)
} else {
Style::default().fg(DEXGRAY)
};
Line::from(Span::styled(format!(" {l}"), style))
})
.collect();
f.render_widget(
Paragraph::new(text)
.scroll((*scroll, 0))
.block(list_block(" quota - remaining (live from Anthropic) ")),
main,
);
f.render_widget(Paragraph::new(""), foot);
f.render_widget(
Paragraph::new(key_hints(&[
("\u{2191}\u{2193}", "scroll"),
("esc", "back"),
])),
help,
);
}
}
})?;
if let Screen::Doctor { pending: true, .. } = &screen {
let lines = ctx.doctor();
screen = Screen::Doctor {
lines,
scroll: 0,
pending: false,
};
continue;
}
if let Screen::Usage { pending: true, .. } = &screen {
let lines = ctx.usage();
screen = Screen::Usage {
lines,
scroll: 0,
pending: false,
};
continue;
}
if let Screen::Quota { pending: true, .. } = &screen {
let lines = ctx.quota();
screen = Screen::Quota {
lines,
scroll: 0,
pending: false,
};
continue;
}
if first_frame {
timing.mark("first frame drawn");
first_frame = false;
}
if quota_rx.is_some() && !fetch_marked {
timing.mark("usage read started (off the loop)");
fetch_marked = true;
}
if let Some(rx) = quota_rx.as_ref() {
if let Ok(got) = rx.try_recv() {
quota_pct = Some(got.into_iter().collect());
quota_fetched = Some(std::time::Instant::now());
quota_rx = None;
}
}
let stale_quota = quota_fetched.is_none_or(|t: std::time::Instant| {
t.elapsed() >= std::time::Duration::from_secs(QUOTA_REFRESH_SECS)
});
if matches!(screen, Screen::Main) && stale_quota && quota_rx.is_none() && !rows.is_empty() {
quota_rx = Some(ctx.quota_pct_async());
}
let mut click_activate = false;
if !event::poll(std::time::Duration::from_millis(500))? {
continue;
}
if !first_key_marked {
timing.mark("first input event received");
first_key_marked = true;
}
let key = match event::read()? {
Event::Key(k) if k.kind == KeyEventKind::Press => k,
Event::Mouse(m) => {
use ratatui::crossterm::event::{MouseButton, MouseEventKind as MK};
if let Screen::Doctor { lines, scroll, .. }
| Screen::Usage { lines, scroll, .. }
| Screen::Quota { lines, scroll, .. } = &mut screen
{
let max = (lines.len() as u16).saturating_sub(1);
match m.kind {
MK::ScrollDown => *scroll = (*scroll + 1).min(max),
MK::ScrollUp => *scroll = scroll.saturating_sub(1),
_ => {}
}
continue;
}
let list_len = match &screen {
Screen::Main => rows.len(),
Screen::Open {
entries, new_conv, ..
} => entries.len() + new_conv.len(),
Screen::ToolPick => 4,
Screen::Folder { rows: frows, .. } => frows.len(),
_ => 0,
};
let is_main = matches!(screen, Screen::Main);
let sel = if is_main { &mut state } else { &mut open_state };
match m.kind {
MK::ScrollDown if list_len > 0 => {
let i = sel.selected().unwrap_or(0);
sel.select(Some((i + 1).min(list_len - 1)));
}
MK::ScrollUp if list_len > 0 => {
let i = sel.selected().unwrap_or(0);
sel.select(Some(i.saturating_sub(1)));
}
MK::Down(MouseButton::Left) if list_len > 0 => {
let header = if is_main && main_area.height >= 14 {
8u16
} else {
0
};
let per = if is_main { 3 } else { 1 }; let top = main_area.y + header + 1;
let bottom = main_area.y + main_area.height.saturating_sub(1);
if m.row >= top && m.row < bottom {
let idx = if is_main {
let heights: Vec<u16> = group_heads(&rows)
.iter()
.enumerate()
.map(|(i, h)| match (*h, i) {
(true, 0) => 3, (true, _) => 4, (false, _) => 1, })
.collect();
click_item_index(sel.offset(), m.row, top, &heights)
} else {
click_row_index(sel.offset(), m.row, top, per)
};
if idx < list_len {
sel.select(Some(idx));
click_activate = !is_main;
}
}
}
_ => {}
}
if click_activate {
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)
} else {
continue;
}
}
_ => continue,
};
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
break 'ui Outcome::Quit;
}
match &mut screen {
Screen::Main => {
if let Some(i) = confirm_delete {
if matches!(key.code, KeyCode::Char('y') | KeyCode::Char('Y')) {
if let Some(row) = rows.get(i) {
status = ctx.delete(&row.name);
rows = ctx.rows();
}
clamp_selection(&mut state, rows.len());
onboard_live = if rows.is_empty() {
ctx.live_tools()
} else {
Vec::new()
};
}
confirm_delete = None;
continue;
}
match key.code {
KeyCode::Char('q') | KeyCode::Esc => break 'ui Outcome::Quit,
KeyCode::Down | KeyCode::Char('j') => {
let i = state.selected().unwrap_or(0);
state.select(Some((i + 1).min(rows.len().saturating_sub(1))));
}
KeyCode::Up | KeyCode::Char('k') => {
let i = state.selected().unwrap_or(0);
state.select(Some(i.saturating_sub(1)));
}
KeyCode::Char(c) if c.is_ascii_digit() && c != '0' && !rows.is_empty() => {
let idx = (c as usize) - ('1' as usize);
if let Some(name) = rows.get(idx).map(|r| r.name.clone()) {
state.select(Some(idx));
let (ok, msg) = ctx.switch(&name);
rows = ctx.rows();
clamp_selection(&mut state, rows.len());
status = if !ok {
msg
} else if ctx.proxy_running() {
format!("{name} now serves the running session")
} else {
msg
};
}
}
KeyCode::Enter if !rows.is_empty() => {
if let Some(name) = state
.selected()
.and_then(|i| rows.get(i))
.map(|r| r.name.clone())
{
let (ok, msg) = ctx.switch(&name);
rows = ctx.rows();
clamp_selection(&mut state, rows.len());
status = if !ok {
msg
} else if ctx.proxy_running() {
format!("{name} now serves the running session")
} else {
msg
};
}
}
KeyCode::Char('o') if !rows.is_empty() => {
if let Some(name) = state
.selected()
.and_then(|i| rows.get(i))
.map(|r| r.name.clone())
{
let (ok, msg) = ctx.switch(&name);
status = msg;
rows = ctx.rows();
clamp_selection(&mut state, rows.len());
if ok {
if ctx.proxy_running() {
status = format!("{name} also serves the session already open");
}
let (label, entries, tools) = ctx.sessions(&name);
let new_conv = new_conv_for(&tools);
open_state.select(Some(0));
screen = Screen::Open {
label,
entries,
new_conv,
};
}
}
}
KeyCode::Char('a') => {
open_state.select(Some(0));
screen = Screen::ToolPick;
}
KeyCode::Char('s') if rows.is_empty() && !onboard_live.is_empty() => {
screen = Screen::Input {
kind: InputKind::SaveCurrent,
value: String::new(),
};
}
KeyCode::Char('n') if !rows.is_empty() => {
if let Some(name) = state
.selected()
.and_then(|i| rows.get(i))
.map(|r| r.name.clone())
{
screen = Screen::Input {
kind: InputKind::Rename(name),
value: String::new(),
};
}
}
KeyCode::Char('r') => {
let (_ok, msg) = ctx.switch("-");
status = msg;
rows = ctx.rows();
}
KeyCode::Char('l') if !rows.is_empty() => {
if let Some(name) = state
.selected()
.and_then(|i| rows.get(i))
.map(|r| r.name.clone())
{
let (_ok, msg) = suspended(&mut terminal, || ctx.sign_in(&name));
status = msg;
rows = ctx.rows();
clamp_selection(&mut state, rows.len());
}
}
KeyCode::Char('e') if !rows.is_empty() => {
if let Some(name) = state
.selected()
.and_then(|i| rows.get(i))
.map(|r| r.name.clone())
{
status = ctx.toggle_rotation(&name);
rows = ctx.rows();
clamp_selection(&mut state, rows.len());
}
}
KeyCode::Char('d') if !rows.is_empty() => {
confirm_delete = state.selected();
}
KeyCode::Char('?') => {
screen = Screen::Doctor {
lines: vec!["running health check...".into()],
scroll: 0,
pending: true,
};
}
KeyCode::Char('u') if !rows.is_empty() => {
screen = Screen::Usage {
lines: vec!["computing usage...".into()],
scroll: 0,
pending: true,
};
}
KeyCode::Char('%') => {
screen = Screen::Quota {
lines: vec!["fetching remaining quota from Anthropic...".into()],
scroll: 0,
pending: true,
};
}
_ => {}
}
}
Screen::Open {
entries, new_conv, ..
} => match key.code {
KeyCode::Esc | KeyCode::Char('q') => {
rows = ctx.rows();
screen = Screen::Main;
}
KeyCode::Down | KeyCode::Char('j') => {
let max = (entries.len() + new_conv.len()).saturating_sub(1);
let i = open_state.selected().unwrap_or(0);
open_state.select(Some((i + 1).min(max)));
}
KeyCode::Up | KeyCode::Char('k') => {
let i = open_state.selected().unwrap_or(0);
open_state.select(Some(i.saturating_sub(1)));
}
KeyCode::Enter => {
let i = open_state.selected().unwrap_or(0);
let Some(&(_, tool)) = new_conv.get(i) else {
break 'ui Outcome::OpenSession(i - new_conv.len());
};
let cwd = std::env::current_dir()
.ok()
.or_else(dirs::home_dir)
.unwrap_or_else(|| PathBuf::from("/"));
let frows = folder_rows(&cwd);
open_state.select(Some(0));
if let Screen::Open {
label,
entries,
new_conv: nc,
} = std::mem::replace(
&mut screen,
Screen::Folder {
tool,
cwd,
rows: frows,
back: (String::new(), Vec::new(), Vec::new()),
},
) {
if let Screen::Folder { back, .. } = &mut screen {
*back = (label, entries, nc);
}
}
}
_ => {}
},
Screen::Folder {
tool,
cwd,
rows: frows,
back,
} => match key.code {
KeyCode::Esc => {
let (label, entries, new_conv) = std::mem::take(back);
screen = Screen::Open {
label,
entries,
new_conv,
};
}
KeyCode::Down | KeyCode::Char('j') => {
let i = open_state.selected().unwrap_or(0);
open_state.select(Some((i + 1).min(frows.len().saturating_sub(1))));
}
KeyCode::Up | KeyCode::Char('k') => {
let i = open_state.selected().unwrap_or(0);
open_state.select(Some(i.saturating_sub(1)));
}
KeyCode::Left | KeyCode::Backspace => {
if let Some(parent) = cwd.parent() {
*cwd = parent.to_path_buf();
*frows = folder_rows(cwd);
open_state.select(Some(0));
}
}
KeyCode::Enter | KeyCode::Right => {
let i = open_state.selected().unwrap_or(0);
match frows.get(i) {
Some(FolderRow::OpenHere) => {
break 'ui Outcome::NewConv {
tool,
dir: Some(cwd.clone()),
};
}
Some(FolderRow::Up) => {
if let Some(parent) = cwd.parent() {
*cwd = parent.to_path_buf();
*frows = folder_rows(cwd);
open_state.select(Some(0));
}
}
Some(FolderRow::Home) => {
if let Some(h) = dirs::home_dir() {
*cwd = h;
*frows = folder_rows(cwd);
open_state.select(Some(0));
}
}
Some(FolderRow::Into(p)) => {
*cwd = p.clone();
*frows = folder_rows(cwd);
open_state.select(Some(0));
}
None => {}
}
}
_ => {}
},
Screen::ToolPick => match key.code {
KeyCode::Esc | KeyCode::Char('q') => screen = Screen::Main,
KeyCode::Down | KeyCode::Char('j') => {
let i = open_state.selected().unwrap_or(0);
open_state.select(Some((i + 1).min(3)));
}
KeyCode::Up | KeyCode::Char('k') => {
let i = open_state.selected().unwrap_or(0);
open_state.select(Some(i.saturating_sub(1)));
}
KeyCode::Enter => {
let tool = ["claude-code", "codex", "gemini", "antigravity"]
[open_state.selected().unwrap_or(0)];
break 'ui Outcome::AddAccount(tool);
}
_ => {}
},
Screen::Input { kind, value } => match key.code {
KeyCode::Esc => screen = Screen::Main,
KeyCode::Backspace => {
value.pop();
}
KeyCode::Char(c) => value.push(c),
KeyCode::Enter => {
let name = value.trim().to_string();
if name.is_empty() {
screen = Screen::Main;
} else {
let (ok, msg) = match kind {
InputKind::Rename(old) => ctx.rename(old, &name),
InputKind::SaveCurrent => ctx.save_current(&name),
};
status = msg;
rows = ctx.rows();
onboard_live = if rows.is_empty() {
ctx.live_tools()
} else {
Vec::new()
};
if ok {
state.select(rows.iter().position(|r| r.name == name).or(Some(0)));
}
screen = Screen::Main;
}
}
_ => {}
},
Screen::Doctor { lines, scroll, .. } => match key.code {
KeyCode::Esc | KeyCode::Char('q') => screen = Screen::Main,
KeyCode::Down | KeyCode::Char('j') => {
let max = (lines.len() as u16).saturating_sub(1);
*scroll = (*scroll + 1).min(max);
}
KeyCode::Up | KeyCode::Char('k') => *scroll = scroll.saturating_sub(1),
_ => {}
},
Screen::Usage { lines, scroll, .. } => match key.code {
KeyCode::Esc | KeyCode::Char('q') => screen = Screen::Main,
KeyCode::Down | KeyCode::Char('j') => {
let max = (lines.len() as u16).saturating_sub(1);
*scroll = (*scroll + 1).min(max);
}
KeyCode::Up | KeyCode::Char('k') => *scroll = scroll.saturating_sub(1),
_ => {}
},
Screen::Quota { lines, scroll, .. } => match key.code {
KeyCode::Esc | KeyCode::Char('q') => screen = Screen::Main,
KeyCode::Down | KeyCode::Char('j') => {
let max = (lines.len() as u16).saturating_sub(1);
*scroll = (*scroll + 1).min(max);
}
KeyCode::Up | KeyCode::Char('k') => *scroll = scroll.saturating_sub(1),
_ => {}
},
}
};
let _ = ratatui::crossterm::execute!(
std::io::stdout(),
ratatui::crossterm::event::DisableMouseCapture
);
ratatui::restore();
timing.report();
Ok(outcome)
}
#[derive(Debug, Default, PartialEq)]
pub struct Fleet {
pub ready: usize,
pub total: usize,
pub left_pct: Option<f64>,
pub next_reset: Option<i64>,
}
pub fn fleet_line(f: &Fleet) -> String {
if f.total < 2 {
return String::new();
}
let mut s = format!(" {}/{} ready", f.ready, f.total);
if let Some(left) = f.left_pct {
s.push_str(&format!(" · {left:.0}% left"));
}
s
}
pub fn fleet_of<'a>(
rows: impl Iterator<Item = &'a Row>,
usage: impl Fn(&Row) -> Option<&'a Usage>,
) -> Fleet {
let (mut f, mut measured, mut sum) = (Fleet::default(), 0usize, 0.0f64);
for r in rows {
f.total += 1;
let u = usage(r);
let spent = u.is_some_and(|u| {
!u.on_credits
&& (u.five_h.is_some_and(|p| p >= SPENT) || u.seven_d.is_some_and(|p| p >= SPENT))
});
if !r.needs_login && !spent {
f.ready += 1;
}
if let Some(u) = u {
if let Some(worst) = [u.five_h, u.seven_d]
.into_iter()
.flatten()
.fold(None::<f64>, |acc, p| Some(acc.map_or(p, |a: f64| a.max(p))))
{
measured += 1;
sum += (100.0 - worst).clamp(0.0, 100.0);
}
for reset in [u.five_h_reset, u.seven_d_reset].into_iter().flatten() {
f.next_reset = Some(f.next_reset.map_or(reset, |cur: i64| cur.min(reset)));
}
}
}
if measured > 0 {
f.left_pct = Some(sum / measured as f64);
}
f
}
#[cfg(test)]
mod fleet_tests {
use super::*;
fn row(name: &str, needs_login: bool) -> Row {
Row {
name: name.into(),
ident: String::new(),
tools: "claude-code".into(),
active: false,
warn: None,
disabled: false,
needs_login,
stale: false,
is_slot: true,
also: Vec::new(),
}
}
fn used(five_h: f64, reset: i64) -> Usage {
Usage {
five_h: Some(five_h),
five_h_reset: Some(reset),
..Default::default()
}
}
#[test]
fn it_counts_what_could_actually_serve() {
let rows = [
row("fresh", false),
row("spent", false),
row("nologin", true),
];
let u = |r: &Row| match r.name.as_str() {
"fresh" => Some(Box::leak(Box::new(used(10.0, 500))) as &Usage),
"spent" => Some(Box::leak(Box::new(used(100.0, 200))) as &Usage),
_ => None,
};
let f = fleet_of(rows.iter(), u);
assert_eq!(f.total, 3);
assert_eq!(
f.ready, 1,
"the spent one and the signed-out one are not capacity"
);
assert_eq!(f.left_pct, Some(45.0), "90 and 0 across the two measured");
assert_eq!(f.next_reset, Some(200), "the soonest anything comes back");
}
#[test]
fn unmeasured_is_not_reported_as_empty() {
let rows = [row("a", false)];
let f = fleet_of(rows.iter(), |_| None);
assert_eq!(f.left_pct, None);
assert_eq!(f.ready, 1, "unmeasured but signed in - it can still serve");
}
#[test]
fn credits_still_count_as_ready() {
let rows = [row("credits", false)];
let f = fleet_of(rows.iter(), |_| {
Some(Box::leak(Box::new(Usage {
five_h: Some(100.0),
on_credits: true,
..Default::default()
})))
});
assert_eq!(f.ready, 1);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clamp_selection_keeps_index_in_bounds() {
let mut s = ListState::default();
s.select(Some(3));
clamp_selection(&mut s, 2); assert_eq!(s.selected(), Some(1), "clamped to last");
clamp_selection(&mut s, 0); assert_eq!(s.selected(), None, "no selection on empty list");
clamp_selection(&mut s, 3); assert_eq!(s.selected(), Some(0), "reselect top when non-empty");
s.select(Some(1));
clamp_selection(&mut s, 5); assert_eq!(s.selected(), Some(1), "untouched when in range");
}
#[test]
fn click_row_index_accounts_for_scroll_offset() {
assert_eq!(click_row_index(0, 5, 5, 1), 0);
assert_eq!(click_row_index(0, 7, 5, 1), 2);
assert_eq!(click_row_index(3, 5, 5, 1), 3);
assert_eq!(click_row_index(3, 7, 5, 1), 5);
assert_eq!(click_row_index(0, 6, 5, 3), 0);
assert_eq!(click_row_index(0, 8, 5, 3), 1);
}
#[test]
fn new_conv_only_offers_the_profiles_tools() {
let one = super::new_conv_for(&["claude-code"]);
assert_eq!(one.len(), 1);
assert_eq!(one[0].1, "claude-code");
let two = super::new_conv_for(&["gemini", "codex"]);
let tools: Vec<&str> = two.iter().map(|(_, t)| *t).collect();
assert_eq!(tools, vec!["codex", "gemini"]);
}
#[test]
fn folder_rows_lead_with_open_here_and_hide_dotfiles() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("visible")).unwrap();
std::fs::create_dir(dir.path().join(".hidden")).unwrap();
std::fs::write(dir.path().join("afile"), b"x").unwrap();
let rows = folder_rows(dir.path());
assert!(matches!(rows[0], FolderRow::OpenHere), "open-here is first");
assert!(
rows.iter().any(|r| matches!(r, FolderRow::Up)),
"parent exists -> Up row present"
);
let into: Vec<_> = rows
.iter()
.filter_map(|r| match r {
FolderRow::Into(p) => p.file_name().and_then(|n| n.to_str()),
_ => None,
})
.collect();
assert_eq!(into, vec!["visible"], "only non-dot subdirs, no files");
}
#[test]
fn tildify_collapses_home() {
if let Some(home) = dirs::home_dir() {
assert_eq!(tildify(&home), "~");
assert_eq!(tildify(&home.join("proj")), "~/proj");
}
assert_eq!(tildify(std::path::Path::new("/etc")), "/etc");
}
#[test]
fn duplicate_identities_collapse_to_the_row_that_can_serve() {
let row = |name: &str, ident: &str, needs_login: bool, active: bool| Row {
name: name.into(),
ident: ident.into(),
tools: "claude-code".into(),
active,
warn: None,
disabled: false,
needs_login,
is_slot: false,
stale: false,
also: Vec::new(),
};
let out = dedupe_by_identity(vec![
row("rnd", "rnd@x.co [team]", true, false),
row("rnd-slot", "rnd@x.co", false, true),
]);
assert_eq!(out.len(), 1);
assert_eq!(out[0].name, "rnd-slot");
let mut snap = row("rnd", "rnd@x.co [team]", false, false);
let mut slot = row("rnd-slot", "rnd@x.co", false, false);
snap.is_slot = false;
slot.is_slot = true;
let out = dedupe_by_identity(vec![snap, slot]);
assert_eq!(out.len(), 1);
assert_eq!(out[0].name, "rnd-slot", "the switchable row survives");
let out = dedupe_by_identity(vec![
row("a", "a@x.co", false, false),
row("b", "b@x.co", false, false),
]);
assert_eq!(out.len(), 2);
let mut claude = row("claude", "me@x.co [max]", false, false);
let mut codex = row("codex", "me@x.co [chatgpt]", false, true);
claude.tools = "claude-code".into();
codex.tools = "codex".into();
let out = dedupe_by_identity(vec![claude, codex]);
assert_eq!(
out.iter().map(|r| r.name.as_str()).collect::<Vec<_>>(),
vec!["claude", "codex"],
"one email on two tools stays two accounts"
);
let out = dedupe_by_identity(vec![
row("fresh", "", true, false),
row("also", "", true, false),
row("known", "k@x.co", false, false),
]);
assert_eq!(out.len(), 3);
let out = dedupe_by_identity(vec![
row("first", "same@x.co", true, false),
row("other", "b@x.co", false, false),
row("better", "same@x.co", false, false),
]);
assert_eq!(
out.iter().map(|r| r.name.as_str()).collect::<Vec<_>>(),
vec!["better", "other"]
);
}
#[test]
fn a_merged_row_still_finds_usage_filed_under_the_name_it_absorbed() {
let row = |name: &str, is_slot: bool| Row {
name: name.into(),
ident: "rnd@x.co".into(),
tools: "claude-code".into(),
active: false,
warn: None,
disabled: false,
needs_login: false,
stale: false,
is_slot,
also: Vec::new(),
};
let out = dedupe_by_identity(vec![row("rnd", false), row("rnd-slot", true)]);
assert_eq!(out.len(), 1);
assert_eq!(out[0].name, "rnd-slot");
assert_eq!(out[0].also, vec!["rnd".to_string()], "the absorbed name");
let measured = |pct: f64| Usage {
five_h: Some(pct),
..Default::default()
};
let reason = |why: &str| Usage {
note: Some(why.into()),
..Default::default()
};
let mut usage = std::collections::HashMap::new();
usage.insert("rnd".to_string(), measured(42.0));
assert_eq!(
usage_for(&usage, &out[0]).and_then(|u| u.five_h),
Some(42.0),
"a reading taken for the snapshot belongs to the same account"
);
usage.insert("rnd-slot".to_string(), measured(7.0));
assert_eq!(usage_for(&usage, &out[0]).and_then(|u| u.five_h), Some(7.0));
usage.insert("rnd-slot".to_string(), reason("saved token expired"));
assert_eq!(
usage_for(&usage, &out[0]).and_then(|u| u.five_h),
Some(42.0),
"numbers anywhere beat a reason here"
);
usage.insert("rnd".to_string(), reason("endpoint busy"));
assert_eq!(
usage_for(&usage, &out[0]).and_then(|u| u.note.clone()),
Some("saved token expired".into())
);
let lonely = row("other", true);
assert!(usage_for(&usage, &lonely).is_none());
}
#[test]
fn accounts_group_by_tool_with_one_heading_each() {
let row = |name: &str, tools: &str| Row {
name: name.into(),
ident: "e@x".into(),
tools: tools.into(),
active: false,
warn: None,
disabled: false,
needs_login: false,
stale: false,
is_slot: false,
also: Vec::new(),
};
let sorted = group_sorted(vec![
row("codex", "codex*"),
row("rnd", "claude-code*"),
row("work", "codex"),
row("bsgong", "claude-code"),
]);
let names: Vec<&str> = sorted.iter().map(|r| r.name.as_str()).collect();
assert_eq!(
names,
vec!["rnd", "bsgong", "codex", "work"],
"claude accounts first, then codex, original order kept inside a group"
);
assert_eq!(
group_heads(&sorted),
vec![true, false, true, false],
"one heading per group, on its first account"
);
assert_eq!(group_of("claude-code*"), "claude-code");
assert_eq!(group_of("codex"), "codex");
assert_eq!(group_of("mystery"), "other", "an unknown tool still groups");
}
#[test]
fn clicks_map_through_variable_row_heights() {
let heights = [4u16, 3, 4];
assert_eq!(click_item_index(0, 5, 5, &heights), 0);
assert_eq!(click_item_index(0, 8, 5, &heights), 0, "still inside row 0");
assert_eq!(click_item_index(0, 9, 5, &heights), 1);
assert_eq!(click_item_index(0, 12, 5, &heights), 2);
assert_eq!(click_item_index(1, 5, 5, &heights), 1);
assert_eq!(click_item_index(1, 8, 5, &heights), 2);
let real = [3u16, 1, 4, 1];
assert_eq!(click_item_index(0, 5, 5, &real), 0, "the heading");
assert_eq!(click_item_index(0, 7, 5, &real), 0, "its account line");
assert_eq!(click_item_index(0, 8, 5, &real), 1);
assert_eq!(click_item_index(0, 9, 5, &real), 2, "second group starts");
assert_eq!(click_item_index(0, 12, 5, &real), 2, "still its account");
assert_eq!(click_item_index(0, 13, 5, &real), 3);
assert_eq!(click_item_index(0, 200, 5, &heights), 2);
}
#[test]
fn one_name_on_one_tool_is_one_row() {
let row = |name: &str, ident: &str, is_slot: bool, needs_login: bool| Row {
name: name.into(),
ident: ident.into(),
tools: "codex".into(),
active: false,
warn: None,
disabled: false,
needs_login,
stale: false,
is_slot,
also: Vec::new(),
};
let out = dedupe_by_identity(vec![
row("work", "polarisairnd@gmail.com [chatgpt]", false, false),
row("work", "", true, true),
]);
let names: Vec<&str> = out.iter().map(|r| r.name.as_str()).collect();
assert_eq!(out.len(), 1, "one account, one row: {names:?}");
assert!(
out[0].ident.contains("polarisairnd@gmail.com"),
"the half that knows who it is wins the label: {:?}",
out[0].ident
);
let mut a = row("work", "same@x.com", true, false);
a.tools = "claude-code".into();
let b = row("work", "same@x.com", true, false);
assert_eq!(dedupe_by_identity(vec![a, b]).len(), 2);
}
#[test]
fn a_full_window_carried_by_credits_is_not_called_spent() {
let row = Row {
name: "bsgong".into(),
ident: "e@x".into(),
tools: "claude-code".into(),
active: true,
warn: None,
disabled: false,
needs_login: false,
stale: false,
is_slot: true,
also: Vec::new(),
};
let full = |on_credits| Usage {
five_h: Some(100.0),
seven_d: Some(55.0),
on_credits,
..Default::default()
};
assert_eq!(account_status(&row, Some(&full(false))).0, "spent");
assert_eq!(account_status(&row, Some(&full(true))).0, ON_CREDITS);
let fresh = Usage {
five_h: Some(10.0),
seven_d: Some(20.0),
on_credits: true,
..Default::default()
};
assert_eq!(account_status(&row, Some(&fresh)).0, "active");
}
#[test]
fn status_says_what_the_account_can_do() {
let mk = |active: bool, warn, disabled| Row {
name: "a".into(),
ident: "e@x".into(),
tools: "claude-code".into(),
active,
warn,
disabled,
needs_login: false,
stale: false,
is_slot: false,
also: Vec::new(),
};
let spent = Usage {
five_h: Some(100.0),
..Default::default()
};
let fresh = Usage {
five_h: Some(12.0),
seven_d: Some(30.0),
..Default::default()
};
assert_eq!(
account_status(&mk(true, None, false), Some(&fresh)).0,
"active"
);
assert_eq!(
account_status(&mk(false, None, false), Some(&fresh)).0,
"ready"
);
assert_eq!(
account_status(&mk(true, None, false), Some(&spent)).0,
"spent",
"an exhausted window outranks being active"
);
assert_eq!(
account_status(&mk(true, Some("stale"), false), Some(&fresh)).0,
"stale",
"an unusable snapshot outranks quota"
);
assert_eq!(
account_status(&mk(true, Some("stale"), true), Some(&spent)).0,
"paused",
"a deliberate pause is stated plainly, not as a problem"
);
let needs = Row {
name: "a".into(),
ident: "e@x".into(),
tools: "claude-code".into(),
active: true,
warn: None,
disabled: false,
needs_login: true,
stale: false,
is_slot: false,
also: Vec::new(),
};
assert_eq!(account_status(&needs, Some(&fresh)).0, "no login");
assert_eq!(account_status(&mk(false, None, false), None).0, "ready");
}
#[test]
fn the_fill_matches_the_number_beside_it() {
let filled_cells = |used: f64| -> usize {
quota_bar(Some(used), None, 20)
.first()
.map(|s| s.content.chars().count())
.unwrap_or(0)
};
assert_eq!(filled_cells(0.0), 20, "nothing used -> the bar is full");
assert_eq!(filled_cells(100.0), 0, "all used -> the bar is empty");
assert_eq!(filled_cells(50.0), 10, "half used -> half full");
assert!(
filled_cells(10.0) > filled_cells(90.0),
"spending more leaves less showing"
);
}
#[test]
fn the_alarming_colour_marks_a_nearly_empty_window() {
assert_eq!(quota_fill(95.0), quota_fill(100.0), "both nearly spent");
assert_ne!(
quota_fill(95.0),
quota_fill(5.0),
"a fresh window is not drawn like a spent one"
);
}
#[test]
fn quota_bar_writes_what_is_left_inside_the_bar() {
let spans = quota_bar(Some(62.0), None, 14);
let text: String = spans.iter().map(|s| s.content.to_string()).collect();
assert_eq!(text.chars().count(), 14, "the bar is exactly its width");
assert!(text.contains("38% left"), "62% spent is 38% left: {text:?}");
let fresh: String = quota_bar(Some(2.0), None, 14)
.iter()
.map(|s| s.content.to_string())
.collect();
assert!(fresh.contains("98% left"), "{fresh:?}");
assert_eq!(spans[0].content.chars().count(), 5);
assert_eq!(spans[1].content.chars().count(), 9);
let full = quota_bar(Some(100.0), Some(3600), 14);
assert_eq!(
full[0].content.chars().count(),
0,
"nothing left, nothing filled: {:?}",
full[1].content
);
assert!(
full[1].content.contains("0% left"),
"a spent window says so plainly: {:?}",
full[1].content
);
let wide: String = quota_bar(Some(10.0), Some(3600), 17)
.iter()
.map(|s| s.content.to_string())
.collect();
assert!(wide.contains("90% left") && wide.contains("1h"), "{wide:?}");
let narrow: String = quota_bar(Some(10.0), Some(3600), 5)
.iter()
.map(|s| s.content.to_string())
.collect();
assert!(
narrow.contains("90%") && !narrow.contains("1h") && !narrow.contains("l"),
"{narrow:?}"
);
let none = quota_bar(None, None, 6);
assert_eq!(none.len(), 1);
assert_eq!(none[0].content.chars().count(), 6);
assert_eq!(none[0].content.trim(), "");
}
#[test]
fn observed_note_only_appears_for_stale_snapshots() {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
assert_eq!(observed_note(None), "", "a live read says nothing");
assert_eq!(
observed_note(Some(now - 60)),
"",
"a minute old is still current enough"
);
assert_eq!(observed_note(Some(now - 2 * 3600)), "as of 2h");
assert_eq!(observed_note(Some(now - 3 * 86400)), "as of 3d");
}
#[test]
fn signing_in_keeps_the_dashboard_alive() {
fn asserts_it_returns<C: TuiCtx>(ctx: &mut C, name: &str) -> (bool, String) {
ctx.sign_in(name)
}
struct Fake {
called: Vec<String>,
}
impl TuiCtx for Fake {
fn rows(&mut self) -> Vec<Row> {
Vec::new()
}
fn switch(&mut self, _: &str) -> (bool, String) {
(true, String::new())
}
fn delete(&mut self, _: &str) -> String {
String::new()
}
fn sessions(&mut self, _: &str) -> (String, Vec<SessionEntry>, Vec<&'static str>) {
(String::new(), Vec::new(), Vec::new())
}
fn rename(&mut self, _: &str, _: &str) -> (bool, String) {
(true, String::new())
}
fn sign_in(&mut self, name: &str) -> (bool, String) {
self.called.push(name.to_string());
(true, format!("'{name}' is signed in"))
}
fn save_current(&mut self, _: &str) -> (bool, String) {
(true, String::new())
}
fn doctor(&mut self) -> Vec<String> {
Vec::new()
}
fn usage(&mut self) -> Vec<String> {
Vec::new()
}
fn quota(&mut self) -> Vec<String> {
Vec::new()
}
fn sessionwiki_present(&mut self) -> bool {
false
}
fn live_tools(&mut self) -> Vec<String> {
Vec::new()
}
}
let mut f = Fake { called: Vec::new() };
let (ok, msg) = asserts_it_returns(&mut f, "work");
assert!(ok);
assert_eq!(f.called, vec!["work".to_string()], "it reached the account");
assert!(msg.contains("signed in"), "and reports back: {msg}");
asserts_it_returns(&mut f, "home");
assert_eq!(f.called.len(), 2);
}
#[test]
fn the_new_conversation_entries_lead_and_never_vanish() {
assert_eq!(
new_conv_for(&["claude-code"]),
vec![("open a NEW Claude Code conversation", "claude-code")],
"only the tools the account actually has"
);
assert_eq!(
new_conv_for(&["codex"]),
vec![("open a NEW Codex conversation", "codex")]
);
assert_eq!(
new_conv_for(&[]),
NEW_CONV[..2].to_vec(),
"the two tools swapdex can launch, rather than nothing"
);
}
#[test]
fn the_selected_row_is_banded_only_up_to_the_bars() {
let spans = || {
vec![
Span::raw(" "),
Span::styled("work", Style::default().fg(Color::White)),
Span::styled("ready", Style::default().fg(Color::Green)),
Span::styled(" ", Style::default().bg(Color::Rgb(1, 2, 3))),
]
};
let out = mark_selected(spans(), 3, true);
assert!(
out[..3].iter().all(|s| s.style.bg == Some(SELECT_BG)),
"the row reads as selected across its text columns"
);
assert!(
out[..3]
.iter()
.all(|s| s.style.add_modifier.contains(Modifier::BOLD)),
"and weight still helps"
);
assert_eq!(
out[1].style.fg,
Some(Color::White),
"each column keeps its own colour"
);
assert_eq!(out[2].style.fg, Some(Color::Green), "including the status");
assert_eq!(
out[3].style.bg,
Some(Color::Rgb(1, 2, 3)),
"the bar's fill is untouched - it is the number, not decoration"
);
let plain = mark_selected(spans(), 3, false);
assert!(plain.iter().all(|s| s.style.bg != Some(SELECT_BG)));
}
#[test]
fn an_account_that_cannot_serve_is_not_called_ready() {
let base = Row {
name: "work".into(),
ident: "w@x.co".into(),
tools: "claude-code".into(),
active: false,
warn: None,
disabled: false,
needs_login: false,
is_slot: true,
also: Vec::new(),
stale: false,
};
assert_eq!(account_status(&base, None).0, "ready");
let stale = Row {
stale: true,
..Row {
name: base.name.clone(),
ident: base.ident.clone(),
tools: base.tools.clone(),
..base
}
};
assert_eq!(account_status(&stale, None).0, "expired");
let fresh = Row {
needs_login: true,
..stale
};
assert_eq!(account_status(&fresh, None).0, "no login");
}
#[test]
fn a_row_with_no_numbers_says_why() {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;
let u = |note: Option<&str>, observed: Option<i64>| Usage {
five_h: None,
five_h_reset: None,
seven_d: None,
seven_d_reset: None,
observed_at: observed,
note: note.map(str::to_string),
on_credits: false,
};
assert_eq!(
trailing_note(&u(Some("token expired"), None), false),
"token expired"
);
assert_eq!(
trailing_note(&u(Some("endpoint busy"), Some(now - 3 * 3600)), false),
"endpoint busy",
"the reason outranks the age"
);
assert_eq!(
trailing_note(&u(None, Some(now - 2 * 3600)), false),
"as of 2h"
);
assert_eq!(trailing_note(&u(None, None), false), "");
assert_eq!(
trailing_note(&u(None, Some(now - 60)), true),
"checking\u{2026}"
);
assert_eq!(
trailing_note(&u(None, Some(now - 2 * 3600)), true),
"as of 2h, checking\u{2026}"
);
assert_eq!(trailing_note(&u(None, None), true), "");
}
#[test]
fn fmt_reset_shortens_to_the_useful_unit() {
assert_eq!(fmt_reset(48 * 60), "48m");
assert_eq!(fmt_reset(2 * 3600 + 14 * 60), "2h14m");
assert_eq!(fmt_reset(3 * 3600), "3h");
assert_eq!(fmt_reset(3 * 86400 + 4 * 3600), "3d4h");
assert_eq!(fmt_reset(0), "", "already reset: nothing to count down");
}
#[test]
fn usage_bar_column_clears_the_widest_row() {
let row = |name: &str, ident: &str| Row {
name: name.into(),
ident: ident.into(),
tools: String::new(),
active: false,
warn: None,
disabled: false,
needs_login: false,
stale: false,
is_slot: false,
also: Vec::new(),
};
let rows = vec![
row("rnd", "rnd@x.co"),
row("bsgong", "bsgong@polarisai.co.kr"),
];
assert_eq!(usage_bar_column(&rows), 3 + 2 + 6 + 2 + 22 + 2 + 8 + 2);
assert_eq!(
usage_bar_column(&[row("a", "b")]),
3 + 2 + 1 + 2 + 1 + 2 + 8 + 2
);
assert_eq!(usage_bar_column(&[]), 3 + 2 + 2 + 2 + 8 + 2);
}
#[test]
fn r_key_is_previous_not_restore() {
assert!(
ALL_KEYS
.iter()
.any(|(k, label)| *k == "r" && label.contains("last account")),
"the r key goes back to the previous account"
);
assert!(
!ALL_KEYS.iter().any(|(_, label)| label.contains("restore")),
"'restore' is no longer a Main-screen binding"
);
let (a, b) = hint_rows();
assert_eq!(a.len() + b.len(), ALL_KEYS.len(), "no key is dropped");
assert!(!a.is_empty() && !b.is_empty(), "both rows carry keys");
for (k, label) in ALL_KEYS {
assert!(
label.len() >= 4,
"key {k:?} needs a label that explains it: {label:?}"
);
}
}
}