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 = 90;
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 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 k.as_ref().and_then(|k| {
slots
.iter()
.position(|(s, _, _)| s.as_deref() == Some(k.as_str()))
}) {
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,
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"),
];
fn account_status(r: &Row, u: Option<&Usage>) -> (&'static str, Color) {
const SPENT: f64 = 99.0;
if r.disabled {
return ("paused", Color::Rgb(110, 108, 128));
}
if r.needs_login {
return ("no login", 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.five_h.is_some_and(|p| p >= SPENT) || u.seven_d.is_some_and(|p| p >= SPENT)
});
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 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>,
}
fn trailing_note(u: &Usage) -> String {
match &u.note {
Some(n) if !n.is_empty() => n.clone(),
_ => observed_note(u.observed_at),
}
}
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 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 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 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),
SignIn(String),
}
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 short = format!("{pct:.0}%");
let label = match reset_secs.map(fmt_reset) {
Some(r) if !r.is_empty() && short.chars().count() + 1 + r.chars().count() <= width => {
format!("{short} {r}")
}
_ => short,
};
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 = ((pct / 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")
}
}
pub fn run(ctx: &mut dyn TuiCtx) -> Result<Outcome> {
let mut terminal = ratatui::try_init()?;
let _ = ratatui::crossterm::execute!(
std::io::stdout(),
ratatui::crossterm::event::EnableMouseCapture
);
let mut rows = ctx.rows();
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 mut quota_pct: Option<std::collections::HashMap<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);
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 rule_w = bar_col.saturating_sub(title.chars().count() + 3);
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)),
),
]));
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!(
" delete saved profile '{}'? the live login stays. 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) => (
" rename profile ".to_string(),
format!("new name for '{old}'"),
),
InputKind::SaveCurrent => (
" save current logins ".to_string(),
"name for this profile".to_string(),
),
};
f.render_widget(
Paragraph::new(vec![
Line::from(""),
Line::from(vec![
Span::styled(format!(" {prompt}"), Style::default().fg(MUTED)),
Span::raw(": "),
Span::styled(
format!("{value}\u{2588}"),
Style::default().fg(VIOLET).add_modifier(Modifier::BOLD),
),
]),
])
.block(list_block_titled(&title)),
main,
);
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;
}
let stale_quota = quota_pct.is_none()
|| quota_fetched.is_some_and(|t: std::time::Instant| {
t.elapsed() >= std::time::Duration::from_secs(QUOTA_REFRESH_SECS)
});
if matches!(screen, Screen::Main) && stale_quota && !rows.is_empty() {
quota_pct = Some(ctx.quota_pct().into_iter().collect());
quota_fetched = Some(std::time::Instant::now());
continue;
}
let mut click_activate = false;
if !event::poll(std::time::Duration::from_millis(500))? {
continue;
}
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())
{
break 'ui Outcome::SignIn(name);
}
}
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();
Ok(outcome)
}
#[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,
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,
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,
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 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,
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,
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 quota_bar_writes_the_number_inside_the_bar() {
let spans = quota_bar(Some(62.0), None, 11);
let text: String = spans.iter().map(|s| s.content.to_string()).collect();
assert_eq!(text.chars().count(), 11, "the bar is exactly its width");
assert!(
text.contains("62%"),
"the number is inside the bar: {text:?}"
);
assert_eq!(spans[0].content.chars().count(), 7);
assert_eq!(spans[1].content.chars().count(), 4);
let full = quota_bar(Some(100.0), Some(3600), 11);
assert_eq!(
full[1].content.chars().count(),
0,
"nothing unfilled at 100%: {:?}",
full[0].content
);
assert!(full[0].content.contains("100%"));
let wide: String = quota_bar(Some(10.0), Some(3600), 14)
.iter()
.map(|s| s.content.to_string())
.collect();
assert!(wide.contains("10%") && 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("10%") && !narrow.contains("1h"),
"{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 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 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),
};
assert_eq!(
trailing_note(&u(Some("token expired"), None)),
"token expired"
);
assert_eq!(
trailing_note(&u(Some("endpoint busy"), Some(now - 3 * 3600))),
"endpoint busy",
"the reason outranks the age"
);
assert_eq!(trailing_note(&u(None, Some(now - 2 * 3600))), "as of 2h");
assert_eq!(trailing_note(&u(None, None)), "");
}
#[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,
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:?}"
);
}
}
}