use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant, SystemTime};
use anyhow::Result;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use crate::actions::{
CaptureSnapshot, capture_into_profile, capture_snapshot, create_blank_profile, delete_profile,
edit_profile_endpoint, find_matching_oauth_profile, rename_profile, reorder_profile,
switch_off, switch_profile, validate_profile_name,
};
use crate::claude::{
LinkState, adopt_first_login, classify_credentials_link, credentials_diverged,
detach_credentials_link, force_link_profile_credentials, force_snapshot_active_credentials,
is_first_login, link_profile_credentials, read_claude_credentials, snapshot_active_credentials,
};
use crate::fallback::{DEFAULT_THRESHOLD, SwitchAction, auto_switch_if_needed, threshold_for};
use crate::lock::with_state_lock;
use crate::lockorder::{RankedGuard, RankedMutex};
use crate::oauth;
use crate::profile::{
AppConfig, ConfigHandle, Profile, ThemeName, app_state_mtime, load_config, save_app_state,
save_profile,
};
use crate::status::{self, Incident, StatusEvent};
use crate::tui::theme;
use crate::update::{self, UpdateEvent};
use crate::usage::{
ActivityKind, ActivityStore, LastFetchedAt, NextRefreshPerProfile, OpResult, OpResultReceiver,
OpResultSender, PendingSwitch, PendingSwitchOff, ProfileActivity, RefetchQueue,
StartupReceiver, StartupSender, StartupSignal, StatusStore, ThirdPartyList,
ThirdPartyStatusStore, ThirdPartyUsageStore, TokenEntry, TokenList, UsageStore, any_busy,
clear_activity, collect_third_party_entries, fetch_all_into, is_idle, mark_activity,
spawn_refresher,
};
#[derive(Debug, Clone)]
pub(crate) struct InputState {
pub(crate) value: String,
pub(crate) cursor: usize,
}
impl InputState {
pub(crate) fn new(initial: &str) -> Self {
Self {
value: initial.to_string(),
cursor: initial.len(),
}
}
pub(crate) fn insert(&mut self, ch: char) {
self.value.insert(self.cursor, ch);
self.cursor += ch.len_utf8();
}
pub(crate) fn backspace(&mut self) {
if self.cursor == 0 {
return;
}
let prev = self.value[..self.cursor]
.char_indices()
.next_back()
.map(|(i, _)| i)
.unwrap_or(0);
self.value.replace_range(prev..self.cursor, "");
self.cursor = prev;
}
pub(crate) fn delete(&mut self) {
if self.cursor >= self.value.len() {
return;
}
let next = self.value[self.cursor..]
.char_indices()
.nth(1)
.map(|(i, _)| self.cursor + i)
.unwrap_or(self.value.len());
self.value.replace_range(self.cursor..next, "");
}
pub(crate) fn left(&mut self) {
if self.cursor == 0 {
return;
}
self.cursor = self.value[..self.cursor]
.char_indices()
.next_back()
.map(|(i, _)| i)
.unwrap_or(0);
}
pub(crate) fn right(&mut self) {
if self.cursor >= self.value.len() {
return;
}
self.cursor = self.value[self.cursor..]
.char_indices()
.nth(1)
.map(|(i, _)| self.cursor + i)
.unwrap_or(self.value.len());
}
pub(crate) fn delete_word(&mut self) {
if self.cursor == 0 {
return;
}
let head = &self.value[..self.cursor];
let trimmed = head.trim_end_matches(' ');
let start = trimmed.rfind(' ').map(|i| i + 1).unwrap_or(0);
self.value.replace_range(start..self.cursor, "");
self.cursor = start;
}
pub(crate) fn home(&mut self) {
self.cursor = 0;
}
pub(crate) fn end(&mut self) {
self.cursor = self.value.len();
}
pub(crate) fn trimmed(&self) -> &str {
self.value.trim()
}
pub(crate) fn trimmed_some(&self) -> Option<String> {
let t = self.trimmed();
(!t.is_empty()).then(|| t.to_string())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum FallbackRow {
Threshold,
Remove,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ConfigRow {
Name,
BaseUrl,
ApiKey,
AutoStart,
Delete,
Create,
}
impl ConfigRow {
pub(crate) fn is_text(self) -> bool {
matches!(
self,
ConfigRow::Name | ConfigRow::BaseUrl | ConfigRow::ApiKey
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum GlobalConfigRow {
Theme,
WrapOff,
}
#[derive(Debug, Clone)]
pub(crate) struct ConfigDraft {
pub(crate) editing_name: Option<String>,
pub(crate) name: InputState,
pub(crate) base_url: InputState,
pub(crate) api_key: InputState,
pub(crate) active: Option<ConfigRow>,
pub(crate) armed_delete: bool,
}
#[derive(Debug, Clone)]
pub(crate) struct ConfirmState {
pub(crate) message: String,
pub(crate) detail: Option<String>,
pub(crate) choice: bool,
pub(crate) on_confirm: ConfirmAction,
}
#[derive(Debug, Clone)]
pub(crate) enum ConfirmAction {
CaptureConflict(Box<CaptureSnapshot>, bool),
Switch(String),
DiscardDivergence(String),
RotateAll,
RotateOne(String),
}
#[derive(Debug, Clone)]
pub(crate) struct CaptureNameForm {
pub(crate) snapshot: Box<CaptureSnapshot>,
pub(crate) input: InputState,
pub(crate) from_divergence: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DivergenceChoice {
Overwrite,
NewProfile,
Discard,
}
#[derive(Debug, Clone)]
pub(crate) struct DivergenceForm {
pub(crate) active: String,
pub(crate) cursor: usize,
}
impl DivergenceForm {
pub(crate) fn options() -> [DivergenceChoice; 3] {
[
DivergenceChoice::Overwrite,
DivergenceChoice::NewProfile,
DivergenceChoice::Discard,
]
}
}
#[derive(Debug, Clone)]
pub(crate) struct ActionItem {
pub(crate) label: &'static str,
pub(crate) hotkey: Option<char>,
pub(crate) action: ActionMenuAction,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ActionMenuAction {
NewAccount,
RefreshUsage,
RotateTokens,
SwitchToSelected,
ConfigureSelected,
OpenChainMember,
ReorderUp,
ReorderDown,
EditThreshold,
ToggleWrapOff,
RemoveMember,
ToggleAutoStart,
DeleteProfile,
CreateProfile,
EditField,
CycleTheme,
RefreshStatus,
OpenIncidentLink,
}
#[derive(Debug, Clone)]
pub(crate) struct ActionMenuState {
pub(crate) items: Vec<ActionItem>,
pub(crate) cursor: usize,
}
impl ActionMenuState {
pub(crate) fn new(actions: Vec<ActionMenuAction>) -> Self {
const RESERVED: &[char] = &['a', 'x', '?', 'q'];
let mut claimed: Vec<char> = Vec::new();
let items = actions
.into_iter()
.map(|action| {
let label = action.label();
let hotkey = action
.preferred_hotkey()
.filter(|c| !RESERVED.contains(c) && !claimed.contains(c))
.or_else(|| {
label
.chars()
.filter(|c| c.is_alphabetic())
.map(|c| c.to_lowercase().next().unwrap_or(c))
.take(3)
.find(|c| !RESERVED.contains(c) && !claimed.contains(c))
})
.inspect(|c| claimed.push(*c));
ActionItem {
label,
hotkey,
action,
}
})
.collect();
Self { items, cursor: 0 }
}
}
impl ActionMenuAction {
fn preferred_hotkey(&self) -> Option<char> {
match self {
Self::RotateTokens => Some('t'),
_ => None,
}
}
pub(crate) fn label(&self) -> &'static str {
match self {
Self::NewAccount => "new account",
Self::RefreshUsage => "refresh usage",
Self::RotateTokens => "rotate tokens",
Self::SwitchToSelected => "switch to selected",
Self::ConfigureSelected => "configure",
Self::OpenChainMember => "open",
Self::ReorderUp => "reorder up",
Self::ReorderDown => "reorder down",
Self::EditThreshold => "edit threshold",
Self::ToggleWrapOff => "toggle wrap-off",
Self::RemoveMember => "remove member",
Self::ToggleAutoStart => "toggle auto-start",
Self::DeleteProfile => "delete profile",
Self::CreateProfile => "create profile",
Self::EditField => "edit field",
Self::CycleTheme => "cycle theme",
Self::RefreshStatus => "refresh status",
Self::OpenIncidentLink => "open in browser",
}
}
}
#[derive(Debug, Clone)]
pub(crate) enum Modal {
Confirm(ConfirmState),
Divergence(DivergenceForm),
CaptureName(CaptureNameForm),
Help,
ActionMenu(ActionMenuState),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ToastKind {
Info,
Success,
Warning,
Danger,
}
#[derive(Debug, Clone)]
pub(crate) struct Toast {
pub(crate) kind: ToastKind,
pub(crate) body: String,
pub(crate) born: Instant,
}
const ROTATE_ALL_MSG: &str = "rotate tokens for all accounts?";
const ROTATE_ALL_DETAIL: &str = "accounts with a live session might be logged out.";
const ROTATE_ONE_DETAIL: &str = "a live session on this account might be logged out.";
const TOAST_CAPACITY: usize = 3;
const TOAST_TTL_NORMAL: Duration = Duration::from_secs(3);
const TOAST_TTL_DANGER: Duration = Duration::from_secs(6);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Tab {
Overview,
Usage,
Setup,
Fallback,
Config,
Status,
}
impl Tab {
pub(crate) const ALL: [Tab; 6] = [
Tab::Overview,
Tab::Usage,
Tab::Setup,
Tab::Fallback,
Tab::Config,
Tab::Status,
];
pub(crate) fn title(self) -> &'static str {
match self {
Tab::Overview => "Overview",
Tab::Usage => "Usage",
Tab::Setup => "Setup",
Tab::Fallback => "Fallback",
Tab::Config => "Config",
Tab::Status => "Status",
}
}
pub(crate) fn index(self) -> usize {
Tab::ALL.iter().position(|t| *t == self).unwrap_or(0)
}
pub(crate) fn next(self) -> Tab {
Tab::ALL[(self.index() + 1) % Tab::ALL.len()]
}
pub(crate) fn prev(self) -> Tab {
Tab::ALL[(self.index() + Tab::ALL.len() - 1) % Tab::ALL.len()]
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ConfigFocus {
Profiles,
Actions,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum FallbackFocus {
Chain,
Detail,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum StatusFocus {
List,
Detail,
}
#[derive(Debug)]
pub(crate) struct StatusState {
pub(crate) incidents: Vec<Incident>,
pub(crate) fetched_at_ms: Option<u64>,
pub(crate) cached: bool,
pub(crate) error: Option<String>,
pub(crate) fetching: bool,
pub(crate) cursor: usize,
pub(crate) focus: StatusFocus,
pub(crate) detail_scroll: u16,
pub(crate) detail_max_scroll: std::cell::Cell<u16>,
pub(crate) seen_latest: Option<String>,
}
impl Default for StatusState {
fn default() -> Self {
Self {
incidents: Vec::new(),
fetched_at_ms: None,
cached: false,
error: None,
fetching: false,
cursor: 0,
focus: StatusFocus::List,
detail_scroll: 0,
detail_max_scroll: std::cell::Cell::new(0),
seen_latest: None,
}
}
}
impl StatusState {
pub(crate) fn selected(&self) -> Option<&Incident> {
self.incidents.get(self.cursor)
}
pub(crate) fn worst_active_impact(&self) -> crate::status::Impact {
self.incidents
.iter()
.filter(|i| i.is_active())
.map(|i| &i.impact)
.max_by_key(|i| i.severity())
.cloned()
.unwrap_or(crate::status::Impact::None)
}
}
pub(crate) fn incident_is_active(incident: &Incident) -> bool {
incident.is_active()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum FooterAlert {
Warn(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum BannerSeverity {
Warning,
Danger,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Banner {
pub(crate) severity: BannerSeverity,
pub(crate) message: String,
}
#[derive(Debug, Clone, Copy)]
pub(crate) enum MainItemKind {
Profile(usize),
}
pub(crate) struct App {
pub(crate) config: ConfigHandle,
pub(crate) usage_store: UsageStore,
pub(crate) usage_status: StatusStore,
pub(crate) usage_tokens: TokenList,
pub(crate) next_refresh_per_profile: NextRefreshPerProfile,
pub(crate) activity: ActivityStore,
pub(crate) op_results: OpResultReceiver,
pub(crate) op_sender: OpResultSender,
pub(crate) startup_results: StartupReceiver,
pub(crate) startup_sender: StartupSender,
pub(crate) last_fetched: LastFetchedAt,
pub(crate) pending_switch: PendingSwitch,
pub(crate) pending_switch_off: PendingSwitchOff,
pub(crate) refetch_queue: RefetchQueue,
pub(crate) third_party_tokens: ThirdPartyList,
pub(crate) third_party_usage_store: ThirdPartyUsageStore,
pub(crate) third_party_status: ThirdPartyStatusStore,
pub(crate) tab: Tab,
pub(crate) modals: Vec<Modal>,
pub(crate) profile_cursor: usize,
pub(crate) config_focus: ConfigFocus,
pub(crate) config_action_cursor: usize,
pub(crate) config_draft: Option<ConfigDraft>,
pub(crate) chain_cursor: usize,
pub(crate) fallback_focus: FallbackFocus,
pub(crate) fallback_detail_cursor: usize,
pub(crate) fallback_armed_remove: bool,
pub(crate) fallback_threshold_draft: Option<InputState>,
pub(crate) global_config_cursor: usize,
pub(crate) toasts: VecDeque<Toast>,
pub(crate) compact: bool,
pub(crate) update_results: std::sync::mpsc::Receiver<UpdateEvent>,
pub(crate) status: StatusState,
pub(crate) status_events: std::sync::mpsc::Receiver<StatusEvent>,
pub(crate) status_refresh: std::sync::mpsc::Sender<()>,
pub(crate) last_state_mtime: Option<SystemTime>,
pub(crate) started_at: Instant,
pub(crate) tick_count: u64,
pub(crate) quit: bool,
pub(crate) armed_quit: bool,
pub(crate) footer_alert: Option<FooterAlert>,
pub(crate) banner: Option<Banner>,
pub(crate) last_divergence_check: Instant,
pub(crate) reconcile_done: bool,
pub(crate) bootstrap_started: bool,
pub(crate) bootstrap_active: Arc<AtomicBool>,
pub(crate) tab_activity: [Option<ToastKind>; Tab::ALL.len()],
}
struct WorkerHandles {
config: ConfigHandle,
usage_tokens: TokenList,
usage_store: UsageStore,
usage_status: StatusStore,
next_refresh_per_profile: NextRefreshPerProfile,
activity: ActivityStore,
last_fetched: LastFetchedAt,
pending_switch: PendingSwitch,
pending_switch_off: PendingSwitchOff,
refetch_queue: RefetchQueue,
third_party_tokens: ThirdPartyList,
third_party_usage_store: ThirdPartyUsageStore,
third_party_status: ThirdPartyStatusStore,
}
impl WorkerHandles {
fn from_app(app: &App) -> Self {
Self {
config: Arc::clone(&app.config),
usage_tokens: Arc::clone(&app.usage_tokens),
usage_store: Arc::clone(&app.usage_store),
usage_status: Arc::clone(&app.usage_status),
next_refresh_per_profile: Arc::clone(&app.next_refresh_per_profile),
activity: Arc::clone(&app.activity),
last_fetched: Arc::clone(&app.last_fetched),
pending_switch: Arc::clone(&app.pending_switch),
pending_switch_off: Arc::clone(&app.pending_switch_off),
refetch_queue: Arc::clone(&app.refetch_queue),
third_party_tokens: Arc::clone(&app.third_party_tokens),
third_party_usage_store: Arc::clone(&app.third_party_usage_store),
third_party_status: Arc::clone(&app.third_party_status),
}
}
}
impl App {
pub(crate) fn new(config: AppConfig) -> Self {
let usage_store: UsageStore = Arc::new(RankedMutex::new(HashMap::new()));
let usage_status: StatusStore = Arc::new(RankedMutex::new(HashMap::new()));
let usage_tokens: TokenList = Arc::new(RankedMutex::new(collect_tokens(&config.profiles)));
let next_refresh_per_profile: NextRefreshPerProfile =
Arc::new(RankedMutex::new(HashMap::new()));
let activity: ActivityStore = Arc::new(RankedMutex::new(HashMap::new()));
let (op_sender, op_results) = std::sync::mpsc::channel::<OpResult>();
let (startup_sender, startup_results) = std::sync::mpsc::channel::<StartupSignal>();
let last_fetched: LastFetchedAt = Arc::new(RankedMutex::new(HashMap::new()));
let pending_switch: PendingSwitch = Arc::new(RankedMutex::new(HashSet::new()));
let pending_switch_off: PendingSwitchOff = Arc::new(RankedMutex::new(false));
let refetch_queue: RefetchQueue = Arc::new(RankedMutex::new(HashSet::new()));
let third_party_tokens: ThirdPartyList = Arc::new(RankedMutex::new(
collect_third_party_entries(&config.profiles),
));
let third_party_usage_store: ThirdPartyUsageStore =
Arc::new(RankedMutex::new(HashMap::new()));
let third_party_status: ThirdPartyStatusStore = Arc::new(RankedMutex::new(HashMap::new()));
let (update_sender, update_results) = std::sync::mpsc::channel::<UpdateEvent>();
update::spawn(update_sender);
let (status_sender, status_events) = std::sync::mpsc::channel::<StatusEvent>();
let (status_refresh, status_refresh_rx) = std::sync::mpsc::channel::<()>();
if cfg!(test) {
drop((status_sender, status_refresh_rx));
} else {
status::spawn(status_sender, status_refresh_rx);
}
Self {
config: Arc::new(RankedMutex::new(config)),
usage_store,
usage_status,
usage_tokens,
next_refresh_per_profile,
activity,
op_results,
op_sender,
startup_results,
startup_sender,
last_fetched,
pending_switch,
pending_switch_off,
refetch_queue,
third_party_tokens,
third_party_usage_store,
third_party_status,
tab: Tab::Overview,
modals: Vec::new(),
profile_cursor: 0,
config_focus: ConfigFocus::Profiles,
config_action_cursor: 0,
fallback_focus: FallbackFocus::Chain,
fallback_detail_cursor: 0,
fallback_armed_remove: false,
fallback_threshold_draft: None,
global_config_cursor: 0,
config_draft: None,
chain_cursor: 0,
toasts: VecDeque::new(),
compact: false,
update_results,
status: StatusState::default(),
status_events,
status_refresh,
last_state_mtime: app_state_mtime(),
started_at: Instant::now(),
tick_count: 0,
quit: false,
armed_quit: false,
footer_alert: None,
banner: None,
last_divergence_check: Instant::now(),
reconcile_done: false,
bootstrap_started: false,
bootstrap_active: Arc::new(AtomicBool::new(false)),
tab_activity: [None; Tab::ALL.len()],
}
}
pub(crate) fn config(&self) -> RankedGuard<'_, AppConfig> {
self.config.lock().expect("config mutex poisoned")
}
pub(crate) fn spawn_bootstrap(&self) {
let config = Arc::clone(&self.config);
let usage_store = Arc::clone(&self.usage_store);
let usage_status = Arc::clone(&self.usage_status);
let last_fetched = Arc::clone(&self.last_fetched);
let refetch_queue = Arc::clone(&self.refetch_queue);
let activity = Arc::clone(&self.activity);
let startup_sender = self.startup_sender.clone();
let bootstrap_active = Arc::clone(&self.bootstrap_active);
let startup_sender_for_panic = startup_sender.clone();
let bootstrap_active_for_panic = Arc::clone(&bootstrap_active);
std::thread::spawn(move || {
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let active = config
.lock()
.expect("config mutex poisoned")
.state
.active_profile
.clone();
if let Some(active) = active {
let _ = link_profile_credentials(&active);
}
let snapshot =
collect_tokens(&config.lock().expect("config mutex poisoned").profiles);
fetch_all_into(
&config,
&snapshot,
&usage_store,
&usage_status,
&last_fetched,
&refetch_queue,
&activity,
);
bootstrap_active.store(false, Ordering::SeqCst);
let _ = startup_sender.send(StartupSignal::BootstrapDone);
}));
if result.is_err() {
bootstrap_active_for_panic.store(false, Ordering::SeqCst);
let _ = startup_sender_for_panic.send(StartupSignal::BootstrapDone);
}
});
}
fn finish_bootstrap(&mut self) {
self.refresh_tokens();
self.start_scheduler();
self.apply_usage();
let switched = {
let mut cfg = self.config();
auto_switch_if_needed(&mut cfg).ok().flatten()
};
match switched {
Some(SwitchAction::To(target)) => {
self.toast(ToastKind::Warning, format!("auto-switched to '{target}'"));
}
Some(SwitchAction::Off) => {
self.refresh_tokens();
self.toast(
ToastKind::Warning,
"all accounts spent — switched off to halt usage".to_string(),
);
}
None => {}
}
}
fn start_scheduler(&self) {
let h = WorkerHandles::from_app(self);
spawn_refresher(
h.config,
h.usage_tokens,
h.usage_store,
h.usage_status,
h.next_refresh_per_profile,
h.activity,
h.last_fetched,
h.pending_switch,
h.pending_switch_off,
h.refetch_queue,
h.third_party_tokens,
h.third_party_usage_store,
h.third_party_status,
);
}
pub(crate) fn apply_usage(&mut self) {
let third_party_map = self.third_party_usage_store.lock().ok();
let third_party_status_map = self.third_party_status.lock().ok();
let info_map = self.usage_store.lock().ok();
let status_map = self.usage_status.lock().ok();
let mut cfg = self.config();
for p in &mut cfg.profiles {
if let Some(s) = info_map.as_ref() {
p.usage = s.get(p.name.as_str()).cloned();
}
if let Some(s) = status_map.as_ref()
&& s.contains_key(p.name.as_str())
{
p.fetch_status = s.get(p.name.as_str()).copied();
} else if let Some(s) = third_party_status_map.as_ref() {
p.fetch_status = s.get(p.name.as_str()).copied();
}
if let Some(s) = third_party_map.as_ref()
&& s.contains_key(p.name.as_str())
{
p.third_party_usage = s.get(p.name.as_str()).cloned();
}
}
}
pub(crate) fn reload_if_state_changed(&mut self) -> bool {
let current = app_state_mtime();
if current == self.last_state_mtime {
return false;
}
if let Ok(fresh) = load_config() {
*self.config() = fresh;
self.last_state_mtime = current;
let profiles = &self.config().profiles;
*self
.usage_tokens
.lock()
.expect("usage_tokens mutex poisoned") = collect_tokens(profiles);
*self
.third_party_tokens
.lock()
.expect("third_party_tokens mutex poisoned") =
collect_third_party_entries(profiles);
true
} else {
false
}
}
pub(crate) fn refresh_tokens(&self) {
let tokens = collect_tokens(&self.config().profiles);
let third_party = collect_third_party_entries(&self.config().profiles);
*self
.usage_tokens
.lock()
.expect("usage_tokens mutex poisoned") = tokens;
*self
.third_party_tokens
.lock()
.expect("third_party_tokens mutex poisoned") = third_party;
}
pub(crate) fn manual_refresh(&self) {
let names: Vec<String> = self
.usage_tokens
.lock()
.expect("usage_tokens mutex poisoned")
.iter()
.map(|e| e.name.clone())
.collect();
for name in names {
self.manual_refresh_one(&name);
}
}
pub(crate) fn manual_refresh_one(&self, name: &str) {
if is_idle(&self.activity, name) {
mark_activity(&self.activity, name, ProfileActivity::Fetching);
}
if let Ok(mut q) = self.refetch_queue.lock() {
q.insert(name.to_string());
}
}
pub(crate) fn toast(&mut self, kind: ToastKind, body: impl Into<String>) {
if self.toasts.len() >= TOAST_CAPACITY {
self.toasts.pop_front();
}
self.toasts.push_back(Toast {
kind,
body: body.into(),
born: Instant::now(),
});
}
pub(crate) fn disarm_quit(&mut self) {
self.armed_quit = false;
self.footer_alert = None;
}
pub(crate) fn prune_toasts(&mut self) {
while let Some(front) = self.toasts.front() {
let ttl = if front.kind == ToastKind::Danger {
TOAST_TTL_DANGER
} else {
TOAST_TTL_NORMAL
};
if front.born.elapsed() >= ttl {
self.toasts.pop_front();
} else {
break;
}
}
}
pub(crate) fn update_compact(&mut self, terminal_height: u16) {
self.compact = terminal_height < 14;
}
pub(crate) fn set_tab_activity(&mut self, tab: Tab, kind: ToastKind) {
if tab == self.tab {
return;
}
let idx = tab.index();
let prev = self.tab_activity[idx];
let severity = |k: ToastKind| match k {
ToastKind::Danger => 3,
ToastKind::Warning => 2,
ToastKind::Success => 1,
ToastKind::Info => 0,
};
if prev.is_none_or(|p| severity(kind) > severity(p)) {
self.tab_activity[idx] = Some(kind);
}
}
pub(crate) fn main_items(&self) -> Vec<MainItemKind> {
(0..self.config().profiles.len())
.map(MainItemKind::Profile)
.collect()
}
pub(crate) fn profile_count(&self) -> usize {
self.config().profiles.len()
}
pub(crate) fn profile_name_at(&self, idx: usize) -> Option<String> {
self.config().profiles.get(idx).map(|p| p.name.to_string())
}
pub(crate) fn clamp_profile_cursor(&mut self) {
let max = self.profile_count().saturating_sub(1);
self.profile_cursor = self.profile_cursor.min(max);
}
pub(crate) fn current_main_item(&self) -> Option<MainItemKind> {
self.main_items().get(self.profile_cursor).copied()
}
}
fn collect_tokens(profiles: &[Profile]) -> Vec<TokenEntry> {
profiles
.iter()
.filter_map(|p| {
let oauth = p.credentials.as_ref()?.claude_ai_oauth.as_ref()?;
Some(TokenEntry {
name: p.name.to_string(),
access_token: oauth.access_token.clone(),
refresh_token: oauth.refresh_token.clone(),
auto_start: p.auto_start,
access_expires_at: oauth.expires_at,
})
})
.collect()
}
pub(super) fn reconcile_startup(app: &mut App) {
let Some(active) = app.config().state.active_profile.clone() else {
let _ = app.startup_sender.send(StartupSignal::ReconcileDone);
return;
};
let live = with_state_lock(|| Ok(read_claude_credentials().ok().flatten()))
.ok()
.flatten();
let diverged = {
let cfg = app.config();
let stored = cfg.find(&active).and_then(|p| p.credentials.as_ref());
credentials_diverged(stored, live.as_ref())
};
if !diverged {
let mut cfg = app.config();
let _ = snapshot_active_credentials(&mut cfg);
let _ = app.startup_sender.send(StartupSignal::ReconcileDone);
return;
}
let _ = app
.startup_sender
.send(StartupSignal::ReconcileNeedsPrompt {
active: active.to_string(),
});
}
pub(crate) fn handle_key(app: &mut App, key: KeyEvent) {
if key.kind != KeyEventKind::Press {
return;
}
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
app.quit = true;
return;
}
if !app.modals.is_empty() {
handle_modal_key(app, key);
return;
}
if app.tab == Tab::Setup
&& app.config_focus == ConfigFocus::Actions
&& app
.config_draft
.as_ref()
.is_some_and(|d| d.active.is_some())
{
handle_config_edit_key(app, key);
return;
}
if app.tab == Tab::Fallback
&& app.fallback_focus == FallbackFocus::Detail
&& app.fallback_threshold_draft.is_some()
{
handle_fallback_threshold_edit_key(app, key);
return;
}
match key.code {
KeyCode::Right => {
app.disarm_quit();
switch_tab(app, app.tab.next());
return;
}
KeyCode::Left => {
app.disarm_quit();
switch_tab(app, app.tab.prev());
return;
}
KeyCode::Char('?') => {
app.disarm_quit();
app.modals.push(Modal::Help);
return;
}
KeyCode::Char('a') => {
app.disarm_quit();
let state = build_action_menu(app);
if !state.items.is_empty() {
app.modals.push(Modal::ActionMenu(state));
}
return;
}
KeyCode::Char('r') => {
app.disarm_quit();
if app.tab == Tab::Status {
trigger_status_refresh(app);
return;
}
if app.tab == Tab::Usage {
let selected = {
let cfg = app.config();
cfg.profiles
.get(app.profile_cursor)
.map(|p| (p.name.clone(), p.is_oauth(), p.is_third_party()))
};
match selected {
Some((name, true, _)) | Some((name, _, true)) => {
app.manual_refresh_one(&name);
app.toast(ToastKind::Info, format!("refreshing '{name}'…"));
}
Some((name, false, false)) => {
app.toast(ToastKind::Info, format!("'{name}' has no usage to refresh"));
}
None => {}
}
} else {
app.manual_refresh();
app.toast(ToastKind::Info, "refreshing usage…");
}
return;
}
KeyCode::Char('t') => {
app.disarm_quit();
app.modals.push(Modal::Confirm(ConfirmState {
message: ROTATE_ALL_MSG.to_string(),
detail: Some(ROTATE_ALL_DETAIL.to_string()),
choice: false,
on_confirm: ConfirmAction::RotateAll,
}));
return;
}
KeyCode::Char('n') => {
app.disarm_quit();
start_new_account(app);
return;
}
KeyCode::Esc => {
app.disarm_quit();
if app.tab == Tab::Setup && app.config_focus == ConfigFocus::Actions {
app.config_focus = ConfigFocus::Profiles;
app.config_draft = None;
} else if app.tab == Tab::Fallback && app.fallback_focus == FallbackFocus::Detail {
leave_fallback_detail(app);
} else if app.tab == Tab::Status && app.status.focus == StatusFocus::Detail {
app.status.focus = StatusFocus::List;
}
return;
}
KeyCode::Char('q') => {
let has_sub_focus = (app.tab == Tab::Setup && app.config_focus == ConfigFocus::Actions)
|| (app.tab == Tab::Fallback && app.fallback_focus == FallbackFocus::Detail)
|| (app.tab == Tab::Status && app.status.focus == StatusFocus::Detail);
if has_sub_focus {
app.disarm_quit();
if app.tab == Tab::Setup {
app.config_focus = ConfigFocus::Profiles;
app.config_draft = None;
} else if app.tab == Tab::Fallback {
leave_fallback_detail(app);
} else {
app.status.focus = StatusFocus::List;
}
} else if app.armed_quit {
app.quit = true;
} else {
app.armed_quit = true;
app.footer_alert = Some(FooterAlert::Warn("press q again to quit".to_string()));
}
return;
}
KeyCode::Char('x') => {
if !app.toasts.is_empty() {
app.toasts.pop_front();
} else if app.footer_alert.is_some() {
app.footer_alert = None;
if app.armed_quit {
app.armed_quit = false;
}
}
return;
}
_ => {
app.disarm_quit();
}
}
match app.tab {
Tab::Overview => handle_overview_key(app, key),
Tab::Usage => handle_usage_key(app, key),
Tab::Setup => handle_config_key(app, key),
Tab::Fallback => handle_fallback_key(app, key),
Tab::Config => handle_global_config_key(app, key),
Tab::Status => handle_status_key(app, key),
}
}
fn switch_tab(app: &mut App, tab: Tab) {
app.tab = tab;
app.tab_activity[tab.index()] = None;
app.config_draft = None;
app.clamp_profile_cursor();
match tab {
Tab::Overview | Tab::Usage => {}
Tab::Setup => {
app.config_focus = ConfigFocus::Profiles;
app.config_action_cursor = 0;
}
Tab::Fallback => {
app.chain_cursor = chain_cursor_for_profile(app);
sync_profile_from_chain(app);
app.fallback_focus = FallbackFocus::Chain;
app.fallback_detail_cursor = 0;
app.fallback_armed_remove = false;
app.fallback_threshold_draft = None;
}
Tab::Config => {
app.global_config_cursor = 0;
}
Tab::Status => {
app.status.focus = StatusFocus::List;
}
}
}
fn step_profile_cursor(app: &mut App, delta: i32, len: usize) {
if len == 0 {
return;
}
app.profile_cursor = (app.profile_cursor as i32 + delta).rem_euclid(len as i32) as usize;
}
fn handle_overview_key(app: &mut App, key: KeyEvent) {
let count = app.profile_count();
match key.code {
KeyCode::Up if key.modifiers.contains(KeyModifiers::SHIFT) => reorder_main_cursor(app, -1),
KeyCode::Down if key.modifiers.contains(KeyModifiers::SHIFT) => reorder_main_cursor(app, 1),
KeyCode::Up => step_profile_cursor(app, -1, count),
KeyCode::Down => step_profile_cursor(app, 1, count),
KeyCode::Enter => activate_main_item(app),
_ => {}
}
}
fn handle_usage_key(app: &mut App, key: KeyEvent) {
let count = app.profile_count();
match key.code {
KeyCode::Up => step_profile_cursor(app, -1, count),
KeyCode::Down => step_profile_cursor(app, 1, count),
_ => {}
}
}
fn handle_status_key(app: &mut App, key: KeyEvent) {
match app.status.focus {
StatusFocus::List => {
let len = app.status.incidents.len();
match key.code {
KeyCode::Up if len > 0 => {
app.status.cursor = (app.status.cursor + len - 1).rem_euclid(len.max(1));
app.status.detail_scroll = 0;
}
KeyCode::Down if len > 0 => {
app.status.cursor = (app.status.cursor + 1).rem_euclid(len.max(1));
app.status.detail_scroll = 0;
}
KeyCode::Enter if len > 0 => {
app.status.focus = StatusFocus::Detail;
app.status.detail_scroll = 0;
}
_ => {}
}
}
StatusFocus::Detail => match key.code {
KeyCode::Up => {
app.status.detail_scroll = app.status.detail_scroll.saturating_sub(1);
}
KeyCode::Down => {
let max = app.status.detail_max_scroll.get();
app.status.detail_scroll = app.status.detail_scroll.saturating_add(1).min(max);
}
_ => {}
},
}
}
fn trigger_status_refresh(app: &mut App) {
let _ = app.status_refresh.send(());
app.status.fetching = true;
app.toast(ToastKind::Info, "refreshing status…");
}
fn open_incident_link(app: &mut App) {
let Some(link) = app.status.selected().map(|i| i.link.clone()) else {
return;
};
if link.is_empty() {
app.toast(ToastKind::Info, "no link for this incident");
return;
}
let spawned = std::process::Command::new("xdg-open")
.arg(&link)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
match spawned {
Ok(_) => app.toast(ToastKind::Info, "opening in browser"),
Err(_) => app.toast(ToastKind::Danger, "failed to open browser"),
}
}
fn request_switch_to(app: &mut App, idx: usize) {
let cfg = app.config();
let Some(name) = cfg.profiles.get(idx).map(|p| p.name.to_string()) else {
return;
};
if cfg.is_active(&name) {
return;
}
drop(cfg);
app.modals.push(Modal::Confirm(ConfirmState {
message: format!("Switch to '{name}'?"),
detail: None,
choice: true,
on_confirm: ConfirmAction::Switch(name),
}));
}
fn activate_main_item(app: &mut App) {
let Some(item) = app.current_main_item() else {
return;
};
match item {
MainItemKind::Profile(idx) => request_switch_to(app, idx),
}
}
fn reorder_main_cursor(app: &mut App, delta: i32) {
let Some(MainItemKind::Profile(idx)) = app.current_main_item() else {
return;
};
let new_idx = match delta.signum() {
-1 if idx > 0 => idx - 1,
1 if idx + 1 < app.config().profiles.len() => idx + 1,
_ => return,
};
let result = {
let mut cfg = app.config();
reorder_profile(&mut cfg, idx, new_idx)
};
if let Err(e) = result {
app.toast(ToastKind::Danger, format!("reorder failed: {e}"));
return;
}
if delta < 0 && app.profile_cursor > 0 {
app.profile_cursor -= 1;
} else if delta > 0 {
app.profile_cursor += 1;
}
}
fn perform_switch(app: &mut App, name: &str) {
finalize_switch(app, name);
}
fn active_diverged_unsaved(active: &str) -> bool {
matches!(
classify_credentials_link(active).ok(),
Some(LinkState::Diverged)
) && !is_first_login(active).unwrap_or(false)
}
fn prompt_divergence(app: &mut App, active: String, verb: &str) {
app.toast(
ToastKind::Warning,
format!("'{active}' has unsaved Claude Code credentials — resolve before {verb}"),
);
app.modals
.push(Modal::Divergence(DivergenceForm { active, cursor: 0 }));
}
fn finalize_switch(app: &mut App, name: &str) {
let outgoing = app.config().state.active_profile.clone();
if let Some(active) = outgoing
&& active != name
&& active_diverged_unsaved(&active)
{
clear_activity(&app.activity, name);
prompt_divergence(app, active.to_string(), "switching");
return;
}
let result = {
let mut cfg = app.config();
switch_profile(&mut cfg, name)
};
clear_activity(&app.activity, name);
match result {
Ok(()) => {
app.refresh_tokens();
app.last_state_mtime = app_state_mtime();
app.toast(ToastKind::Success, format!("switched to '{name}'"));
}
Err(e) => app.toast(ToastKind::Danger, format!("switch failed: {e}")),
}
}
fn perform_switch_off(app: &mut App) {
let Some(active) = app.config().state.active_profile.clone() else {
return;
};
if active_diverged_unsaved(&active) {
prompt_divergence(app, active.to_string(), "switching off");
return;
}
let result = {
let mut cfg = app.config();
switch_off(&mut cfg)
};
match result {
Ok(()) => {
app.refresh_tokens();
app.last_state_mtime = app_state_mtime();
app.toast(
ToastKind::Warning,
"all accounts spent — switched off to halt usage".to_string(),
);
}
Err(e) => app.toast(ToastKind::Danger, format!("switch-off failed: {e}")),
}
}
fn begin_capture(app: &mut App, from_divergence: bool) {
let snapshot = match capture_snapshot() {
Ok(s) => s,
Err(e) => {
app.toast(ToastKind::Danger, format!("capture failed: {e}"));
return;
}
};
let existing_match = {
let cfg = app.config();
find_matching_oauth_profile(&cfg, snapshot.credentials.as_ref()).map(str::to_string)
};
if let Some(existing) = existing_match {
app.modals.push(Modal::Confirm(ConfirmState {
message: format!("These credentials already belong to '{existing}'."),
detail: Some("Capture anyway?".to_string()),
choice: false,
on_confirm: ConfirmAction::CaptureConflict(Box::new(snapshot), from_divergence),
}));
return;
}
app.modals.push(Modal::CaptureName(CaptureNameForm {
snapshot: Box::new(snapshot),
input: InputState::new(""),
from_divergence,
}));
}
#[derive(Debug, Clone, Copy)]
pub(crate) enum ChainItemKind {
Member(usize),
Add,
}
pub(crate) fn chain_items(app: &App) -> Vec<ChainItemKind> {
let cfg = app.config();
let mut items: Vec<ChainItemKind> = cfg
.state
.fallback_chain
.iter()
.enumerate()
.map(|(i, _)| ChainItemKind::Member(i))
.collect();
let any_unchained = cfg
.profiles
.iter()
.any(|p| !cfg.state.fallback_chain.iter().any(|c| c == &p.name));
if any_unchained {
items.push(ChainItemKind::Add);
}
items
}
pub(crate) const FALLBACK_ROWS: [FallbackRow; 2] = [FallbackRow::Threshold, FallbackRow::Remove];
pub(crate) const GLOBAL_CONFIG_ROWS: [GlobalConfigRow; 2] =
[GlobalConfigRow::Theme, GlobalConfigRow::WrapOff];
fn handle_global_config_key(app: &mut App, key: KeyEvent) {
let last = GLOBAL_CONFIG_ROWS.len() - 1;
app.global_config_cursor = app.global_config_cursor.min(last);
match key.code {
KeyCode::Up => {
app.global_config_cursor = if app.global_config_cursor == 0 {
last
} else {
app.global_config_cursor - 1
};
}
KeyCode::Down => {
app.global_config_cursor = if app.global_config_cursor >= last {
0
} else {
app.global_config_cursor + 1
};
}
KeyCode::Enter | KeyCode::Char(' ') => {
run_global_config_row(app, GLOBAL_CONFIG_ROWS[app.global_config_cursor]);
}
_ => {}
}
}
fn run_global_config_row(app: &mut App, row: GlobalConfigRow) {
match row {
GlobalConfigRow::Theme => cycle_theme(app),
GlobalConfigRow::WrapOff => toggle_wrap_off(app),
}
}
fn cycle_theme(app: &mut App) {
let next = match theme::tier() {
theme::Tier::Full => theme::Tier::Compatible,
theme::Tier::Compatible => theme::Tier::Full,
};
let name = match next {
theme::Tier::Full => ThemeName::Full,
theme::Tier::Compatible => ThemeName::Compatible,
};
{
let mut cfg = app.config();
cfg.state.theme = Some(name);
let _ = save_app_state(&cfg.state);
}
app.last_state_mtime = app_state_mtime();
theme::set_tier(next);
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum FallbackHint {
Empty,
ChainMember,
ChainAdd,
DetailThreshold,
DetailThresholdEdit,
DetailRemove,
DetailRemoveArmed,
DetailAdd,
}
pub(crate) fn fallback_hint(app: &App) -> FallbackHint {
if chain_items(app).is_empty() {
return FallbackHint::Empty;
}
match app.fallback_focus {
FallbackFocus::Chain => match selected_chain_member(app) {
Some(_) => FallbackHint::ChainMember,
None => FallbackHint::ChainAdd,
},
FallbackFocus::Detail => {
if selected_chain_member(app).is_none() {
return FallbackHint::DetailAdd;
}
if app.fallback_threshold_draft.is_some() {
return FallbackHint::DetailThresholdEdit;
}
let cursor = app.fallback_detail_cursor.min(FALLBACK_ROWS.len() - 1);
match FALLBACK_ROWS[cursor] {
FallbackRow::Threshold => FallbackHint::DetailThreshold,
FallbackRow::Remove if app.fallback_armed_remove => FallbackHint::DetailRemoveArmed,
FallbackRow::Remove => FallbackHint::DetailRemove,
}
}
}
}
fn handle_fallback_key(app: &mut App, key: KeyEvent) {
match app.fallback_focus {
FallbackFocus::Chain => handle_fallback_chain_key(app, key),
FallbackFocus::Detail => handle_fallback_detail_key(app, key),
}
}
fn chain_cursor_for_profile(app: &App) -> usize {
let cfg = app.config();
let selected_name = cfg
.profiles
.get(app.profile_cursor)
.map(|p| p.name.as_str());
if let Some(name) = selected_name
&& let Some(pos) = cfg.state.fallback_chain.iter().position(|c| c == name)
{
return pos;
}
0
}
fn sync_profile_from_chain(app: &mut App) {
let chain_pos = app.chain_cursor;
let profile_idx = {
let cfg = app.config();
match cfg.state.fallback_chain.get(chain_pos) {
Some(name) => cfg.profiles.iter().position(|p| p.name == *name),
None => None,
}
};
if let Some(idx) = profile_idx {
app.profile_cursor = idx;
}
}
fn handle_fallback_chain_key(app: &mut App, key: KeyEvent) {
let last = chain_items(app).len().saturating_sub(1);
match key.code {
KeyCode::Up if key.modifiers.contains(KeyModifiers::SHIFT) => reorder_chain_member(app, -1),
KeyCode::Down if key.modifiers.contains(KeyModifiers::SHIFT) => {
reorder_chain_member(app, 1)
}
KeyCode::Up => {
app.chain_cursor = if app.chain_cursor == 0 {
last
} else {
app.chain_cursor - 1
};
sync_profile_from_chain(app);
}
KeyCode::Down => {
app.chain_cursor = if app.chain_cursor >= last {
0
} else {
app.chain_cursor + 1
};
sync_profile_from_chain(app);
}
KeyCode::Enter => enter_fallback_detail(app),
_ => {}
}
}
fn toggle_wrap_off(app: &mut App) {
{
let mut cfg = app.config();
cfg.state.wrap_off = !cfg.state.wrap_off;
let _ = save_app_state(&cfg.state);
}
app.last_state_mtime = app_state_mtime();
}
fn handle_fallback_detail_key(app: &mut App, key: KeyEvent) {
if selected_chain_member(app).is_none() {
handle_fallback_add_key(app, key);
return;
}
let last = FALLBACK_ROWS.len() - 1;
app.fallback_detail_cursor = app.fallback_detail_cursor.min(last);
let on_threshold = FALLBACK_ROWS[app.fallback_detail_cursor] == FallbackRow::Threshold;
match key.code {
KeyCode::Up => {
app.fallback_armed_remove = false;
app.fallback_detail_cursor = if app.fallback_detail_cursor == 0 {
last
} else {
app.fallback_detail_cursor - 1
};
}
KeyCode::Down => {
app.fallback_armed_remove = false;
app.fallback_detail_cursor = if app.fallback_detail_cursor >= last {
0
} else {
app.fallback_detail_cursor + 1
};
}
KeyCode::Char('+' | '=') if on_threshold => adjust_threshold(app, 5.0),
KeyCode::Char('-' | '_') if on_threshold => adjust_threshold(app, -5.0),
KeyCode::Enter | KeyCode::Char(' ') => {
run_fallback_row(app, FALLBACK_ROWS[app.fallback_detail_cursor]);
}
_ => {}
}
}
fn handle_fallback_add_key(app: &mut App, key: KeyEvent) {
let candidates = chain_candidates(app);
if candidates.is_empty() {
leave_fallback_detail(app);
return;
}
let last = candidates.len() - 1;
app.fallback_detail_cursor = app.fallback_detail_cursor.min(last);
match key.code {
KeyCode::Up => {
app.fallback_detail_cursor = if app.fallback_detail_cursor == 0 {
last
} else {
app.fallback_detail_cursor - 1
};
}
KeyCode::Down => {
app.fallback_detail_cursor = if app.fallback_detail_cursor >= last {
0
} else {
app.fallback_detail_cursor + 1
};
}
KeyCode::Enter | KeyCode::Char(' ') => {
let name = candidates[app.fallback_detail_cursor].clone();
add_chain_candidate(app, &name);
app.toast(ToastKind::Success, format!("added '{name}' to chain"));
let remaining = chain_candidates(app);
if remaining.is_empty() {
leave_fallback_detail(app);
app.chain_cursor = chain_items(app).len().saturating_sub(1);
} else {
app.fallback_detail_cursor = app.fallback_detail_cursor.min(remaining.len() - 1);
}
}
_ => {}
}
}
fn selected_chain_member(app: &App) -> Option<usize> {
match chain_items(app).get(app.chain_cursor).copied() {
Some(ChainItemKind::Member(i)) => Some(i),
_ => None,
}
}
pub(crate) fn chain_candidates(app: &App) -> Vec<String> {
let cfg = app.config();
cfg.profiles
.iter()
.filter(|p| !cfg.state.fallback_chain.iter().any(|c| c == &p.name))
.map(|p| p.name.to_string())
.collect()
}
fn enter_fallback_detail(app: &mut App) {
match chain_items(app).get(app.chain_cursor).copied() {
Some(ChainItemKind::Member(_)) => {}
Some(ChainItemKind::Add) if !chain_candidates(app).is_empty() => {}
_ => return,
}
app.fallback_detail_cursor = 0;
app.fallback_armed_remove = false;
app.fallback_focus = FallbackFocus::Detail;
}
fn leave_fallback_detail(app: &mut App) {
app.fallback_focus = FallbackFocus::Chain;
app.fallback_armed_remove = false;
app.fallback_detail_cursor = 0;
app.fallback_threshold_draft = None;
}
fn reorder_chain_member(app: &mut App, delta: i32) {
let Some(pos) = selected_chain_member(app) else {
return;
};
let target = pos as i32 + delta;
{
let mut cfg = app.config();
if target < 0 || target as usize >= cfg.state.fallback_chain.len() {
return;
}
cfg.state.fallback_chain.swap(pos, target as usize);
let _ = save_app_state(&cfg.state);
}
app.chain_cursor = target as usize;
}
fn run_fallback_row(app: &mut App, row: FallbackRow) {
match row {
FallbackRow::Threshold => {
if let Some(current) = selected_threshold(app) {
app.fallback_threshold_draft = Some(InputState::new(&format!("{current:.0}")));
}
}
FallbackRow::Remove => {
if app.fallback_armed_remove {
remove_chain_member(app);
} else {
app.fallback_armed_remove = true;
}
}
}
}
fn handle_fallback_threshold_edit_key(app: &mut App, key: KeyEvent) {
match key.code {
KeyCode::Esc => app.fallback_threshold_draft = None,
KeyCode::Enter => commit_threshold_edit(app),
_ => {
if let Some(input) = app.fallback_threshold_draft.as_mut() {
apply_input_edit(input, key);
}
}
}
}
fn commit_threshold_edit(app: &mut App) {
let Some(raw) = app.fallback_threshold_draft.as_ref().map(|i| i.trimmed()) else {
return;
};
let Some(value) = parse_threshold(raw) else {
return;
};
write_threshold(app, value);
app.fallback_threshold_draft = None;
}
pub(crate) fn parse_threshold(raw: &str) -> Option<f64> {
raw.parse::<f64>()
.ok()
.filter(|v| (0.0..=100.0).contains(v))
}
fn selected_threshold(app: &App) -> Option<f64> {
let pos = selected_chain_member(app)?;
let cfg = app.config();
let name = cfg.state.fallback_chain.get(pos)?;
cfg.find(name).map(threshold_for)
}
fn write_threshold(app: &mut App, value: f64) {
let Some(pos) = selected_chain_member(app) else {
return;
};
let save_err = {
let mut cfg = app.config();
let Some(name) = cfg.state.fallback_chain.get(pos).cloned() else {
return;
};
match cfg.find_mut(&name) {
Some(profile) => {
profile.fallback_threshold = Some(value);
save_profile(profile).err()
}
None => None,
}
};
if let Some(e) = save_err {
app.toast(ToastKind::Danger, format!("save failed: {e}"));
}
}
fn adjust_threshold(app: &mut App, delta: f64) {
if let Some(current) = selected_threshold(app) {
write_threshold(app, (current + delta).clamp(0.0, 100.0));
}
}
fn add_chain_candidate(app: &mut App, name: &str) {
let mut cfg = app.config();
if let Some(profile) = cfg.find_mut(name)
&& profile.fallback_threshold.is_none()
{
profile.fallback_threshold = Some(DEFAULT_THRESHOLD);
let _ = save_profile(profile);
}
cfg.state.fallback_chain.push(name.into());
let _ = save_app_state(&cfg.state);
}
fn remove_chain_member(app: &mut App) {
let Some(pos) = selected_chain_member(app) else {
return;
};
let name = {
let mut cfg = app.config();
let Some(name) = cfg.state.fallback_chain.get(pos).cloned() else {
return;
};
cfg.state.fallback_chain.retain(|n| n != &name);
let _ = save_app_state(&cfg.state);
name
};
leave_fallback_detail(app);
let items_len = chain_items(app).len();
if app.chain_cursor >= items_len {
app.chain_cursor = items_len.saturating_sub(1);
}
app.toast(ToastKind::Info, format!("removed '{name}' from chain"));
}
fn handle_modal_key(app: &mut App, key: KeyEvent) {
let Some(top) = app.modals.last().cloned() else {
return;
};
match top {
Modal::Help => {
if matches!(
key.code,
KeyCode::Esc | KeyCode::Enter | KeyCode::Char('?' | 'q')
) {
app.modals.pop();
}
}
Modal::Confirm(_) => handle_confirm_key(app, key),
Modal::Divergence(_) => handle_divergence_key(app, key),
Modal::CaptureName(_) => handle_capture_name_key(app, key),
Modal::ActionMenu(_) => handle_action_menu_key(app, key),
}
}
fn build_action_menu(app: &App) -> ActionMenuState {
use ActionMenuAction::*;
let mut actions: Vec<ActionMenuAction> = Vec::new();
match app.tab {
Tab::Overview => {
let can_switch = app
.current_main_item()
.map(|item| match item {
MainItemKind::Profile(idx) => {
let cfg = app.config();
cfg.profiles
.get(idx)
.map(|p| !cfg.is_active(&p.name))
.unwrap_or(false)
}
})
.unwrap_or(false);
if can_switch {
actions.push(SwitchToSelected);
}
actions.push(NewAccount);
actions.push(RefreshUsage);
actions.push(RotateTokens);
}
Tab::Usage => {
actions.push(RefreshUsage);
}
Tab::Setup => match app.config_focus {
ConfigFocus::Profiles => {
if app.profile_cursor < app.profile_count() {
actions.push(ConfigureSelected);
}
actions.push(NewAccount);
}
ConfigFocus::Actions => {
let rows = config_rows(app);
if let Some(&row) = rows.get(app.config_action_cursor) {
match row {
ConfigRow::AutoStart => actions.push(ActionMenuAction::ToggleAutoStart),
ConfigRow::Delete => actions.push(ActionMenuAction::DeleteProfile),
ConfigRow::Create => actions.push(ActionMenuAction::CreateProfile),
_ => actions.push(ActionMenuAction::EditField),
}
}
}
},
Tab::Fallback => match app.fallback_focus {
FallbackFocus::Chain => {
let items = chain_items(app);
if let Some(item) = items.get(app.chain_cursor) {
match item {
ChainItemKind::Member(_) => {
actions.push(OpenChainMember);
actions.push(ReorderUp);
actions.push(ReorderDown);
}
ChainItemKind::Add => {}
}
}
}
FallbackFocus::Detail => {
if let Some(&row) = FALLBACK_ROWS.get(app.fallback_detail_cursor) {
match row {
FallbackRow::Threshold => actions.push(EditThreshold),
FallbackRow::Remove => actions.push(RemoveMember),
}
}
}
},
Tab::Config => {
if let Some(&row) = GLOBAL_CONFIG_ROWS.get(app.global_config_cursor) {
match row {
GlobalConfigRow::Theme => actions.push(CycleTheme),
GlobalConfigRow::WrapOff => actions.push(ToggleWrapOff),
}
}
}
Tab::Status => {
actions.push(RefreshStatus);
if app.status.selected().is_some() {
actions.push(OpenIncidentLink);
}
}
}
ActionMenuState::new(actions)
}
fn handle_action_menu_key(app: &mut App, key: KeyEvent) {
if let KeyCode::Char(ch) = key.code {
let ch_lower = ch.to_lowercase().next().unwrap_or(ch);
let Some(Modal::ActionMenu(state)) = app.modals.last() else {
return;
};
let action = state
.items
.iter()
.find(|item| item.hotkey == Some(ch_lower))
.map(|item| item.action.clone());
if let Some(action) = action {
app.modals.pop();
dispatch_action_menu_action(app, action);
return;
}
}
match key.code {
KeyCode::Esc | KeyCode::Char('q') => {
app.modals.pop();
}
KeyCode::Up => {
let Some(Modal::ActionMenu(state)) = app.modals.last_mut() else {
return;
};
let len = state.items.len();
if len > 0 {
state.cursor = (state.cursor + len - 1) % len;
}
}
KeyCode::Down => {
let Some(Modal::ActionMenu(state)) = app.modals.last_mut() else {
return;
};
let len = state.items.len();
if len > 0 {
state.cursor = (state.cursor + 1) % len;
}
}
KeyCode::Enter => {
let Some(Modal::ActionMenu(state)) = app.modals.last() else {
return;
};
let action = state
.items
.get(state.cursor)
.map(|item| item.action.clone());
app.modals.pop();
if let Some(action) = action {
dispatch_action_menu_action(app, action);
}
}
_ => {}
}
}
fn focused_account(app: &App) -> Option<(String, bool, bool)> {
let cfg = app.config();
cfg.profiles
.get(app.profile_cursor)
.map(|p| (p.name.to_string(), p.is_oauth(), p.is_third_party()))
}
fn dispatch_action_menu_action(app: &mut App, action: ActionMenuAction) {
match action {
ActionMenuAction::NewAccount => start_new_account(app),
ActionMenuAction::RefreshUsage => {
match focused_account(app) {
Some((name, _, true)) | Some((name, true, _)) => {
app.manual_refresh_one(&name);
app.toast(ToastKind::Info, format!("refreshing '{name}'…"));
}
Some((name, false, false)) => {
app.toast(ToastKind::Info, format!("'{name}' has no usage to refresh"));
}
None => {}
}
}
ActionMenuAction::RotateTokens => {
match focused_account(app) {
Some((name, true, _)) => {
app.modals.push(Modal::Confirm(ConfirmState {
message: format!("rotate tokens for '{name}'?"),
detail: Some(ROTATE_ONE_DETAIL.to_string()),
choice: false,
on_confirm: ConfirmAction::RotateOne(name),
}));
}
Some((name, _, _)) => {
app.toast(ToastKind::Info, format!("'{name}' has no tokens to rotate"));
}
None => {}
}
}
ActionMenuAction::SwitchToSelected => activate_main_item(app),
ActionMenuAction::ConfigureSelected => enter_config_detail(app),
ActionMenuAction::OpenChainMember => enter_fallback_detail(app),
ActionMenuAction::ReorderUp => reorder_chain_member(app, -1),
ActionMenuAction::ReorderDown => reorder_chain_member(app, 1),
ActionMenuAction::EditThreshold => {
run_fallback_row(app, FallbackRow::Threshold);
}
ActionMenuAction::ToggleWrapOff => {
toggle_wrap_off(app);
}
ActionMenuAction::RemoveMember => {
run_fallback_row(app, FallbackRow::Remove);
}
ActionMenuAction::ToggleAutoStart => {
let rows = config_rows(app);
if let Some(&row) = rows.get(app.config_action_cursor) {
run_config_row(app, row);
}
}
ActionMenuAction::DeleteProfile => {
let rows = config_rows(app);
if let Some(&row) = rows.get(app.config_action_cursor) {
run_config_row(app, row);
}
}
ActionMenuAction::CreateProfile => {
let rows = config_rows(app);
if let Some(&row) = rows.get(app.config_action_cursor) {
run_config_row(app, row);
}
}
ActionMenuAction::EditField => {
let rows = config_rows(app);
if let Some(&row) = rows.get(app.config_action_cursor) {
run_config_row(app, row);
}
}
ActionMenuAction::CycleTheme => cycle_theme(app),
ActionMenuAction::RefreshStatus => trigger_status_refresh(app),
ActionMenuAction::OpenIncidentLink => open_incident_link(app),
}
}
fn handle_config_key(app: &mut App, key: KeyEvent) {
let sel_len = app.profile_count() + 1;
app.profile_cursor = app.profile_cursor.min(sel_len - 1);
match app.config_focus {
ConfigFocus::Profiles => match key.code {
KeyCode::Up => step_profile_cursor(app, -1, sel_len),
KeyCode::Down => step_profile_cursor(app, 1, sel_len),
KeyCode::Enter => enter_config_detail(app),
_ => {}
},
ConfigFocus::Actions => {
let rows = config_rows(app);
if rows.is_empty() {
app.config_focus = ConfigFocus::Profiles;
app.config_draft = None;
return;
}
let last = rows.len() - 1;
app.config_action_cursor = app.config_action_cursor.min(last);
match key.code {
KeyCode::Up => {
disarm_delete(app);
app.config_action_cursor = if app.config_action_cursor == 0 {
last
} else {
app.config_action_cursor - 1
};
}
KeyCode::Down => {
disarm_delete(app);
app.config_action_cursor = if app.config_action_cursor >= last {
0
} else {
app.config_action_cursor + 1
};
}
KeyCode::Enter | KeyCode::Char(' ') => {
run_config_row(app, rows[app.config_action_cursor]);
}
_ => {}
}
}
}
}
pub(crate) fn config_rows(app: &App) -> Vec<ConfigRow> {
let cfg = app.config();
if app.profile_cursor >= cfg.profiles.len() {
return vec![
ConfigRow::Name,
ConfigRow::BaseUrl,
ConfigRow::ApiKey,
ConfigRow::Create,
];
}
let is_oauth = cfg
.profiles
.get(app.profile_cursor)
.map(|p| p.is_oauth())
.unwrap_or(true);
let mut rows = vec![ConfigRow::Name, ConfigRow::BaseUrl, ConfigRow::ApiKey];
if is_oauth {
rows.push(ConfigRow::AutoStart);
}
rows.push(ConfigRow::Delete);
rows
}
fn enter_config_detail(app: &mut App) {
app.config_action_cursor = 0;
if app.profile_cursor >= app.profile_count() {
app.config_draft = Some(build_draft_new());
} else if let Some(name) = app.profile_name_at(app.profile_cursor) {
app.config_draft = Some(build_draft_existing(app, &name));
} else {
return;
}
app.config_focus = ConfigFocus::Actions;
}
fn start_new_account(app: &mut App) {
switch_tab(app, Tab::Setup);
app.profile_cursor = app.profile_count();
app.config_action_cursor = 0;
app.config_draft = Some(build_draft_new());
app.config_focus = ConfigFocus::Actions;
}
fn build_draft_new() -> ConfigDraft {
ConfigDraft {
editing_name: None,
name: InputState::new(""),
base_url: InputState::new(""),
api_key: InputState::new(""),
active: None,
armed_delete: false,
}
}
fn build_draft_existing(app: &App, name: &str) -> ConfigDraft {
let cfg = app.config();
let profile = cfg.find(name);
ConfigDraft {
editing_name: Some(name.to_string()),
name: InputState::new(name),
base_url: InputState::new(profile.and_then(|p| p.base_url.as_deref()).unwrap_or("")),
api_key: InputState::new(profile.and_then(|p| p.api_key.as_deref()).unwrap_or("")),
active: None,
armed_delete: false,
}
}
fn disarm_delete(app: &mut App) {
if let Some(d) = app.config_draft.as_mut() {
d.armed_delete = false;
}
}
fn run_config_row(app: &mut App, row: ConfigRow) {
if row.is_text() {
if let Some(d) = app.config_draft.as_mut() {
d.active = Some(row);
match row {
ConfigRow::Name => d.name.end(),
ConfigRow::BaseUrl => d.base_url.end(),
ConfigRow::ApiKey => d.api_key.end(),
_ => {}
}
}
return;
}
let name = app
.config_draft
.as_ref()
.and_then(|d| d.editing_name.clone());
match row {
ConfigRow::AutoStart => {
if let Some(name) = name {
toggle_auto_start(app, &name);
}
}
ConfigRow::Delete => {
let armed = app
.config_draft
.as_ref()
.map(|d| d.armed_delete)
.unwrap_or(false);
match (armed, name) {
(true, Some(name)) => perform_delete(app, &name),
_ => disarm_delete_inverse(app),
}
}
ConfigRow::Create => commit_new_account(app),
_ => {}
}
}
fn disarm_delete_inverse(app: &mut App) {
if let Some(d) = app.config_draft.as_mut() {
d.armed_delete = true;
}
}
fn handle_config_edit_key(app: &mut App, key: KeyEvent) {
let Some(active) = app.config_draft.as_ref().and_then(|d| d.active) else {
return;
};
match key.code {
KeyCode::Esc => cancel_config_edit(app, active),
KeyCode::Enter => commit_config_field(app, active),
_ => {
if let Some(d) = app.config_draft.as_mut() {
let input = match active {
ConfigRow::Name => &mut d.name,
ConfigRow::BaseUrl => &mut d.base_url,
ConfigRow::ApiKey => &mut d.api_key,
_ => return,
};
apply_input_edit(input, key);
}
}
}
}
fn cancel_config_edit(app: &mut App, field: ConfigRow) {
let editing_name = app
.config_draft
.as_ref()
.and_then(|d| d.editing_name.clone());
if let Some(name) = editing_name {
let value = {
let cfg = app.config();
let profile = cfg.find(&name);
match field {
ConfigRow::Name => name.clone(),
ConfigRow::BaseUrl => profile.and_then(|p| p.base_url.clone()).unwrap_or_default(),
ConfigRow::ApiKey => profile.and_then(|p| p.api_key.clone()).unwrap_or_default(),
_ => String::new(),
}
};
if let Some(d) = app.config_draft.as_mut() {
match field {
ConfigRow::Name => d.name = InputState::new(&value),
ConfigRow::BaseUrl => d.base_url = InputState::new(&value),
ConfigRow::ApiKey => d.api_key = InputState::new(&value),
_ => {}
}
}
}
if let Some(d) = app.config_draft.as_mut() {
d.active = None;
}
}
fn commit_config_field(app: &mut App, field: ConfigRow) {
let is_new = app
.config_draft
.as_ref()
.map(|d| d.editing_name.is_none())
.unwrap_or(true);
if is_new {
if let Some(d) = app.config_draft.as_mut() {
d.active = None;
}
return;
}
match field {
ConfigRow::Name => commit_rename(app),
ConfigRow::BaseUrl | ConfigRow::ApiKey => commit_endpoint(app),
_ => {
if let Some(d) = app.config_draft.as_mut() {
d.active = None;
}
}
}
}
fn commit_rename(app: &mut App) {
let Some(d) = app.config_draft.as_ref() else {
return;
};
let Some(old) = d.editing_name.clone() else {
return;
};
let new = d.name.trimmed().to_string();
if new == old {
if let Some(d) = app.config_draft.as_mut() {
d.active = None;
}
return;
}
let validation = {
let cfg = app.config();
validate_profile_name(&new, &cfg.names(), Some(old.as_str()))
};
if let Err(e) = validation {
app.toast(ToastKind::Danger, format!("{e}"));
return;
}
let result = {
let mut cfg = app.config();
rename_profile(&mut cfg, &old, &new)
};
match result {
Ok(()) => {
app.refresh_tokens();
app.last_state_mtime = app_state_mtime();
if let Some(d) = app.config_draft.as_mut() {
d.editing_name = Some(new.clone());
d.name = InputState::new(&new);
d.active = None;
}
app.toast(ToastKind::Success, format!("renamed '{old}' → '{new}'"));
}
Err(e) => app.toast(ToastKind::Danger, format!("rename failed: {e}")),
}
}
fn commit_endpoint(app: &mut App) {
let Some(d) = app.config_draft.as_ref() else {
return;
};
let Some(name) = d.editing_name.clone() else {
return;
};
let base_url = d.base_url.trimmed_some();
let api_key = if base_url.is_some() {
d.api_key.trimmed_some()
} else {
None
};
let result = {
let mut cfg = app.config();
edit_profile_endpoint(&mut cfg, &name, base_url, api_key)
};
match result {
Ok(()) => {
let (base, key) = {
let cfg = app.config();
let p = cfg.find(&name);
(
p.and_then(|p| p.base_url.clone()).unwrap_or_default(),
p.and_then(|p| p.api_key.clone()).unwrap_or_default(),
)
};
if let Some(d) = app.config_draft.as_mut() {
d.base_url = InputState::new(&base);
d.api_key = InputState::new(&key);
d.active = None;
}
}
Err(e) => app.toast(ToastKind::Danger, format!("edit failed: {e}")),
}
}
fn commit_new_account(app: &mut App) {
let Some(d) = app.config_draft.as_ref() else {
return;
};
let name = d.name.trimmed().to_string();
let base_url = d.base_url.trimmed_some();
let api_key = d.api_key.trimmed_some();
let validation = {
let cfg = app.config();
validate_profile_name(&name, &cfg.names(), None)
};
if let Err(e) = validation {
app.toast(ToastKind::Danger, format!("{e}"));
return;
}
let api_key = if base_url.is_some() { api_key } else { None };
let result = {
let mut cfg = app.config();
create_blank_profile(&mut cfg, name.clone(), base_url, api_key)
};
match result {
Ok(()) => {
app.refresh_tokens();
app.last_state_mtime = app_state_mtime();
let new_idx = app
.config()
.profiles
.iter()
.position(|p| p.name == name)
.unwrap_or(0);
app.profile_cursor = new_idx;
app.config_focus = ConfigFocus::Profiles;
app.config_draft = None;
app.toast(ToastKind::Success, format!("created '{name}'"));
}
Err(e) => app.toast(ToastKind::Danger, format!("create failed: {e}")),
}
}
fn perform_delete(app: &mut App, name: &str) {
let result = {
let mut cfg = app.config();
delete_profile(&mut cfg, name)
};
match result {
Ok(()) => {
app.refresh_tokens();
app.last_state_mtime = app_state_mtime();
app.config_focus = ConfigFocus::Profiles;
app.config_draft = None;
app.clamp_profile_cursor();
app.toast(ToastKind::Success, format!("deleted '{name}'"));
}
Err(e) => app.toast(ToastKind::Danger, format!("delete failed: {e}")),
}
}
fn toggle_auto_start(app: &mut App, name: &str) {
enum Outcome {
NotOAuth,
Saved(bool),
SaveFailed(anyhow::Error),
Missing,
}
let outcome = {
let mut cfg = app.config();
match cfg.find_mut(name) {
None => Outcome::Missing,
Some(profile) if !profile.is_oauth() => Outcome::NotOAuth,
Some(profile) => {
profile.auto_start = !profile.auto_start;
let now_on = profile.auto_start;
match save_profile(profile) {
Ok(()) => Outcome::Saved(now_on),
Err(e) => {
if let Some(p) = cfg.find_mut(name) {
p.auto_start = !now_on;
}
Outcome::SaveFailed(e)
}
}
}
}
};
match outcome {
Outcome::Missing => {}
Outcome::NotOAuth => app.toast(
ToastKind::Warning,
"auto-start usage only applies to OAuth profiles",
),
Outcome::Saved(_now_on) => {
app.refresh_tokens();
}
Outcome::SaveFailed(e) => {
app.toast(ToastKind::Danger, format!("save failed: {e}"));
}
}
}
fn handle_confirm_key(app: &mut App, key: KeyEvent) {
let Some(Modal::Confirm(state)) = app.modals.last_mut() else {
return;
};
match key.code {
KeyCode::Left | KeyCode::Right | KeyCode::Tab => {
state.choice = !state.choice;
}
KeyCode::Char('y') => state.choice = true,
KeyCode::Char('n') => state.choice = false,
KeyCode::Esc => {
app.modals.pop();
}
KeyCode::Enter | KeyCode::Char(' ') => {
let confirmed = state.choice;
let action = state.on_confirm.clone();
app.modals.pop();
if confirmed {
run_confirm_action(app, action);
}
}
_ => {}
}
}
fn run_confirm_action(app: &mut App, action: ConfirmAction) {
match action {
ConfirmAction::CaptureConflict(snapshot, from_divergence) => {
app.modals.push(Modal::CaptureName(CaptureNameForm {
snapshot,
input: InputState::new(""),
from_divergence,
}));
}
ConfirmAction::Switch(name) => {
if !is_idle(&app.activity, &name) {
app.toast(
ToastKind::Warning,
format!("'{name}' is already busy — try again in a moment"),
);
return;
}
perform_switch(app, &name);
}
ConfirmAction::DiscardDivergence(name) => run_discard_divergence(app, &name),
ConfirmAction::RotateAll => {
if app.bootstrap_active.load(Ordering::SeqCst) || any_busy(&app.activity) {
app.toast(
ToastKind::Warning,
"rotate-all skipped — another op is still in flight",
);
return;
}
let config = Arc::clone(&app.config);
let refetch = Arc::clone(&app.refetch_queue);
let activity = Arc::clone(&app.activity);
let sender = app.op_sender.clone();
std::thread::spawn(move || {
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _ = oauth::refresh_all(&config, true, &refetch, &activity, &sender);
}));
});
app.toast(ToastKind::Info, "rotating all tokens…");
}
ConfirmAction::RotateOne(name) => {
let config = Arc::clone(&app.config);
let refetch = Arc::clone(&app.refetch_queue);
let activity = Arc::clone(&app.activity);
let sender = app.op_sender.clone();
let target = name.clone();
std::thread::spawn(move || {
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
oauth::rotate_one(&config, &target, &refetch, &activity, &sender, true);
}));
});
app.toast(ToastKind::Info, format!("rotating '{name}'…"));
}
}
}
fn handle_divergence_key(app: &mut App, key: KeyEvent) {
let Some(Modal::Divergence(state)) = app.modals.last_mut() else {
return;
};
let options = DivergenceForm::options();
let last = options.len() - 1;
match key.code {
KeyCode::Up => {
state.cursor = if state.cursor == 0 {
last
} else {
state.cursor - 1
};
}
KeyCode::Down => {
state.cursor = if state.cursor >= last {
0
} else {
state.cursor + 1
};
}
KeyCode::Esc => {
app.modals.pop();
}
KeyCode::Enter | KeyCode::Char(' ') => {
let choice = options[state.cursor];
let active = state.active.clone();
app.modals.pop();
run_divergence_choice(app, &active, choice);
}
_ => {}
}
}
fn run_divergence_choice(app: &mut App, active: &str, choice: DivergenceChoice) {
match choice {
DivergenceChoice::Overwrite => {
let snapshot_result = {
let mut cfg = app.config();
force_snapshot_active_credentials(&mut cfg)
};
if let Err(e) = snapshot_result {
app.toast(ToastKind::Danger, format!("overwrite failed: {e}"));
return;
}
if let Err(e) = force_link_profile_credentials(active) {
app.toast(ToastKind::Danger, format!("relink failed: {e}"));
return;
}
app.refresh_tokens();
app.toast(
ToastKind::Success,
format!("saved live credentials into '{active}'"),
);
}
DivergenceChoice::NewProfile => {
begin_capture(app, true);
}
DivergenceChoice::Discard => {
app.modals.push(Modal::Confirm(ConfirmState {
message: format!("Discard the new login and restore '{active}'?"),
detail: Some(
"Claude Code's freshly written credentials will be overwritten with the profile's stored tokens.".to_string(),
),
choice: false,
on_confirm: ConfirmAction::DiscardDivergence(active.to_string()),
}));
}
}
}
fn run_discard_divergence(app: &mut App, active: &str) {
if let Err(e) = force_link_profile_credentials(active) {
app.toast(ToastKind::Danger, format!("discard failed: {e}"));
return;
}
app.toast(
ToastKind::Warning,
format!("discarded new login; restored '{active}'"),
);
}
fn handle_capture_name_key(app: &mut App, key: KeyEvent) {
let Some(Modal::CaptureName(form)) = app.modals.last_mut() else {
return;
};
match key.code {
KeyCode::Esc => {
app.modals.pop();
}
KeyCode::Enter => {
let name = form.input.trimmed().to_string();
let validation = {
let cfg = app.config();
let existing = cfg.names();
validate_profile_name(&name, &existing, None)
};
if let Err(e) = validation {
app.toast(ToastKind::Danger, format!("{e}"));
return;
}
let Some(Modal::CaptureName(form)) = app.modals.pop() else {
return;
};
let snapshot = *form.snapshot;
if form.from_divergence {
let _ = detach_credentials_link();
let mut cfg = app.config();
cfg.state.active_profile = None;
let _ = save_app_state(&cfg.state);
}
let result = {
let mut cfg = app.config();
capture_into_profile(&mut cfg, name.clone(), snapshot)
};
match result {
Ok(()) => {
app.refresh_tokens();
app.last_state_mtime = app_state_mtime();
app.toast(ToastKind::Success, format!("captured '{name}'"));
}
Err(e) => app.toast(ToastKind::Danger, format!("capture failed: {e}")),
}
}
_ => apply_input_edit(&mut form.input, key),
}
}
fn apply_input_edit(input: &mut InputState, key: KeyEvent) {
match key.code {
KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => input.delete_word(),
KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => input.insert(c),
KeyCode::Backspace => input.backspace(),
KeyCode::Delete => input.delete(),
KeyCode::Left => input.left(),
KeyCode::Right => input.right(),
KeyCode::Home => input.home(),
KeyCode::End => input.end(),
_ => {}
}
}
fn drain_status_events(app: &mut App) {
while let Ok(ev) = app.status_events.try_recv() {
match ev {
StatusEvent::Fetched {
incidents,
fetched_at_ms,
} => {
let was_manual = app.status.fetching;
apply_status_incidents(app, incidents, fetched_at_ms, false, was_manual);
}
StatusEvent::Cached {
incidents,
fetched_at_ms,
} => {
let was_manual = app.status.fetching;
apply_status_incidents(app, incidents, fetched_at_ms, true, false);
if was_manual {
app.status.fetching = false;
app.toast(ToastKind::Danger, "status refresh failed — showing cached");
}
}
StatusEvent::Failed(msg) => {
let was_manual = app.status.fetching;
app.status.fetching = false;
if app.status.incidents.is_empty() {
app.status.error = Some(msg);
}
if was_manual {
app.toast(ToastKind::Danger, "status refresh failed");
}
}
}
}
}
fn apply_status_incidents(
app: &mut App,
incidents: Vec<Incident>,
fetched_at_ms: u64,
cached: bool,
manual: bool,
) {
let prev_selected_id = app.status.selected().map(|i| i.id.clone());
app.status.fetching = false;
app.status.cached = cached;
app.status.fetched_at_ms = Some(fetched_at_ms);
if !cached {
app.status.error = None;
}
let newest_id = incidents.first().map(|i| i.id.clone());
if let Some(newest) = &newest_id
&& app.status.seen_latest.as_ref() != Some(newest)
{
let is_initial = app.status.seen_latest.is_none();
if !is_initial && let Some(incident) = incidents.first() {
let severity = if incident_is_active(incident) {
ToastKind::Warning
} else {
ToastKind::Info
};
if app.tab == Tab::Status {
let title = truncate_chars(&incident.title, 40);
app.toast(severity, format!("new incident · {title}"));
} else {
app.set_tab_activity(Tab::Status, severity);
}
}
app.status.seen_latest = newest_id.clone();
}
let _ = manual;
app.status.incidents = incidents;
if app.status.incidents.is_empty() {
app.status.cursor = 0;
} else if app.status.cursor >= app.status.incidents.len() {
app.status.cursor = app.status.incidents.len() - 1;
}
if app.status.selected().map(|i| i.id.clone()) != prev_selected_id {
app.status.detail_scroll = 0;
}
}
fn truncate_chars(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
out.push('…');
out
}
pub(crate) fn on_tick(app: &mut App) {
app.tick_count = app.tick_count.wrapping_add(1);
while let Ok(ev) = app.update_results.try_recv() {
match ev {
UpdateEvent::Installed(v) => {
app.toast(
ToastKind::Success,
format!("updated to v{v} — restart to apply"),
);
}
UpdateEvent::Available(v) => {
app.toast(
ToastKind::Info,
format!("update available: v{v} — run `cargo install clauth`"),
);
}
}
}
drain_op_results(app);
drain_status_events(app);
if app.reload_if_state_changed() {
app.clamp_profile_cursor();
}
app.apply_usage();
let auto_switch_targets: Vec<String> = app
.pending_switch
.lock()
.map(|mut g| g.drain().collect())
.unwrap_or_default();
for name in auto_switch_targets {
if !is_idle(&app.activity, &name) {
continue;
}
app.toast(ToastKind::Warning, format!("auto-switching to '{name}'"));
app.set_tab_activity(Tab::Overview, ToastKind::Warning);
perform_switch(app, &name);
}
drain_pending_switch_off(app);
drain_startup_signals(app);
maybe_spawn_bootstrap(app);
poll_credentials_divergence(app);
update_banner(app);
app.prune_toasts();
}
fn update_banner(app: &mut App) {
let cfg = app.config();
let all_spent = !cfg.profiles.is_empty() && cfg.state.active_profile.is_none();
drop(cfg);
app.banner = if all_spent {
Some(Banner {
severity: BannerSeverity::Danger,
message: "all accounts spent · switch to a profile to resume".to_string(),
})
} else if app.compact {
Some(Banner {
severity: BannerSeverity::Warning,
message: "terminal too small · enlarge for full layout".to_string(),
})
} else {
None
};
}
fn drain_op_results(app: &mut App) {
let mut needs_token_snapshot_rebuild = false;
while let Ok(OpResult {
name,
kind,
outcome,
}) = app.op_results.try_recv()
{
if matches!(kind, ActivityKind::Fetching) {
unreachable!(
"ActivityKind::Fetching must never be sent via OpResult; \
the Fetching slot is managed by the join loop directly"
);
}
if let Ok(mut a) = app.activity.lock()
&& a.get(&name).copied() == Some(kind.as_activity())
{
a.remove(&name);
}
match outcome {
Ok(()) => {
if kind == ActivityKind::Refreshing {
needs_token_snapshot_rebuild = true;
app.toast(ToastKind::Info, format!("rotated token for '{name}'"));
app.set_tab_activity(Tab::Usage, ToastKind::Info);
}
}
Err(e) => {
let verb = match kind {
ActivityKind::Fetching => {
unreachable!("ActivityKind::Fetching must never be sent via OpResult")
}
ActivityKind::Refreshing => "refresh",
ActivityKind::Switching => "switch",
ActivityKind::Starting => "start",
};
app.toast(
ToastKind::Danger,
format!("{verb} for '{name}' failed: {e}"),
);
let failure_tab = match kind {
ActivityKind::Refreshing => Tab::Usage,
ActivityKind::Switching | ActivityKind::Starting => Tab::Overview,
_ => Tab::Overview,
};
app.set_tab_activity(failure_tab, ToastKind::Danger);
}
}
}
if needs_token_snapshot_rebuild {
app.refresh_tokens();
}
}
fn drain_pending_switch_off(app: &mut App) {
if !app.modals.is_empty() {
return;
}
let switch_off_pending = app
.pending_switch_off
.lock()
.map(|mut g| std::mem::replace(&mut *g, false))
.unwrap_or(false);
if switch_off_pending {
perform_switch_off(app);
}
}
fn drain_startup_signals(app: &mut App) {
while let Ok(signal) = app.startup_results.try_recv() {
match signal {
StartupSignal::ReconcileDone => {
app.reconcile_done = true;
}
StartupSignal::ReconcileNeedsPrompt { active } => {
app.reconcile_done = true;
app.modals
.push(Modal::Divergence(DivergenceForm { active, cursor: 0 }));
}
StartupSignal::BootstrapDone => {
app.finish_bootstrap();
}
}
}
}
fn maybe_spawn_bootstrap(app: &mut App) {
if app.bootstrap_started || !app.reconcile_done || !app.modals.is_empty() {
return;
}
app.bootstrap_started = true;
app.bootstrap_active.store(true, Ordering::SeqCst);
app.spawn_bootstrap();
}
fn poll_credentials_divergence(app: &mut App) {
const POLL_INTERVAL: Duration = Duration::from_secs(1);
if app.last_divergence_check.elapsed() < POLL_INTERVAL {
return;
}
app.last_divergence_check = Instant::now();
if !app.modals.is_empty() {
return;
}
let Some(active) = app
.config()
.state
.active_profile
.as_deref()
.map(str::to_string)
else {
return;
};
if !matches!(
classify_credentials_link(&active).ok(),
Some(LinkState::Diverged)
) {
return;
}
if is_first_login(&active).unwrap_or(false) {
let result = {
let mut cfg = app.config();
adopt_first_login(&mut cfg, &active)
};
match result {
Ok(()) => {
app.refresh_tokens();
app.last_state_mtime = app_state_mtime();
app.toast(ToastKind::Success, format!("saved login into '{active}'"));
}
Err(e) => app.toast(ToastKind::Danger, format!("adopt failed: {e}")),
}
return;
}
app.modals
.push(Modal::Divergence(DivergenceForm { active, cursor: 0 }));
}
pub(crate) fn shutdown(app: &mut App) -> Result<()> {
{
let mut cfg = app.config();
let _ = snapshot_active_credentials(&mut cfg);
let _ = save_app_state(&cfg.state);
}
let _ = detach_credentials_link();
Ok(())
}
#[cfg(test)]
mod tests {
use crate::lockorder::RankedMutex;
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use crate::usage::{ActivityStore, ProfileActivity, any_busy};
fn make_activity(entries: &[(&str, ProfileActivity)]) -> ActivityStore {
let mut map = HashMap::new();
for (name, activity) in entries {
map.insert(name.to_string(), *activity);
}
Arc::new(RankedMutex::new(map))
}
fn bootstrap_busy(flag: &Arc<AtomicBool>, activity: &ActivityStore) -> bool {
flag.load(Ordering::SeqCst) || any_busy(activity)
}
use super::{InputState, parse_threshold};
#[test]
fn delete_word_removes_run_left_of_caret() {
let mut input = InputState::new("foo bar");
input.delete_word();
assert_eq!(input.value, "foo ");
input.delete_word();
assert_eq!(input.value, "");
}
#[test]
fn delete_word_respects_caret_position() {
let mut input = InputState::new("foo bar");
input.home(); input.delete_word();
assert_eq!(input.value, "foo bar");
}
#[test]
fn parse_threshold_accepts_in_range_only() {
assert_eq!(parse_threshold("0"), Some(0.0));
assert_eq!(parse_threshold("100"), Some(100.0));
assert_eq!(parse_threshold("73.5"), Some(73.5));
assert!(parse_threshold("150").is_none());
assert!(parse_threshold("-1").is_none());
assert!(parse_threshold("abc").is_none());
assert!(parse_threshold("").is_none());
}
#[test]
fn bootstrap_active_true_reports_busy() {
let flag = Arc::new(AtomicBool::new(true));
let activity = make_activity(&[]);
assert!(bootstrap_busy(&flag, &activity));
}
#[test]
fn bootstrap_active_false_empty_store_reports_idle() {
let flag = Arc::new(AtomicBool::new(false));
let activity = make_activity(&[]);
assert!(!bootstrap_busy(&flag, &activity));
}
#[test]
fn bootstrap_active_true_with_refreshing_slot_reports_busy() {
let flag = Arc::new(AtomicBool::new(true));
let activity = make_activity(&[("alice", ProfileActivity::Refreshing)]);
assert!(bootstrap_busy(&flag, &activity));
}
#[test]
fn bootstrap_active_false_with_refreshing_slot_still_busy() {
let flag = Arc::new(AtomicBool::new(false));
let activity = make_activity(&[("alice", ProfileActivity::Refreshing)]);
assert!(bootstrap_busy(&flag, &activity));
}
use super::App;
fn bare_app() -> App {
use crate::profile::{AppConfig, AppState};
App::new(AppConfig {
state: AppState::default(),
profiles: Vec::new(),
})
}
#[test]
fn compact_entry_sets_flag_no_toast() {
let mut app = bare_app();
app.update_compact(13);
assert!(app.compact);
assert!(app.toasts.is_empty(), "compact must not fire a toast");
}
#[test]
fn compact_yields_warning_banner() {
use super::{BannerSeverity, update_banner};
let mut app = bare_app();
app.update_compact(13);
update_banner(&mut app);
let banner = app.banner.as_ref().expect("compact banner present");
assert_eq!(banner.severity, BannerSeverity::Warning);
assert_eq!(
banner.message,
"terminal too small · enlarge for full layout"
);
}
#[test]
fn compact_exit_clears_banner() {
use super::update_banner;
let mut app = bare_app();
app.update_compact(13);
update_banner(&mut app);
assert!(app.banner.is_some());
app.update_compact(14);
update_banner(&mut app);
assert!(!app.compact);
assert!(app.banner.is_none(), "banner self-clears on resize");
}
#[test]
fn compact_rearm_after_exit() {
use super::update_banner;
let mut app = bare_app();
app.update_compact(13);
app.update_compact(14); app.update_compact(13); update_banner(&mut app);
assert!(app.compact);
assert!(app.toasts.is_empty(), "compact must not fire a toast");
assert!(app.banner.is_some());
}
use super::theme::{self, Tier};
use super::{GLOBAL_CONFIG_ROWS, KeyCode, KeyEvent, KeyModifiers, Tab};
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
#[test]
fn theme_set_tier_round_trips() {
theme::set_tier(Tier::Full);
assert_eq!(theme::tier(), Tier::Full);
theme::set_tier(Tier::Compatible);
assert_eq!(theme::tier(), Tier::Compatible);
theme::set_tier(Tier::Full);
assert_eq!(theme::tier(), Tier::Full);
}
#[test]
fn global_config_cursor_wraps() {
let mut app = bare_app();
app.tab = Tab::Config;
let last = GLOBAL_CONFIG_ROWS.len() - 1;
assert_eq!(app.global_config_cursor, 0);
super::handle_global_config_key(&mut app, key(KeyCode::Up));
assert_eq!(
app.global_config_cursor, last,
"Up from first wraps to last"
);
super::handle_global_config_key(&mut app, key(KeyCode::Down));
assert_eq!(app.global_config_cursor, 0, "Down from last wraps to first");
}
#[test]
fn global_config_rows_have_actions() {
for (i, _row) in GLOBAL_CONFIG_ROWS.iter().enumerate() {
let mut app = bare_app();
app.tab = Tab::Config;
app.global_config_cursor = i;
let menu = super::build_action_menu(&app);
assert!(
!menu.items.is_empty(),
"row {i} must surface at least one action"
);
}
}
}