use std::collections::{HashMap, HashSet, VecDeque};
use std::io::Write;
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::thread::JoinHandle;
use std::time::{Duration, Instant, SystemTime};
use anyhow::Result;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use crate::actions::{
CaptureSnapshot, EnvKeyCollision, capture_into_profile, capture_snapshot, classify_env_key,
clear_profile_credentials, create_blank_profile, create_profile_from_login, delete_profile,
edit_profile_endpoint, edit_profile_env, edit_profile_model, find_matching_oauth_profile,
overwrite_captured_profile, rename_profile, reorder_profile, switch_off, switch_profile,
validate_profile_name,
};
use crate::claude::{
LinkState, adopt_first_login, classify_credentials_link, claude_settings_env_keys,
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, DivergenceChoice, MAX_REFRESH_INTERVAL_MS, MIN_REFRESH_INTERVAL_MS,
ModelSettings, 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::{
ActivityStore, FetchStatus, LastFetchedAt, NextRefreshPerProfile, OpResult, OpResultReceiver,
OpResultSender, PendingSwitch, PendingSwitchOff, ProfileActivity, RateLimitStreaks,
RefetchQueue, StartupReceiver, StartupSender, StartupSignal, StatusStore,
SuppressedGenericStore, ThirdPartyList, ThirdPartyStatusStore, ThirdPartyUsageStore,
TokenEntry, TokenList, UsageInfo, UsageStore, any_busy, bootstrap_fetch, bootstrap_third_party,
clear_activity, collect_third_party_entries, is_idle, mark_activity, now_ms, 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,
LastResort,
Remove,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ConfigRow {
Name,
BaseUrl,
ApiKey,
Model,
OpusModel,
SonnetModel,
HaikuModel,
SubagentModel,
ModelOverrideAdd,
EnvEntry(usize),
EnvAdd,
AutoStart,
Login,
DeleteCreds,
Delete,
Create,
}
impl ConfigRow {
pub(crate) fn is_text(self) -> bool {
matches!(
self,
ConfigRow::Name
| ConfigRow::BaseUrl
| ConfigRow::ApiKey
| ConfigRow::OpusModel
| ConfigRow::SonnetModel
| ConfigRow::HaikuModel
| ConfigRow::SubagentModel
)
}
}
pub(crate) const MODEL_PRESETS: [&str; 4] = ["opus", "sonnet", "haiku", "opusplan"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum GlobalConfigRow {
Theme,
WrapOff,
RefreshInterval,
DivergenceDefault,
BurnAware,
}
#[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) model: InputState,
pub(crate) opus_model: InputState,
pub(crate) sonnet_model: InputState,
pub(crate) haiku_model: InputState,
pub(crate) subagent_model: InputState,
pub(crate) env_value: InputState,
pub(crate) env_new_key: InputState,
pub(crate) active: Option<ConfigRow>,
pub(crate) armed_delete: bool,
pub(crate) overrides_expanded: bool,
pub(crate) captured_creds: Option<Box<crate::profile::ClaudeCredentials>>,
}
impl ConfigDraft {
pub(crate) fn field(&self, row: ConfigRow) -> Option<&InputState> {
Some(match row {
ConfigRow::Name => &self.name,
ConfigRow::BaseUrl => &self.base_url,
ConfigRow::ApiKey => &self.api_key,
ConfigRow::Model => &self.model,
ConfigRow::OpusModel => &self.opus_model,
ConfigRow::SonnetModel => &self.sonnet_model,
ConfigRow::HaikuModel => &self.haiku_model,
ConfigRow::SubagentModel => &self.subagent_model,
ConfigRow::EnvEntry(_) if self.active == Some(row) => &self.env_value,
ConfigRow::EnvAdd if self.active == Some(ConfigRow::EnvAdd) => &self.env_new_key,
ConfigRow::EnvEntry(_)
| ConfigRow::EnvAdd
| ConfigRow::ModelOverrideAdd
| ConfigRow::AutoStart
| ConfigRow::Login
| ConfigRow::DeleteCreds
| ConfigRow::Delete
| ConfigRow::Create => return None,
})
}
pub(crate) fn field_mut(&mut self, row: ConfigRow) -> Option<&mut InputState> {
Some(match row {
ConfigRow::Name => &mut self.name,
ConfigRow::BaseUrl => &mut self.base_url,
ConfigRow::ApiKey => &mut self.api_key,
ConfigRow::Model => &mut self.model,
ConfigRow::OpusModel => &mut self.opus_model,
ConfigRow::SonnetModel => &mut self.sonnet_model,
ConfigRow::HaikuModel => &mut self.haiku_model,
ConfigRow::SubagentModel => &mut self.subagent_model,
ConfigRow::EnvEntry(_) if self.active == Some(row) => &mut self.env_value,
ConfigRow::EnvAdd if self.active == Some(ConfigRow::EnvAdd) => &mut self.env_new_key,
ConfigRow::EnvEntry(_)
| ConfigRow::EnvAdd
| ConfigRow::ModelOverrideAdd
| ConfigRow::AutoStart
| ConfigRow::Login
| ConfigRow::DeleteCreds
| ConfigRow::Delete
| ConfigRow::Create => return None,
})
}
}
#[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),
CaptureOverwrite(Box<CaptureSnapshot>, String, bool),
AdoptDivergence(Box<CaptureSnapshot>, String),
Switch(String),
DiscardDivergence(String),
RotateAll,
RotateOne(String),
WireMcpServers,
RelinkCredentials(String),
BlankCredentials(String),
RestartLogin(String, bool),
}
#[derive(Debug, Clone)]
pub(crate) struct CaptureNameForm {
pub(crate) snapshot: Box<CaptureSnapshot>,
pub(crate) input: InputState,
pub(crate) from_divergence: bool,
}
#[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 DivergenceTargetForm {
pub(crate) targets: Vec<String>,
pub(crate) cursor: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum EnvCollisionChoice {
Overwrite,
KeepExisting,
Cancel,
}
#[derive(Debug, Clone)]
pub(crate) struct EnvCollisionForm {
pub(crate) profile: String,
pub(crate) key: String,
pub(crate) reason: String,
pub(crate) existing_idx: Option<usize>,
pub(crate) cursor: usize,
}
impl EnvCollisionForm {
pub(crate) fn options() -> [EnvCollisionChoice; 3] {
[
EnvCollisionChoice::Overwrite,
EnvCollisionChoice::KeepExisting,
EnvCollisionChoice::Cancel,
]
}
}
#[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,
ToggleLastResort,
RemoveMember,
ToggleAutoStart,
DeleteProfile,
CreateProfile,
LoginAccount,
ClearCredentials,
EditField,
RemoveEnvField,
RefreshStatus,
OpenIncidentLink,
ToggleEstimates,
TogglePace,
TokensPeriodLifetime,
TokensPeriodDaily,
TokensPeriodWeekly,
TokensPeriodMonthly,
TokensShowAll,
TokensShowClaude,
TokensShowOthers,
ToggleCountCache,
ReloadTokenStats,
}
#[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'),
Self::ToggleEstimates => Some('e'),
Self::TogglePace => Some('p'),
Self::ToggleCountCache => Some('c'),
Self::ReloadTokenStats => Some('r'),
Self::TokensPeriodLifetime => Some('l'),
Self::TokensPeriodDaily => Some('d'),
Self::TokensPeriodWeekly => Some('w'),
Self::TokensPeriodMonthly => Some('m'),
_ => None,
}
}
pub(crate) fn label(&self) -> &'static str {
match self {
Self::NewAccount => "new account",
Self::RefreshUsage => "refresh usage",
Self::RotateTokens => "rotate access token",
Self::SwitchToSelected => "switch to selected",
Self::ConfigureSelected => "configure",
Self::OpenChainMember => "open",
Self::ReorderUp => "reorder up",
Self::ReorderDown => "reorder down",
Self::EditThreshold => "edit threshold",
Self::ToggleLastResort => "toggle last resort",
Self::RemoveMember => "remove member",
Self::ToggleAutoStart => "toggle auto-start",
Self::DeleteProfile => "delete profile",
Self::CreateProfile => "create profile",
Self::LoginAccount => "log in",
Self::ClearCredentials => "log out",
Self::EditField => "edit field",
Self::RemoveEnvField => "remove field",
Self::RefreshStatus => "refresh status",
Self::OpenIncidentLink => "open in browser",
Self::ToggleEstimates => "toggle estimates",
Self::TogglePace => "toggle pace marker",
Self::TokensPeriodLifetime => "period: lifetime",
Self::TokensPeriodDaily => "period: daily",
Self::TokensPeriodWeekly => "period: weekly",
Self::TokensPeriodMonthly => "period: monthly",
Self::TokensShowAll => "show all models",
Self::TokensShowClaude => "show claude models",
Self::TokensShowOthers => "show other models",
Self::ToggleCountCache => "toggle cache counting",
Self::ReloadTokenStats => "reload stats",
}
}
}
#[derive(Debug, Clone)]
pub(crate) enum Modal {
Confirm(ConfirmState),
Divergence(DivergenceForm),
CaptureName(CaptureNameForm),
DivergenceTarget(DivergenceTargetForm),
Help,
ActionMenu(ActionMenuState),
EnvCollision(EnvCollisionForm),
Login,
}
#[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 all access tokens?";
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,
Tokens,
Setup,
Fallback,
Config,
Status,
Plugin,
}
impl Tab {
pub(crate) const ALL: [Tab; 8] = [
Tab::Overview,
Tab::Usage,
Tab::Tokens,
Tab::Setup,
Tab::Fallback,
Tab::Config,
Tab::Status,
Tab::Plugin,
];
pub(crate) fn title(self) -> &'static str {
match self {
Tab::Overview => "Overview",
Tab::Usage => "Usage",
Tab::Tokens => "Tokens",
Tab::Setup => "Setup",
Tab::Fallback => "Fallback",
Tab::Config => "Config",
Tab::Status => "Status",
Tab::Plugin => "Plugin",
}
}
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 TokenView {
Dashboard,
Models,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) enum TokenFilter {
#[default]
All,
Claude,
Others,
}
impl TokenFilter {
pub(crate) fn matches(self, model: &str) -> bool {
match self {
TokenFilter::All => true,
TokenFilter::Claude => crate::tokens::is_anthropic(model),
TokenFilter::Others => !crate::tokens::is_anthropic(model),
}
}
pub(crate) fn badge(self) -> Option<&'static str> {
match self {
TokenFilter::All => None,
TokenFilter::Claude => Some("claude only"),
TokenFilter::Others => Some("others only"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) enum TokenPeriod {
#[default]
Lifetime,
Daily,
Weekly,
Monthly,
}
impl TokenPeriod {
pub(crate) fn next(self) -> Self {
match self {
TokenPeriod::Lifetime => TokenPeriod::Daily,
TokenPeriod::Daily => TokenPeriod::Weekly,
TokenPeriod::Weekly => TokenPeriod::Monthly,
TokenPeriod::Monthly => TokenPeriod::Lifetime,
}
}
pub(crate) fn badge(self) -> Option<&'static str> {
match self {
TokenPeriod::Lifetime => None,
TokenPeriod::Daily => Some("today"),
TokenPeriod::Weekly => Some("this week"),
TokenPeriod::Monthly => Some("this month"),
}
}
pub(crate) fn lens_badge(self) -> &'static str {
self.badge().unwrap_or("lifetime")
}
pub(crate) fn bucket(self) -> Option<crate::tokens::Bucket> {
match self {
TokenPeriod::Weekly => Some(crate::tokens::Bucket::Week),
TokenPeriod::Monthly => Some(crate::tokens::Bucket::Month),
TokenPeriod::Lifetime | TokenPeriod::Daily => None,
}
}
}
#[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, Copy, PartialEq, Eq)]
pub(crate) enum PluginFocus {
List,
Detail,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Health {
Ok,
Warn,
Danger,
Idle,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum PluginFix {
WireMcpServers,
RepairDivergence(String),
RelinkCredentials(String),
}
#[derive(Debug, Clone)]
pub(crate) struct Check {
pub(crate) label: &'static str,
pub(crate) health: Health,
pub(crate) detail: Vec<String>,
pub(crate) fix: Option<PluginFix>,
}
#[derive(Debug)]
pub(crate) struct PluginState {
pub(crate) focus: PluginFocus,
pub(crate) cursor: usize,
pub(crate) detail_scroll: u16,
pub(crate) detail_max_scroll: std::cell::Cell<u16>,
pub(crate) fetching: bool,
pub(crate) error: Option<String>,
pub(crate) checks: Vec<Check>,
pub(crate) cc_version: Option<Option<String>>,
pub(crate) mcp_boot: Option<crate::plugin_probe::McpProbe>,
}
impl Default for PluginState {
fn default() -> Self {
Self {
focus: PluginFocus::List,
cursor: 0,
detail_scroll: 0,
detail_max_scroll: std::cell::Cell::new(0),
fetching: false,
error: None,
checks: Vec::new(),
cc_version: None,
mcp_boot: None,
}
}
}
impl PluginState {
pub(crate) fn row_count(&self) -> usize {
self.checks.len()
}
pub(crate) fn selected_check(&self) -> Option<&Check> {
self.checks.get(self.cursor)
}
pub(crate) fn selected_fix(&self) -> Option<&PluginFix> {
self.selected_check().and_then(|check| check.fix.as_ref())
}
}
#[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 LoginSession {
pub(crate) name: String,
pub(crate) is_new: bool,
pub(crate) generation: u64,
pub(crate) url: Option<String>,
pub(crate) stage: LoginStage,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum LoginStage {
WaitingBrowser,
ExchangingCode,
Verifying,
}
pub(crate) enum LoginEvent {
Url(String),
Stage(LoginStage),
}
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) rate_limit_streaks: RateLimitStreaks,
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) refresh_interval_draft: Option<InputState>,
pub(crate) toasts: VecDeque<Toast>,
pub(crate) compact: bool,
pub(crate) update_results: std::sync::mpsc::Receiver<UpdateEvent>,
pub(crate) update_handle: Option<JoinHandle<()>>,
pub(crate) login: Option<LoginSession>,
pub(crate) login_generation: u64,
pub(crate) login_event_rx: std::sync::mpsc::Receiver<(u64, LoginEvent)>,
pub(crate) login_event_tx: std::sync::mpsc::Sender<(u64, LoginEvent)>,
pub(crate) login_result_rx: std::sync::mpsc::Receiver<(
u64,
std::result::Result<crate::profile::ClaudeCredentials, String>,
)>,
pub(crate) login_result_tx: std::sync::mpsc::Sender<(
u64,
std::result::Result<crate::profile::ClaudeCredentials, String>,
)>,
pub(crate) status: StatusState,
pub(crate) status_events: std::sync::mpsc::Receiver<StatusEvent>,
pub(crate) status_refresh: std::sync::mpsc::Sender<()>,
pub(crate) plugin: PluginState,
pub(crate) token_stats: Option<crate::tokens::TokenStats>,
pub(crate) tokens_failed: bool,
pub(crate) tokens_topping_up: bool,
pub(crate) tokens_progress: Option<(usize, usize)>,
pub(crate) token_view: TokenView,
pub(crate) token_model_cursor: usize,
pub(crate) token_filter: TokenFilter,
pub(crate) token_period: TokenPeriod,
pub(crate) tokens_events: std::sync::mpsc::Receiver<crate::tokens::TokensEvent>,
pub(crate) tokens_refresh: std::sync::mpsc::Sender<()>,
pub(crate) price_table: Option<crate::pricing::PriceTable>,
pub(crate) pricing_events: std::sync::mpsc::Receiver<crate::pricing::PricingEvent>,
pub(crate) pricing_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) divergence_snooze: Option<u64>,
pub(crate) last_plugin_refresh: Instant,
pub(crate) reconcile_done: bool,
pub(crate) bootstrap_started: bool,
pub(crate) refresh_interval: Arc<AtomicU64>,
pub(crate) bootstrap_active: Arc<AtomicBool>,
pub(crate) shutting_down: Arc<AtomicBool>,
pub(crate) tab_activity: [Option<ToastKind>; Tab::ALL.len()],
pub(crate) bell_fired: HashMap<String, bool>,
pub(crate) history_cache: HashMap<String, Vec<(u64, UsageInfo)>>,
pub(crate) history_mtimes: HashMap<String, std::time::SystemTime>,
pub(crate) last_history_usage: HashMap<String, UsageInfo>,
}
struct WorkerHandles {
config: ConfigHandle,
usage_tokens: TokenList,
usage_store: UsageStore,
usage_status: StatusStore,
refresh_interval: Arc<AtomicU64>,
next_refresh_per_profile: NextRefreshPerProfile,
activity: ActivityStore,
last_fetched: LastFetchedAt,
rate_limit_streaks: RateLimitStreaks,
pending_switch: PendingSwitch,
pending_switch_off: PendingSwitchOff,
refetch_queue: RefetchQueue,
third_party_tokens: ThirdPartyList,
third_party_usage_store: ThirdPartyUsageStore,
third_party_status: ThirdPartyStatusStore,
shutting_down: Arc<AtomicBool>,
}
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),
refresh_interval: Arc::clone(&app.refresh_interval),
next_refresh_per_profile: Arc::clone(&app.next_refresh_per_profile),
activity: Arc::clone(&app.activity),
last_fetched: Arc::clone(&app.last_fetched),
rate_limit_streaks: Arc::clone(&app.rate_limit_streaks),
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),
shutting_down: Arc::clone(&app.shutting_down),
}
}
}
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 rate_limit_streaks: RateLimitStreaks = 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 refresh_interval = Arc::new(AtomicU64::new(config.state.refresh_interval_ms));
for profile in &config.profiles {
crate::profile::prune_usage_history(profile.name.as_str());
}
let mut history_cache: HashMap<String, Vec<(u64, UsageInfo)>> = HashMap::new();
let mut history_mtimes: HashMap<String, std::time::SystemTime> = HashMap::new();
for profile in &config.profiles {
let name = profile.name.as_str();
let data = crate::profile::load_usage_history(name);
if !data.is_empty() {
if let Ok(path) = crate::profile::profile_history_path(name)
&& let Ok(meta) = std::fs::metadata(&path)
&& let Ok(mtime) = meta.modified()
{
history_mtimes.insert(name.to_string(), mtime);
}
history_cache.insert(name.to_string(), data);
}
}
let (update_sender, update_results) = std::sync::mpsc::channel::<UpdateEvent>();
let update_handle = 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);
}
let (tokens_sender, tokens_events) =
std::sync::mpsc::channel::<crate::tokens::TokensEvent>();
let (tokens_refresh, tokens_refresh_rx) = std::sync::mpsc::channel::<()>();
if cfg!(test) {
drop((tokens_sender, tokens_refresh_rx));
} else if let Ok(claude_dir) = crate::profile::claude_dir() {
crate::tokens::spawn(tokens_sender, tokens_refresh_rx, claude_dir);
} else {
drop((tokens_sender, tokens_refresh_rx));
}
let (pricing_sender, pricing_events) =
std::sync::mpsc::channel::<crate::pricing::PricingEvent>();
let (pricing_refresh, pricing_refresh_rx) = std::sync::mpsc::channel::<()>();
if cfg!(test) {
drop((pricing_sender, pricing_refresh_rx));
} else {
crate::pricing::spawn(pricing_sender, pricing_refresh_rx);
}
let (login_event_tx, login_event_rx) = std::sync::mpsc::channel();
let (login_result_tx, login_result_rx) = std::sync::mpsc::channel();
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,
rate_limit_streaks,
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,
refresh_interval_draft: None,
config_draft: None,
chain_cursor: 0,
toasts: VecDeque::new(),
compact: false,
update_results,
update_handle,
login: None,
login_generation: 0,
login_event_rx,
login_event_tx,
login_result_rx,
login_result_tx,
status: StatusState::default(),
status_events,
status_refresh,
plugin: PluginState::default(),
token_stats: None,
tokens_failed: false,
tokens_topping_up: false,
tokens_progress: None,
token_view: TokenView::Dashboard,
token_model_cursor: 0,
token_filter: TokenFilter::default(),
token_period: TokenPeriod::default(),
tokens_events,
tokens_refresh,
price_table: None,
pricing_events,
pricing_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(),
divergence_snooze: None,
last_plugin_refresh: Instant::now(),
reconcile_done: false,
bootstrap_started: false,
refresh_interval,
bootstrap_active: Arc::new(AtomicBool::new(false)),
shutting_down: Arc::new(AtomicBool::new(false)),
tab_activity: [None; Tab::ALL.len()],
bell_fired: HashMap::new(),
history_cache,
history_mtimes,
last_history_usage: HashMap::new(),
}
}
pub(crate) fn config(&self) -> RankedGuard<'_, AppConfig> {
#[allow(clippy::expect_used, reason = "mutex poisoning is unrecoverable")]
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 third_party_usage_store = Arc::clone(&self.third_party_usage_store);
let third_party_status = Arc::clone(&self.third_party_status);
let last_fetched = Arc::clone(&self.last_fetched);
let refresh_interval = Arc::clone(&self.refresh_interval);
let activity = Arc::clone(&self.activity);
let done = BootstrapDoneGuard {
bootstrap_active: Arc::clone(&self.bootstrap_active),
startup_sender: self.startup_sender.clone(),
};
spawn_worker(move || {
let _done = done;
#[allow(clippy::expect_used, reason = "mutex poisoning is unrecoverable")]
let active = config
.lock()
.expect("config mutex poisoned")
.state
.active_profile
.clone();
if let Some(active) = active {
let _ = link_profile_credentials(&active);
}
let (snapshot, third_party) = {
#[allow(clippy::expect_used, reason = "mutex poisoning is unrecoverable")]
let cfg = config.lock().expect("config mutex poisoned");
(
collect_tokens(&cfg.profiles),
collect_third_party_entries(&cfg.profiles),
)
};
let interval_ms = refresh_interval.load(Ordering::Relaxed);
bootstrap_fetch(
&usage_store,
&usage_status,
&last_fetched,
&snapshot,
interval_ms,
);
bootstrap_third_party(
&third_party_usage_store,
&third_party_status,
&last_fetched,
&third_party,
interval_ms,
);
let now = now_ms();
let due_now: Vec<String> = match last_fetched.lock() {
Ok(lf) => snapshot
.iter()
.map(|e| e.name.clone())
.chain(third_party.iter().map(|e| e.name.clone()))
.filter(|n| {
lf.get(n)
.is_none_or(|t| t.as_millis().saturating_add(interval_ms) <= now)
})
.collect(),
Err(_) => Vec::new(),
};
for name in &due_now {
mark_activity(&activity, name, ProfileActivity::Queued);
}
});
}
pub(crate) fn active_burn_rate(&self, name: &str, usage_info: &UsageInfo) -> Option<f64> {
let five_h = usage_info.five_hour.as_ref().map(|w| ("5h", w))?;
crate::usage::compute_burn_rates_from_history(
self.history_cache
.get(name)
.map(|v| v.as_slice())
.unwrap_or(&[]),
std::slice::from_ref(&five_h),
crate::usage::BURN_LOOKBACK_MS,
crate::usage::BURN_MIN_SAMPLES,
crate::usage::BURN_GAP_CUT_MS,
)
.remove("5h")
.flatten()
}
fn finish_bootstrap(&mut self) {
self.refresh_tokens();
self.start_scheduler();
self.apply_usage();
let switched = {
let mut cfg = self.config();
let active_profile = cfg
.state
.active_profile
.as_deref()
.and_then(|n| cfg.find(n));
let active_fresh =
active_profile.is_some_and(|p| p.fetch_status == Some(FetchStatus::Fresh));
if active_fresh {
let rate = active_profile.and_then(|p| {
let usage = p.usage.as_ref()?;
self.active_burn_rate(p.name.as_str(), usage)
});
auto_switch_if_needed(&mut cfg, rate).ok().flatten()
} else {
None
}
};
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);
let suppressed_generic: SuppressedGenericStore = Arc::new(RankedMutex::new(HashSet::new()));
spawn_refresher(
h.config,
h.usage_tokens,
h.usage_store,
h.usage_status,
h.refresh_interval,
h.next_refresh_per_profile,
h.activity,
h.last_fetched,
h.rate_limit_streaks,
h.pending_switch,
h.pending_switch_off,
h.refetch_queue,
h.third_party_tokens,
h.third_party_usage_store,
h.third_party_status,
suppressed_generic,
h.shutting_down,
);
}
pub(crate) fn apply_usage(&mut self) {
let bells;
let usage_snapshots;
{
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();
}
}
bells = cfg
.profiles
.iter()
.map(|p| {
let util = p
.usage
.as_ref()
.and_then(|u| u.five_hour.as_ref())
.map(|u| u.utilization);
let fresh = p.fetch_status == Some(FetchStatus::Fresh);
(p.name.to_string(), p.bell_threshold, util, fresh)
})
.collect::<Vec<_>>();
usage_snapshots = cfg
.profiles
.iter()
.filter_map(|p| {
let fresh = p.fetch_status == Some(FetchStatus::Fresh);
p.usage.clone().map(|u| (p.name.to_string(), u, fresh))
})
.collect::<Vec<_>>();
}
for (name, threshold, util, fresh) in bells {
if !fresh {
continue;
}
if let Some(t) = threshold
&& let Some(u) = util
{
if u >= t {
if !self.bell_fired.contains_key(&name) {
self.toast(ToastKind::Warning, format!("bell · {name} at {:.0}%", u));
self.set_tab_activity(Tab::Overview, ToastKind::Warning);
self.bell_fired.insert(name, true);
}
} else {
self.bell_fired.remove(&name);
}
}
}
for (name, usage, fresh) in &usage_snapshots {
if !fresh {
continue;
}
let old_usage = self.last_history_usage.get(name.as_str()).cloned();
let changed = match &old_usage {
Some(last) => serde_json::to_string(last).ok() != serde_json::to_string(usage).ok(),
None => true,
};
if changed {
if let Ok(path) = crate::profile::profile_history_path(name)
&& path
.parent()
.is_some_and(|p| std::fs::create_dir_all(p).is_ok())
&& let Ok(mut file) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
{
let ts = now_ms();
if let Some(last) = &old_usage {
let last_json = serde_json::to_string(last).unwrap_or_default();
let name_json = serde_json::to_string(name)
.unwrap_or_else(|_| format!(r#""{}""#, name));
let _ = writeln!(
file,
r#"{{"ts":{},"name":{},"usage":{}}}"#,
ts.saturating_sub(1),
name_json,
last_json,
);
}
let usage_json = serde_json::to_string(usage).unwrap_or_default();
let name_json =
serde_json::to_string(name).unwrap_or_else(|_| format!(r#""{}""#, name));
let _ = writeln!(
file,
r#"{{"ts":{},"name":{},"usage":{}}}"#,
ts, name_json, usage_json,
);
}
self.last_history_usage.insert(name.clone(), usage.clone());
}
}
for (name, _, _) in &usage_snapshots {
if let Ok(path) = crate::profile::profile_history_path(name)
&& let Ok(mtime) = path.metadata().and_then(|m| m.modified())
&& self.history_mtimes.get(name) != Some(&mtime)
{
self.history_cache
.insert(name.clone(), crate::profile::load_usage_history(name));
self.history_mtimes.insert(name.clone(), mtime);
}
}
}
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;
#[allow(clippy::expect_used, reason = "mutex poisoning is unrecoverable")]
{
*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);
#[allow(clippy::expect_used, reason = "mutex poisoning is unrecoverable")]
{
*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) {
#[allow(clippy::expect_used, reason = "mutex poisoning is unrecoverable")]
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.enqueue_refetch(&name, false);
}
}
pub(crate) fn manual_refresh_one(&self, name: &str) {
self.enqueue_refetch(name, true);
}
fn enqueue_refetch(&self, name: &str, refresh_plan: bool) {
if is_idle(&self.activity, name) {
mark_activity(&self.activity, name, ProfileActivity::Queued);
}
if refresh_plan {
crate::usage::expire_profile_ttl(name);
}
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 has_sub_focus(app: &App) -> bool {
(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)
|| (app.tab == Tab::Plugin && app.plugin.focus == PluginFocus::Detail)
|| (app.tab == Tab::Tokens && app.token_view == TokenView::Models)
}
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;
app.shutting_down.store(true, Ordering::SeqCst);
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;
}
if app.tab == Tab::Config && app.refresh_interval_draft.is_some() {
handle_refresh_interval_edit_key(app, key);
return;
}
if app.login.is_some() && matches!(key.code, KeyCode::Esc | KeyCode::Char('q')) {
app.login = None;
app.toast(ToastKind::Info, "login canceled");
return;
}
match key.code {
KeyCode::Right | KeyCode::Tab => {
app.disarm_quit();
switch_tab(app, app.tab.next());
return;
}
KeyCode::Left | KeyCode::BackTab => {
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::Plugin {
recompute_plugin_checks(app, true);
app.toast(ToastKind::Info, "re-running plugin checks");
return;
}
if app.tab == Tab::Tokens {
reload_token_stats(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();
if app.tab == Tab::Tokens {
set_token_period(app, app.token_period.next());
return;
}
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 {
leave_config_detail(app);
} 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;
} else if app.tab == Tab::Plugin && app.plugin.focus == PluginFocus::Detail {
app.plugin.focus = PluginFocus::List;
} else if app.tab == Tab::Tokens && app.token_view == TokenView::Models {
app.token_view = TokenView::Dashboard;
}
return;
}
KeyCode::Char('q') => {
if has_sub_focus(app) {
app.disarm_quit();
if app.tab == Tab::Setup {
leave_config_detail(app);
} else if app.tab == Tab::Fallback {
leave_fallback_detail(app);
} else if app.tab == Tab::Tokens {
app.token_view = TokenView::Dashboard;
} else if app.tab == Tab::Plugin {
app.plugin.focus = PluginFocus::List;
} else {
app.status.focus = StatusFocus::List;
}
} else if app.armed_quit {
app.quit = true;
app.shutting_down.store(true, Ordering::SeqCst);
} 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::Tokens => handle_tokens_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),
Tab::Plugin => handle_plugin_key(app, key),
}
}
pub(crate) fn token_period_models(app: &App) -> Vec<crate::tokens::PeriodModel> {
use crate::tokens::PeriodModel;
let Some(stats) = app.token_stats.as_ref() else {
return Vec::new();
};
let mut rows: Vec<PeriodModel> = if let Some(bucket) = app.token_period.bucket() {
let (from, to) = crate::tokens::current_bucket_bounds(&crate::tokens::today_date(), bucket);
crate::tokens::period_models(&stats.daily_models, &from, &to)
} else if app.token_period == TokenPeriod::Daily {
stats
.today
.as_ref()
.map(|t| t.models.iter().map(PeriodModel::from_full).collect())
.unwrap_or_default()
} else {
crate::tokens::group_models(&stats.models)
.iter()
.map(PeriodModel::from_full)
.collect()
};
rows.retain(|m| app.token_filter.matches(&m.model));
let basis = crate::tokens::effective_cache_basis(&rows, app.config().state.count_cache);
rows.sort_unstable_by_key(|m| std::cmp::Reverse(m.metric(basis)));
rows
}
fn token_model_count(app: &App) -> usize {
token_period_models(app).len()
}
fn reload_token_stats(app: &mut App) {
let _ = app.tokens_refresh.send(());
let _ = app.pricing_refresh.send(());
app.tokens_topping_up = true;
app.tokens_progress = None;
app.toast(ToastKind::Info, "reloading token usage");
}
fn handle_tokens_key(app: &mut App, key: KeyEvent) {
if key.code == KeyCode::Char('c') {
toggle_count_cache(app);
return;
}
match app.token_view {
TokenView::Dashboard => {
if key.code == KeyCode::Enter && token_model_count(app) > 0 {
app.token_view = TokenView::Models;
app.token_model_cursor = 0;
}
}
TokenView::Models => {
let len = token_model_count(app);
match key.code {
KeyCode::Up if len > 0 => {
app.token_model_cursor = (app.token_model_cursor + len - 1).rem_euclid(len);
}
KeyCode::Down if len > 0 => {
app.token_model_cursor = (app.token_model_cursor + 1).rem_euclid(len);
}
_ => {}
}
}
}
}
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::Tokens => {
app.token_view = TokenView::Dashboard;
app.token_model_cursor = 0;
}
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;
app.refresh_interval_draft = None;
}
Tab::Status => {
app.status.focus = StatusFocus::List;
}
Tab::Plugin => {
app.plugin.focus = PluginFocus::List;
app.plugin.cursor = 0;
app.plugin.detail_scroll = 0;
recompute_plugin_checks(app, false);
}
}
}
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),
KeyCode::Char('e') => toggle_show_estimates(app),
KeyCode::Char('p') => toggle_show_pace(app),
_ => {}
}
}
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 handle_plugin_key(app: &mut App, key: KeyEvent) {
match app.plugin.focus {
PluginFocus::List => {
let len = app.plugin.row_count();
match key.code {
KeyCode::Up if len > 0 => {
app.plugin.cursor = (app.plugin.cursor + len - 1) % len;
app.plugin.detail_scroll = 0;
}
KeyCode::Down if len > 0 => {
app.plugin.cursor = (app.plugin.cursor + 1) % len;
app.plugin.detail_scroll = 0;
}
KeyCode::Enter if len > 0 => {
app.plugin.focus = PluginFocus::Detail;
app.plugin.detail_scroll = 0;
}
KeyCode::Char('f') => apply_plugin_fix(app),
_ => {}
}
}
PluginFocus::Detail => match key.code {
KeyCode::Up => {
app.plugin.detail_scroll = app.plugin.detail_scroll.saturating_sub(1);
}
KeyCode::Down => {
let max = app.plugin.detail_max_scroll.get();
app.plugin.detail_scroll = app.plugin.detail_scroll.saturating_add(1).min(max);
}
KeyCode::Char('f') => apply_plugin_fix(app),
_ => {}
},
}
}
fn apply_plugin_fix(app: &mut App) {
let Some(fix) = app.plugin.selected_fix().cloned() else {
return;
};
match fix {
PluginFix::WireMcpServers => {
app.disarm_quit();
app.modals.push(Modal::Confirm(ConfirmState {
message: "Wire clauth into Claude Code's mcpServers?".to_string(),
detail: Some(
"Writes the clauth entry into ~/.claude.json; other fields are preserved."
.to_string(),
),
choice: false,
on_confirm: ConfirmAction::WireMcpServers,
}));
}
PluginFix::RepairDivergence(name) => {
app.disarm_quit();
app.modals.push(Modal::Divergence(DivergenceForm {
active: name,
cursor: 0,
}));
}
PluginFix::RelinkCredentials(name) => {
app.disarm_quit();
app.modals.push(Modal::Confirm(ConfirmState {
message: format!("Relink ~/.claude credentials to '{name}'?"),
detail: Some(
"Re-points .credentials.json at the profile's own stored tokens; spends nothing.".to_string(),
),
choice: false,
on_confirm: ConfirmAction::RelinkCredentials(name),
}));
}
}
}
fn recompute_plugin_checks(app: &mut App, refresh_version: bool) {
use crate::plugin_probe as probe;
app.plugin.error = None;
if refresh_version || app.plugin.cc_version.is_none() {
app.plugin.fetching = true;
app.plugin.cc_version = Some(if cfg!(test) {
None
} else {
probe::cc_version()
});
app.plugin.fetching = false;
}
let cc_version = app.plugin.cc_version.clone().flatten();
let mut checks: Vec<Check> = Vec::with_capacity(4);
let clauth_path = probe::on_path("clauth");
let mut about_detail = vec![format!(
"data: {}",
crate::profile::clauth_dir()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| "\u{2014}".to_string())
)];
match &clauth_path {
Some(path) => about_detail.push(format!("path: {}", path.display())),
None => {
about_detail.push("path: not on PATH".to_string());
about_detail.push(
"Claude Code spawns `clauth mcp` by name, so the server won't start".to_string(),
);
about_detail.push("install clauth so its bin directory is on PATH".to_string());
}
}
match &cc_version {
Some(version) => about_detail.push(format!("claude: {version}")),
None => {
about_detail.push("claude: not found".to_string());
about_detail.push("`claude --version` failed or claude is not on PATH".to_string());
about_detail.push("install Claude Code so the `claude` binary resolves".to_string());
}
}
checks.push(Check {
label: "about",
health: if clauth_path.is_none() {
Health::Danger
} else if cc_version.is_none() {
Health::Warn
} else {
Health::Ok
},
detail: about_detail,
fix: None,
});
if refresh_version {
app.plugin.fetching = true;
app.plugin.mcp_boot = if clauth_path.is_some() {
Some(if cfg!(test) {
probe::McpProbe::Ok
} else {
probe::mcp_boots()
})
} else {
None
};
app.plugin.fetching = false;
}
let mcp_boot = app.plugin.mcp_boot.clone();
let records = probe::installed_records();
let installed = !records.is_empty();
let plugin_global = records.iter().any(|r| r.scope.as_deref() == Some("user"));
let wiring = probe::manual_mcp_wiring();
let wired = installed || wiring != probe::McpWiring::None;
let manual_global = wiring == probe::McpWiring::GlobalConfig;
let drifted = manual_global && !plugin_global && probe::global_entry_drifted() == Some(true);
let globally_wired = plugin_global || (manual_global && !drifted);
let project_only = wired && !globally_wired && !drifted;
let source = if plugin_global {
"source: plugin install (user)"
} else if manual_global {
"source: ~/.claude.json (manual)"
} else if installed {
"source: plugin install (project)"
} else if wiring == probe::McpWiring::ProjectFile {
"source: ./.mcp.json (manual)"
} else {
"source: none"
};
let mut mcp_detail = vec![
format!("present: {}", if wired { "yes" } else { "no" }),
source.to_string(),
];
match &mcp_boot {
Some(probe::McpProbe::Ok) => mcp_detail.push("server: boots".to_string()),
Some(probe::McpProbe::Failed(reason)) => {
mcp_detail.push(format!("server: failed ({reason})"));
}
None => {}
}
let needs_wire = !globally_wired || drifted;
if needs_wire {
mcp_detail.push(String::new());
if drifted {
mcp_detail.push("entry doesn't match the current launch line".to_string());
} else if project_only {
mcp_detail.push("wired for this project only, not global".to_string());
}
mcp_detail.push("[f] wire mcpServers into ~/.claude.json".to_string());
}
let boot_failed = matches!(mcp_boot, Some(probe::McpProbe::Failed(_)));
checks.push(Check {
label: "mcp servers",
health: if boot_failed {
Health::Danger
} else if needs_wire {
Health::Warn
} else {
Health::Ok
},
detail: mcp_detail,
fix: needs_wire.then_some(PluginFix::WireMcpServers),
});
let marketplace = probe::marketplace_known();
let plugin_check = if let Some(record) = records.first() {
let scope = record.scope.as_deref();
let mut detail = vec![format!("installed: yes ({})", scope.unwrap_or("?"))];
if let Some(version) = &record.version {
detail.push(format!("version: {version}"));
}
if let Some(sha) = &record.git_commit_sha {
detail.push(format!(
"commit: {}",
sha.chars().take(7).collect::<String>()
));
}
if let Some(at) = &record.installed_at {
detail.push(format!(
"installed at: {}",
at.split('T').next().unwrap_or(at)
));
}
if let Some(project) = &record.project_path {
detail.push(format!("project: {project}"));
}
if let Some(repo) = marketplace.as_ref().and_then(|m| m.repo.as_ref()) {
detail.push(format!("marketplace: {repo}"));
}
if plugin_global {
Check {
label: "plugin",
health: Health::Ok,
detail,
fix: None,
}
} else {
detail.push(String::new());
detail.push("installed for this project only, not global".to_string());
detail.push("make it global (run in shell):".to_string());
if let Some(scope) = scope {
detail.push(format!(
" claude plugin uninstall {} --scope {scope}",
probe::PLUGIN_ID
));
}
detail.push(format!(
" claude plugin install {} --scope user",
probe::PLUGIN_ID
));
Check {
label: "plugin",
health: Health::Warn,
detail,
fix: None,
}
}
} else {
let known = marketplace.is_some();
let mut detail = vec![format!(
"installed: no ({})",
if known {
"marketplace known"
} else {
"marketplace unknown"
}
)];
if let Some(repo) = marketplace.as_ref().and_then(|m| m.repo.as_ref()) {
detail.push(format!("marketplace: {repo}"));
}
detail.push(String::new());
detail.push("install (run in Claude Code):".to_string());
detail.push(" /plugin marketplace add uwuclxdy/clauth".to_string());
detail.push(" /plugin install clauth@clauth".to_string());
Check {
label: "plugin",
health: Health::Warn,
detail,
fix: None,
}
};
checks.push(plugin_check);
struct Snap {
name: String,
active: bool,
expires_at: Option<i64>,
}
let snaps: Vec<Snap> = {
let cfg = app.config();
cfg.profiles
.iter()
.map(|p| Snap {
name: p.name.as_str().to_string(),
active: cfg.is_active(p.name.as_str()),
expires_at: p.access_token_expires_at(),
})
.collect()
};
let now_secs = (crate::usage::now_ms() / 1000) as i64;
let total = snaps.len();
let mut live_sessions: usize = 0;
let mut live_profiles: usize = 0;
let mut live_names: Vec<String> = Vec::new();
let mut rate_limited_names: Vec<String> = Vec::new();
let mut active_name: Option<String> = None;
let mut active_link = "\u{2014}";
let mut active_expires = "\u{2014}".to_string();
let mut active_fix: Option<PluginFix> = None;
let mut active_bad = false;
for snap in snaps {
let instances = crate::runtime::live_session_count(&snap.name);
live_sessions += instances;
if instances > 0 {
live_profiles += 1;
live_names.push(if instances > 1 {
format!("{} ({instances})", snap.name)
} else {
snap.name.clone()
});
}
let throughput = crate::throughput::summary(&snap.name, now_secs);
if throughput.iter().any(|t| t.rate_limited_recent) {
rate_limited_names.push(snap.name.clone());
}
if !snap.active {
continue;
}
active_name = Some(snap.name.clone());
let link_result = classify_credentials_link(&snap.name);
let link = link_result.as_ref().ok().copied();
let link_err = link_result.is_err();
let diverged = matches!(link, Some(LinkState::Diverged));
let missing = matches!(link, Some(LinkState::Missing));
let stored_creds = crate::profile::profile_dir(&snap.name)
.map(|dir| dir.join("credentials.json").exists())
.unwrap_or(false);
active_link = if link_err {
"unknown"
} else {
match link {
Some(LinkState::LinkedTo) => "linked",
Some(LinkState::Diverged) => "diverged",
Some(LinkState::Missing) => "missing",
None => "\u{2014}",
}
};
active_bad = link_err || diverged || missing;
active_fix = if diverged {
Some(PluginFix::RepairDivergence(snap.name.clone()))
} else if missing && stored_creds {
Some(PluginFix::RelinkCredentials(snap.name.clone()))
} else {
None
};
active_expires = match snap.expires_at {
Some(ms) => {
let secs = ms / 1000 - (crate::usage::now_ms() / 1000) as i64;
if secs <= 0 {
"expired".to_string()
} else {
crate::usage::humanize_duration(secs)
}
}
None => "\u{2014}".to_string(),
};
}
let runtime_health = if active_bad || !rate_limited_names.is_empty() {
Health::Warn
} else if active_link == "linked" || live_sessions > 0 {
Health::Ok
} else {
Health::Idle
};
let sessions_line = if live_sessions == 0 {
"0".to_string()
} else {
format!("{live_sessions} live across {live_profiles}")
};
let link_line = match &active_name {
Some(_) if active_expires != "\u{2014}" => format!("{active_link} · {active_expires}"),
Some(_) => active_link.to_string(),
None => "\u{2014}".to_string(),
};
let mut runtime_detail = vec![
format!("profiles: {total}"),
format!("sessions: {sessions_line}"),
];
for name in &live_names {
runtime_detail.push(format!(" {name}"));
}
runtime_detail.push(format!(
"active: {}",
active_name.as_deref().unwrap_or("\u{2014}")
));
runtime_detail.push(format!("link: {link_line}"));
if !rate_limited_names.is_empty() {
runtime_detail.push(format!(
"delegate: rate-limited ({})",
rate_limited_names.join(", ")
));
}
match &active_fix {
Some(PluginFix::RepairDivergence(_)) => {
runtime_detail.push(String::new());
runtime_detail.push("[f] repair credentials".to_string());
}
Some(PluginFix::RelinkCredentials(_)) => {
runtime_detail.push(String::new());
runtime_detail.push("[f] relink credentials".to_string());
}
_ => {}
}
checks.push(Check {
label: "runtime",
health: runtime_health,
detail: runtime_detail,
fix: active_fix,
});
app.plugin.checks = checks;
let max = app.plugin.row_count().saturating_sub(1);
if app.plugin.cursor > max {
app.plugin.cursor = max;
}
}
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;
}
match crate::platform::open_url(&link) {
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 capture_live_or_toast(app: &mut App) -> Option<CaptureSnapshot> {
let snapshot = match capture_snapshot() {
Ok(s) => s,
Err(e) => {
app.toast(ToastKind::Danger, format!("capture failed: {e}"));
return None;
}
};
let has_oauth = snapshot
.credentials
.as_ref()
.is_some_and(|c| c.claude_ai_oauth.is_some());
if !has_oauth && snapshot.base_url.is_none() && snapshot.api_key.is_none() {
app.toast(
ToastKind::Danger,
"no live login found; nothing to capture (macOS keychain isn't supported yet)",
);
return None;
}
Some(snapshot)
}
fn begin_capture(app: &mut App, from_divergence: bool) {
let Some(snapshot) = capture_live_or_toast(app) else {
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; 3] = [
FallbackRow::Threshold,
FallbackRow::LastResort,
FallbackRow::Remove,
];
pub(crate) const GLOBAL_CONFIG_ROWS: [GlobalConfigRow; 5] = [
GlobalConfigRow::Theme,
GlobalConfigRow::DivergenceDefault,
GlobalConfigRow::RefreshInterval,
GlobalConfigRow::WrapOff,
GlobalConfigRow::BurnAware,
];
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::Char(' ') => {
run_global_config_row(app, GLOBAL_CONFIG_ROWS[app.global_config_cursor]);
}
KeyCode::Enter => {
let row = GLOBAL_CONFIG_ROWS[app.global_config_cursor];
if row == GlobalConfigRow::RefreshInterval {
begin_refresh_interval_edit(app);
} else {
run_global_config_row(app, row);
}
}
_ => {}
}
}
fn run_global_config_row(app: &mut App, row: GlobalConfigRow) {
match row {
GlobalConfigRow::Theme => cycle_theme(app),
GlobalConfigRow::DivergenceDefault => cycle_divergence_default(app),
GlobalConfigRow::WrapOff => toggle_wrap_off(app),
GlobalConfigRow::RefreshInterval => step_refresh_interval(app),
GlobalConfigRow::BurnAware => toggle_burn_aware_switching(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,
DetailLastResort,
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::LastResort => FallbackHint::DetailLastResort,
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),
_ => {}
}
}
pub(crate) fn next_divergence_default(
current: Option<DivergenceChoice>,
) -> Option<DivergenceChoice> {
match current {
None => Some(DivergenceChoice::Overwrite),
Some(DivergenceChoice::Overwrite) => Some(DivergenceChoice::NewProfile),
Some(DivergenceChoice::NewProfile) => Some(DivergenceChoice::Discard),
Some(DivergenceChoice::Discard) => None,
}
}
fn cycle_divergence_default(app: &mut App) {
let next = next_divergence_default(app.config().state.default_divergence);
{
let mut cfg = app.config();
cfg.state.default_divergence = next;
let _ = save_app_state(&cfg.state);
}
app.last_state_mtime = app_state_mtime();
}
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 toggle_burn_aware_switching(app: &mut App) {
{
let mut cfg = app.config();
cfg.state.burn_aware_switching = !cfg.state.burn_aware_switching;
let _ = save_app_state(&cfg.state);
}
app.last_state_mtime = app_state_mtime();
}
fn toggle_show_estimates(app: &mut App) {
{
let mut cfg = app.config();
cfg.state.show_estimates = !cfg.state.show_estimates;
let _ = save_app_state(&cfg.state);
}
app.last_state_mtime = app_state_mtime();
}
fn toggle_count_cache(app: &mut App) {
{
let mut cfg = app.config();
cfg.state.count_cache = !cfg.state.count_cache;
let _ = save_app_state(&cfg.state);
}
app.last_state_mtime = app_state_mtime();
}
fn toggle_show_pace(app: &mut App) {
{
let mut cfg = app.config();
cfg.state.show_pace = !cfg.state.show_pace;
let _ = save_app_state(&cfg.state);
}
app.last_state_mtime = app_state_mtime();
}
fn step_refresh_interval(app: &mut App) {
const PRESETS: [u64; 6] = [15_000, 30_000, 60_000, 90_000, 120_000, 300_000];
let current = app.refresh_interval.load(Ordering::Relaxed);
let new_interval = PRESETS
.iter()
.copied()
.find(|&p| p > current)
.unwrap_or(PRESETS[0]);
app.refresh_interval.store(new_interval, Ordering::Relaxed);
{
let mut cfg = app.config();
cfg.state.refresh_interval_ms = new_interval;
let _ = save_app_state(&cfg.state);
}
app.last_state_mtime = app_state_mtime();
}
fn begin_refresh_interval_edit(app: &mut App) {
let secs = app.refresh_interval.load(Ordering::Relaxed) / 1000;
app.refresh_interval_draft = Some(InputState::new(&secs.to_string()));
}
fn handle_refresh_interval_edit_key(app: &mut App, key: KeyEvent) {
match key.code {
KeyCode::Esc => app.refresh_interval_draft = None,
KeyCode::Enter => commit_refresh_interval_edit(app),
_ => {
if let Some(input) = app.refresh_interval_draft.as_mut() {
apply_input_edit(input, key);
}
}
}
}
fn commit_refresh_interval_edit(app: &mut App) {
let Some(raw) = app.refresh_interval_draft.as_ref().map(|i| i.trimmed()) else {
return;
};
let Some(ms) = parse_refresh_secs(raw) else {
return;
};
app.refresh_interval.store(ms, Ordering::Relaxed);
{
let mut cfg = app.config();
cfg.state.refresh_interval_ms = ms;
let _ = save_app_state(&cfg.state);
}
app.last_state_mtime = app_state_mtime();
app.refresh_interval_draft = None;
}
pub(crate) fn parse_refresh_secs(raw: &str) -> Option<u64> {
let ms = raw.parse::<u64>().ok()?.checked_mul(1000)?;
(MIN_REFRESH_INTERVAL_MS..=MAX_REFRESH_INTERVAL_MS)
.contains(&ms)
.then_some(ms)
}
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::LastResort => toggle_last_resort(app),
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 toggle_last_resort(app: &mut App) {
enum Outcome {
Missing,
Saved { moved_from: Option<String> },
SaveFailed(anyhow::Error),
}
let Some(pos) = selected_chain_member(app) else {
return;
};
let outcome = {
let mut cfg = app.config();
let Some(name) = cfg.state.fallback_chain.get(pos).cloned() else {
return;
};
match cfg.find_mut(&name) {
None => Outcome::Missing,
Some(profile) => {
profile.last_resort = !profile.last_resort;
let now_on = profile.last_resort;
match save_profile(profile) {
Ok(()) => {
let mut moved_from = None;
if now_on {
for p in cfg
.profiles
.iter_mut()
.filter(|p| p.last_resort && p.name != name)
{
p.last_resort = false;
match save_profile(p) {
Ok(()) => {
moved_from.get_or_insert_with(|| p.name.to_string());
}
Err(_) => p.last_resort = true,
}
}
}
Outcome::Saved { moved_from }
}
Err(e) => {
if let Some(p) = cfg.find_mut(&name) {
p.last_resort = !now_on;
}
Outcome::SaveFailed(e)
}
}
}
}
};
match outcome {
Outcome::Missing => {}
Outcome::Saved { moved_from } => {
if let Some(prev) = moved_from {
app.toast(ToastKind::Info, format!("last resort moved from '{prev}'"));
}
app.refresh_tokens();
}
Outcome::SaveFailed(e) => app.toast(ToastKind::Danger, format!("save failed: {e}")),
}
}
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::DivergenceTarget(_) => handle_divergence_target_key(app, key),
Modal::ActionMenu(_) => handle_action_menu_key(app, key),
Modal::EnvCollision(_) => handle_env_collision_key(app, key),
Modal::Login => match key.code {
KeyCode::Char('r') | KeyCode::Char('R') => {
if let Some(url) = app.login.as_ref().and_then(|s| s.url.clone()) {
match crate::platform::open_url(&url) {
Ok(()) => app.toast(ToastKind::Info, "opening your browser…"),
Err(_) => app.toast(ToastKind::Danger, "couldn't open the browser"),
}
}
}
KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => {
app.modals.pop();
}
_ => {}
},
}
}
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);
actions.push(ToggleEstimates);
actions.push(TogglePace);
}
Tab::Tokens => {
for (period, action) in [
(TokenPeriod::Lifetime, TokensPeriodLifetime),
(TokenPeriod::Daily, TokensPeriodDaily),
(TokenPeriod::Weekly, TokensPeriodWeekly),
(TokenPeriod::Monthly, TokensPeriodMonthly),
] {
if app.token_period != period {
actions.push(action);
}
}
if app.token_filter != TokenFilter::All {
actions.push(TokensShowAll);
}
if app.token_filter != TokenFilter::Claude {
actions.push(TokensShowClaude);
}
if app.token_filter != TokenFilter::Others {
actions.push(TokensShowOthers);
}
actions.push(ToggleCountCache);
actions.push(ReloadTokenStats);
}
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::Login => actions.push(ActionMenuAction::LoginAccount),
ConfigRow::DeleteCreds => actions.push(ActionMenuAction::ClearCredentials),
ConfigRow::Delete => actions.push(ActionMenuAction::DeleteProfile),
ConfigRow::Create => actions.push(ActionMenuAction::CreateProfile),
ConfigRow::EnvEntry(_) => {
actions.push(ActionMenuAction::EditField);
actions.push(ActionMenuAction::RemoveEnvField);
}
ConfigRow::ModelOverrideAdd => {}
_ => 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::LastResort => actions.push(ToggleLastResort),
FallbackRow::Remove => actions.push(RemoveMember),
}
}
}
},
Tab::Config => {}
Tab::Status => {
actions.push(RefreshStatus);
if app.status.selected().is_some() {
actions.push(OpenIncidentLink);
}
}
Tab::Plugin => {}
}
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 access token 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::ToggleLastResort => {
run_fallback_row(app, FallbackRow::LastResort);
}
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::LoginAccount => {
let rows = config_rows(app);
if let Some(&row) = rows.get(app.config_action_cursor) {
run_config_row(app, row);
}
}
ActionMenuAction::ClearCredentials => {
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::RemoveEnvField => remove_env_field(app),
ActionMenuAction::RefreshStatus => trigger_status_refresh(app),
ActionMenuAction::OpenIncidentLink => open_incident_link(app),
ActionMenuAction::ToggleEstimates => toggle_show_estimates(app),
ActionMenuAction::TogglePace => toggle_show_pace(app),
ActionMenuAction::TokensPeriodLifetime => set_token_period(app, TokenPeriod::Lifetime),
ActionMenuAction::TokensPeriodDaily => set_token_period(app, TokenPeriod::Daily),
ActionMenuAction::TokensPeriodWeekly => set_token_period(app, TokenPeriod::Weekly),
ActionMenuAction::TokensPeriodMonthly => set_token_period(app, TokenPeriod::Monthly),
ActionMenuAction::TokensShowAll => set_token_filter(app, TokenFilter::All),
ActionMenuAction::TokensShowClaude => set_token_filter(app, TokenFilter::Claude),
ActionMenuAction::TokensShowOthers => set_token_filter(app, TokenFilter::Others),
ActionMenuAction::ToggleCountCache => toggle_count_cache(app),
ActionMenuAction::ReloadTokenStats => reload_token_stats(app),
}
}
fn set_token_filter(app: &mut App, filter: TokenFilter) {
app.token_filter = filter;
let len = token_model_count(app);
if app.token_model_cursor >= len {
app.token_model_cursor = len.saturating_sub(1);
}
}
fn set_token_period(app: &mut App, period: TokenPeriod) {
app.token_period = period;
let len = token_model_count(app);
if app.token_model_cursor >= len {
app.token_model_cursor = len.saturating_sub(1);
}
}
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 => run_config_row(app, rows[app.config_action_cursor]),
KeyCode::Char(' ') => {
let row = rows[app.config_action_cursor];
if row == ConfigRow::Model {
cycle_model(app);
} else {
run_config_row(app, row);
}
}
_ => {}
}
}
}
}
pub(crate) fn config_rows(app: &App) -> Vec<ConfigRow> {
let cfg = app.config();
let draft = app.config_draft.as_ref();
if app.profile_cursor >= cfg.profiles.len() {
let mut rows = vec![ConfigRow::Name, ConfigRow::BaseUrl];
if draft.is_some_and(|d| !d.base_url.value.trim().is_empty()) {
rows.push(ConfigRow::ApiKey);
}
rows.push(ConfigRow::Model);
if draft.is_none_or(|d| d.base_url.value.trim().is_empty()) {
rows.push(ConfigRow::Login);
}
rows.push(ConfigRow::Create);
return rows;
}
let profile = cfg.profiles.get(app.profile_cursor);
let is_api = match draft {
Some(d) => !d.base_url.value.trim().is_empty(),
None => profile.map(|p| !p.is_oauth()).unwrap_or(false),
};
let mut rows = vec![ConfigRow::Name];
if !is_api {
rows.push(ConfigRow::AutoStart);
}
rows.push(ConfigRow::BaseUrl);
if is_api {
rows.push(ConfigRow::ApiKey);
}
rows.push(ConfigRow::Model);
let expanded = draft.is_some_and(|d| d.overrides_expanded);
let override_set = |row: ConfigRow| match draft {
Some(d) => d.field(row).is_some_and(|i| !i.value.trim().is_empty()),
None => {
let v = match row {
ConfigRow::OpusModel => profile.and_then(|p| p.models.opus.as_deref()),
ConfigRow::SonnetModel => profile.and_then(|p| p.models.sonnet.as_deref()),
ConfigRow::HaikuModel => profile.and_then(|p| p.models.haiku.as_deref()),
ConfigRow::SubagentModel => profile.and_then(|p| p.models.subagent.as_deref()),
_ => None,
};
v.is_some_and(|s| !s.trim().is_empty())
}
};
let mut any_collapsed = false;
for row in [
ConfigRow::OpusModel,
ConfigRow::SonnetModel,
ConfigRow::HaikuModel,
ConfigRow::SubagentModel,
] {
if expanded || override_set(row) {
rows.push(row);
} else {
any_collapsed = true;
}
}
if any_collapsed {
rows.push(ConfigRow::ModelOverrideAdd);
}
let env_count = profile.map(|p| p.env.len()).unwrap_or(0);
rows.extend((0..env_count).map(ConfigRow::EnvEntry));
rows.push(ConfigRow::EnvAdd);
if !is_api {
rows.push(ConfigRow::Login);
let has_creds = profile.and_then(|p| p.credentials.as_ref()).is_some();
if has_creds {
rows.push(ConfigRow::DeleteCreds);
}
}
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(""),
model: InputState::new(""),
opus_model: InputState::new(""),
sonnet_model: InputState::new(""),
haiku_model: InputState::new(""),
subagent_model: InputState::new(""),
env_value: InputState::new(""),
env_new_key: InputState::new(""),
active: None,
armed_delete: false,
overrides_expanded: false,
captured_creds: None,
}
}
fn build_draft_existing(app: &App, name: &str) -> ConfigDraft {
let cfg = app.config();
let profile = cfg.find(name);
let m = profile.map(|p| p.models.clone()).unwrap_or_default();
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("")),
model: InputState::new(m.default.as_deref().unwrap_or("")),
opus_model: InputState::new(m.opus.as_deref().unwrap_or("")),
sonnet_model: InputState::new(m.sonnet.as_deref().unwrap_or("")),
haiku_model: InputState::new(m.haiku.as_deref().unwrap_or("")),
subagent_model: InputState::new(m.subagent.as_deref().unwrap_or("")),
env_value: InputState::new(""),
env_new_key: InputState::new(""),
active: None,
armed_delete: false,
overrides_expanded: false,
captured_creds: None,
}
}
fn leave_config_detail(app: &mut App) {
let mint_dropped = app
.config_draft
.as_ref()
.is_some_and(|d| d.editing_name.is_none() && d.captured_creds.is_some());
app.config_focus = ConfigFocus::Profiles;
app.config_draft = None;
if mint_dropped {
app.toast(
ToastKind::Warning,
"the captured login was dropped with the form",
);
}
}
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) {
match row {
ConfigRow::EnvEntry(i) => {
enter_env_value_edit(app, i);
return;
}
ConfigRow::EnvAdd => {
enter_env_add_edit(app);
return;
}
ConfigRow::ModelOverrideAdd => {
if let Some(d) = app.config_draft.as_mut() {
d.overrides_expanded = true;
}
return;
}
_ => {}
}
if row.is_text() || row == ConfigRow::Model {
if let Some(d) = app.config_draft.as_mut() {
d.active = Some(row);
if let Some(input) = d.field_mut(row) {
input.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),
ConfigRow::Login => {
let editing = app
.config_draft
.as_ref()
.and_then(|d| d.editing_name.clone());
let target = match editing {
Some(name) => Some((name, false)),
None => {
let typed = app
.config_draft
.as_ref()
.map(|d| d.name.trimmed().to_string())
.unwrap_or_default();
let validation = {
let cfg = app.config();
validate_profile_name(&typed, &cfg.names(), None)
};
match validation {
Ok(()) => Some((typed, true)),
Err(e) => {
app.toast(ToastKind::Danger, format!("{e}"));
None
}
}
}
};
if let Some((name, is_new)) = target {
let has_stash = app
.config_draft
.as_ref()
.is_some_and(|d| d.captured_creds.is_some());
if has_stash {
app.modals.push(Modal::Confirm(ConfirmState {
message: "Replace the captured login?".to_string(),
detail: Some(
"A fresh browser login replaces the one already captured for this account. The stashed tokens are dropped."
.to_string(),
),
choice: false,
on_confirm: ConfirmAction::RestartLogin(name, is_new),
}));
} else {
start_login(app, name, is_new);
}
}
}
ConfigRow::DeleteCreds => {
if let Some(name) = name {
app.modals.push(Modal::Confirm(ConfirmState {
message: format!("Log out of '{name}'?"),
detail: Some(
"Blanks the login; keeps the profile, model, env, and chain slot. Re-login any time."
.to_string(),
),
choice: false,
on_confirm: ConfirmAction::BlankCredentials(name),
}));
}
}
_ => {}
}
}
fn start_login(app: &mut App, name: String, is_new: bool) {
if let Some(session) = app.login.as_ref() {
if session.name != name || session.is_new != is_new {
app.toast(
ToastKind::Warning,
format!("a login for '{}' is already in progress", session.name),
);
}
open_login_modal(app);
return;
}
app.login_generation += 1;
let generation = app.login_generation;
app.login = Some(LoginSession {
name,
is_new,
generation,
url: None,
stage: LoginStage::WaitingBrowser,
});
let event_tx = app.login_event_tx.clone();
let result_tx = app.login_result_tx.clone();
spawn_worker(move || {
let res = crate::oauth_login::login_with(|progress| {
use crate::oauth_login::LoginProgress;
let event = match progress {
LoginProgress::AuthorizeUrl(url) => LoginEvent::Url(url.to_string()),
LoginProgress::ExchangingCode => LoginEvent::Stage(LoginStage::ExchangingCode),
LoginProgress::Verifying => LoginEvent::Stage(LoginStage::Verifying),
};
let _ = event_tx.send((generation, event));
});
let _ = result_tx.send((generation, res.map_err(|e| e.to_string())));
});
open_login_modal(app);
}
fn open_login_modal(app: &mut App) {
if !app.modals.iter().any(|m| matches!(m, Modal::Login)) {
app.modals.push(Modal::Login);
}
}
fn close_login_modal(app: &mut App) {
app.modals.retain(|m| !matches!(m, Modal::Login));
}
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 Some(input) = d.field_mut(active)
{
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();
row_committed_value(cfg.find(&name), &name, field)
};
if let Some(d) = app.config_draft.as_mut()
&& let Some(input) = d.field_mut(field)
{
*input = InputState::new(&value);
}
}
if let Some(d) = app.config_draft.as_mut() {
d.active = None;
}
}
fn row_committed_value(profile: Option<&Profile>, name: &str, row: ConfigRow) -> String {
match row {
ConfigRow::Name => name.to_string(),
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(),
ConfigRow::Model => profile
.and_then(|p| p.models.default.clone())
.unwrap_or_default(),
ConfigRow::OpusModel => profile
.and_then(|p| p.models.opus.clone())
.unwrap_or_default(),
ConfigRow::SonnetModel => profile
.and_then(|p| p.models.sonnet.clone())
.unwrap_or_default(),
ConfigRow::HaikuModel => profile
.and_then(|p| p.models.haiku.clone())
.unwrap_or_default(),
ConfigRow::SubagentModel => profile
.and_then(|p| p.models.subagent.clone())
.unwrap_or_default(),
ConfigRow::EnvEntry(i) => profile
.and_then(|p| p.env.values().nth(i).cloned())
.unwrap_or_default(),
ConfigRow::EnvAdd
| ConfigRow::ModelOverrideAdd
| ConfigRow::AutoStart
| ConfigRow::Login
| ConfigRow::DeleteCreds
| ConfigRow::Delete
| ConfigRow::Create => String::new(),
}
}
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),
ConfigRow::Model
| ConfigRow::OpusModel
| ConfigRow::SonnetModel
| ConfigRow::HaikuModel
| ConfigRow::SubagentModel => commit_model_field(app, field),
ConfigRow::EnvEntry(i) => commit_env_value(app, i),
ConfigRow::EnvAdd => commit_env_new_key(app),
_ => {
if let Some(d) = app.config_draft.as_mut() {
d.active = None;
}
}
}
}
fn commit_model_field(app: &mut App, field: ConfigRow) {
let Some(name) = app
.config_draft
.as_ref()
.and_then(|d| d.editing_name.clone())
else {
return;
};
let raw = app
.config_draft
.as_ref()
.and_then(|d| d.field(field))
.map(|i| i.trimmed().to_string())
.unwrap_or_default();
let mut models = {
let cfg = app.config();
cfg.find(&name)
.map(|p| p.models.clone())
.unwrap_or_default()
};
apply_model_field(&mut models, field, &raw);
let result = {
let mut cfg = app.config();
edit_profile_model(&mut cfg, &name, models)
};
match result {
Ok(()) => {
let value = {
let cfg = app.config();
row_committed_value(cfg.find(&name), &name, field)
};
if let Some(d) = app.config_draft.as_mut() {
if let Some(input) = d.field_mut(field) {
*input = InputState::new(&value);
}
d.active = None;
}
}
Err(e) => app.toast(ToastKind::Danger, format!("model update failed: {e}")),
}
}
fn apply_model_field(models: &mut ModelSettings, field: ConfigRow, raw: &str) {
let scalar = (!raw.is_empty()).then(|| raw.to_string());
match field {
ConfigRow::Model => models.default = scalar,
ConfigRow::OpusModel => models.opus = scalar,
ConfigRow::SonnetModel => models.sonnet = scalar,
ConfigRow::HaikuModel => models.haiku = scalar,
ConfigRow::SubagentModel => models.subagent = scalar,
_ => {}
}
}
fn cycle_model(app: &mut App) {
let editing_name = app
.config_draft
.as_ref()
.and_then(|d| d.editing_name.clone());
let Some(name) = editing_name else {
if let Some(d) = app.config_draft.as_mut() {
let next = next_model_preset(d.model.trimmed_some().as_deref()).unwrap_or_default();
d.model = InputState::new(&next);
}
return;
};
let mut models = {
let cfg = app.config();
cfg.find(&name)
.map(|p| p.models.clone())
.unwrap_or_default()
};
models.default = next_model_preset(models.default.as_deref());
let result = {
let mut cfg = app.config();
edit_profile_model(&mut cfg, &name, models)
};
match result {
Ok(()) => {
let value = {
let cfg = app.config();
cfg.find(&name)
.and_then(|p| p.models.default.clone())
.unwrap_or_default()
};
if let Some(d) = app.config_draft.as_mut() {
d.model = InputState::new(&value);
}
}
Err(e) => app.toast(ToastKind::Danger, format!("model update failed: {e}")),
}
}
fn next_model_preset(current: Option<&str>) -> Option<String> {
match current {
None => Some(MODEL_PRESETS[0].to_string()),
Some(cur) => match MODEL_PRESETS.iter().position(|p| *p == cur) {
Some(i) if i + 1 < MODEL_PRESETS.len() => Some(MODEL_PRESETS[i + 1].to_string()),
_ => None,
},
}
}
fn enter_env_value_edit(app: &mut App, i: usize) {
let Some(name) = app
.config_draft
.as_ref()
.and_then(|d| d.editing_name.clone())
else {
return;
};
let value = {
let cfg = app.config();
cfg.find(&name)
.and_then(|p| p.env.values().nth(i).cloned())
.unwrap_or_default()
};
if let Some(d) = app.config_draft.as_mut() {
d.env_value = InputState::new(&value);
d.active = Some(ConfigRow::EnvEntry(i));
}
}
fn enter_env_add_edit(app: &mut App) {
if let Some(d) = app.config_draft.as_mut() {
d.env_new_key = InputState::new("");
d.active = Some(ConfigRow::EnvAdd);
}
}
fn commit_env_value(app: &mut App, i: usize) {
let Some(name) = app
.config_draft
.as_ref()
.and_then(|d| d.editing_name.clone())
else {
return;
};
let value = app
.config_draft
.as_ref()
.map(|d| d.env_value.trimmed().to_string())
.unwrap_or_default();
let new_env = {
let cfg = app.config();
cfg.find(&name).map(|p| {
let mut env = p.env.clone();
if let Some(key) = p.env.keys().nth(i).cloned() {
env.insert(key, value.clone());
}
env
})
};
let Some(new_env) = new_env else {
if let Some(d) = app.config_draft.as_mut() {
d.active = None;
}
return;
};
let result = {
let mut cfg = app.config();
edit_profile_env(&mut cfg, &name, new_env)
};
match result {
Ok(()) => {
let saved = {
let cfg = app.config();
cfg.find(&name)
.and_then(|p| p.env.values().nth(i).cloned())
.unwrap_or_default()
};
if let Some(d) = app.config_draft.as_mut() {
d.env_value = InputState::new(&saved);
d.active = None;
}
}
Err(e) => app.toast(ToastKind::Danger, format!("env update failed: {e}")),
}
}
fn commit_env_new_key(app: &mut App) {
let Some(name) = app
.config_draft
.as_ref()
.and_then(|d| d.editing_name.clone())
else {
return;
};
let key = app
.config_draft
.as_ref()
.map(|d| d.env_new_key.trimmed().to_string())
.unwrap_or_default();
if key.is_empty() {
if let Some(d) = app.config_draft.as_mut() {
d.active = None;
}
return;
}
if key.contains(char::is_whitespace) || key.contains('=') {
app.toast(
ToastKind::Danger,
"env key can't contain spaces or '='".to_string(),
);
return; }
let base_env_keys = claude_settings_env_keys().unwrap_or_default();
let collision = {
let cfg = app.config();
cfg.find(&name)
.and_then(|p| classify_env_key(p, &base_env_keys, &key))
};
if let Some(d) = app.config_draft.as_mut() {
d.active = None;
}
match collision {
Some(c) => app
.modals
.push(Modal::EnvCollision(env_collision_form(name, key, c))),
None => env_add_commit(app, &name, &key),
}
}
fn env_add_commit(app: &mut App, name: &str, key: &str) {
let exists = {
let cfg = app.config();
cfg.find(name)
.map(|p| p.env.contains_key(key))
.unwrap_or(false)
};
if !exists {
let new_env = {
let cfg = app.config();
cfg.find(name).map(|p| {
let mut env = p.env.clone();
env.insert(key.to_string(), String::new());
env
})
};
let Some(new_env) = new_env else {
return;
};
if let Err(e) = {
let mut cfg = app.config();
edit_profile_env(&mut cfg, name, new_env)
} {
app.toast(ToastKind::Danger, format!("env update failed: {e}"));
return;
}
}
let idx = {
let cfg = app.config();
cfg.find(name)
.and_then(|p| p.env.keys().position(|k| k == key))
};
let Some(idx) = idx else {
return;
};
if let Some(row_pos) = env_entry_row_index(app, idx) {
app.config_action_cursor = row_pos;
}
enter_env_value_edit(app, idx);
}
fn remove_env_field(app: &mut App) {
let rows = config_rows(app);
let Some(ConfigRow::EnvEntry(i)) = rows.get(app.config_action_cursor).copied() else {
return;
};
let Some(name) = app
.config_draft
.as_ref()
.and_then(|d| d.editing_name.clone())
else {
return;
};
let (removed, new_env) = {
let cfg = app.config();
match cfg.find(&name) {
Some(p) => {
let Some(removed) = p.env.keys().nth(i).cloned() else {
return;
};
let mut env = p.env.clone();
env.remove(&removed);
(removed, env)
}
None => return,
}
};
let result = {
let mut cfg = app.config();
edit_profile_env(&mut cfg, &name, new_env)
};
match result {
Ok(()) => {
if let Some(d) = app.config_draft.as_mut() {
d.active = None;
}
let last = config_rows(app).len().saturating_sub(1);
app.config_action_cursor = app.config_action_cursor.min(last);
app.toast(ToastKind::Success, format!("removed env '{removed}'"));
}
Err(e) => app.toast(ToastKind::Danger, format!("env remove failed: {e}")),
}
}
fn env_entry_row_index(app: &App, sorted_idx: usize) -> Option<usize> {
config_rows(app)
.iter()
.position(|r| *r == ConfigRow::EnvEntry(sorted_idx))
}
fn env_collision_form(
profile: String,
key: String,
collision: EnvKeyCollision,
) -> EnvCollisionForm {
let (reason, existing_idx) = match collision {
EnvKeyCollision::Managed(label) => (label.to_string(), None),
EnvKeyCollision::ProfileField(idx) => (
"another custom field on this account".to_string(),
Some(idx),
),
EnvKeyCollision::BaseSettings => ("your ~/.claude/settings.json".to_string(), None),
};
EnvCollisionForm {
profile,
key,
reason,
existing_idx,
cursor: EnvCollisionForm::options().len() - 1,
}
}
fn handle_env_collision_key(app: &mut App, key: KeyEvent) {
let Some(Modal::EnvCollision(state)) = app.modals.last_mut() else {
return;
};
let last = EnvCollisionForm::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 | KeyCode::Char('q') => {
app.modals.pop();
}
KeyCode::Enter | KeyCode::Char(' ') => {
let choice = EnvCollisionForm::options()[state.cursor.min(last)];
let name = state.profile.clone();
let pending = state.key.clone();
let existing_idx = state.existing_idx;
app.modals.pop();
run_env_collision_choice(app, &name, &pending, existing_idx, choice);
}
_ => {}
}
}
fn run_env_collision_choice(
app: &mut App,
name: &str,
key: &str,
existing_idx: Option<usize>,
choice: EnvCollisionChoice,
) {
match choice {
EnvCollisionChoice::Overwrite => env_add_commit(app, name, key),
EnvCollisionChoice::KeepExisting => {
if let Some(idx) = existing_idx
&& let Some(row_pos) = env_entry_row_index(app, idx)
{
app.config_action_cursor = row_pos;
}
}
EnvCollisionChoice::Cancel => {}
}
}
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 model = d.model.trimmed_some();
let captured = if base_url.is_none() {
d.captured_creds.clone()
} else {
None
};
let mint_discarded = base_url.is_some() && d.captured_creds.is_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();
match captured {
Some(creds) => create_profile_from_login(&mut cfg, name.clone(), model, *creds),
None => create_blank_profile(&mut cfg, name.clone(), base_url, api_key, model),
}
};
match result {
Ok(()) => {
if mint_discarded {
app.toast(
ToastKind::Info,
"base url set · the captured oauth login was discarded",
);
}
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 | KeyCode::Char('q') => {
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::CaptureOverwrite(snapshot, name, from_divergence) => {
if 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();
overwrite_captured_profile(&mut cfg, &name, *snapshot)
};
match result {
Ok(()) => {
app.refresh_tokens();
app.last_state_mtime = app_state_mtime();
app.toast(
ToastKind::Success,
format!("overwrote '{name}' with the captured login"),
);
}
Err(e) => app.toast(ToastKind::Danger, format!("overwrite failed: {e}")),
}
}
ConfirmAction::AdoptDivergence(snapshot, name) => {
let result = {
let mut cfg = app.config();
overwrite_captured_profile(&mut cfg, &name, *snapshot).and_then(|()| {
cfg.state.active_profile = Some(name.as_str().into());
save_app_state(&cfg.state)
})
};
let result = result.and_then(|()| force_link_profile_credentials(&name));
match result {
Ok(()) => {
app.refresh_tokens();
app.last_state_mtime = app_state_mtime();
app.toast(
ToastKind::Success,
format!("saved the login into '{name}', now active"),
);
}
Err(e) => app.toast(ToastKind::Danger, format!("save failed: {e}")),
}
}
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();
spawn_worker(move || {
let _ = oauth::refresh_all(&config, true, &refetch, &activity, &sender);
});
app.toast(ToastKind::Info, "rotating all tokens");
}
ConfirmAction::RotateOne(name) => {
if crate::runtime::has_live_session(&name) {
app.toast(
ToastKind::Warning,
format!(
"'{name}' is in use by a running session; its tokens are managed there"
),
);
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();
let target = name.clone();
spawn_worker(move || {
oauth::rotate_one(&config, &target, &refetch, &activity, &sender);
});
app.toast(ToastKind::Info, format!("rotating '{name}'"));
}
ConfirmAction::WireMcpServers => {
match crate::plugin_probe::wire_mcp_server() {
Ok(()) => {
app.toast(ToastKind::Success, "wired clauth into ~/.claude.json");
recompute_plugin_checks(app, false);
}
Err(e) => app.toast(ToastKind::Danger, format!("wire failed: {e}")),
}
}
ConfirmAction::RelinkCredentials(name) => match force_link_profile_credentials(&name) {
Ok(()) => {
app.refresh_tokens();
app.toast(
ToastKind::Success,
format!("relinked credentials to '{name}'"),
);
recompute_plugin_checks(app, false);
}
Err(e) => app.toast(ToastKind::Danger, format!("relink failed: {e}")),
},
ConfirmAction::BlankCredentials(name) => {
let result = {
let mut cfg = app.config();
clear_profile_credentials(&mut cfg, &name)
};
match result {
Ok(()) => {
app.refresh_tokens();
app.last_state_mtime = app_state_mtime();
app.toast(ToastKind::Success, format!("logged out of '{name}'"));
}
Err(e) => app.toast(ToastKind::Danger, format!("log out failed: {e}")),
}
}
ConfirmAction::RestartLogin(name, is_new) => start_login(app, name, is_new),
}
}
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 | KeyCode::Char('q') => {
app.divergence_snooze = live_creds_fingerprint();
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 => open_divergence_target_picker(app),
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 open_divergence_target_picker(app: &mut App) {
let (targets, preselect) = {
let cfg = app.config();
let active = cfg.state.active_profile.as_deref();
let targets: Vec<String> = cfg
.profiles
.iter()
.map(|p| p.name.as_str().to_string())
.filter(|n| Some(n.as_str()) != active)
.collect();
let live = read_claude_credentials().ok().flatten();
let preselect = find_matching_oauth_profile(&cfg, live.as_ref())
.and_then(|m| targets.iter().position(|n| n == m))
.map_or(0, |i| i + 1);
(targets, preselect)
};
if targets.is_empty() {
begin_capture(app, true);
return;
}
app.modals
.push(Modal::DivergenceTarget(DivergenceTargetForm {
targets,
cursor: preselect,
}));
}
fn handle_divergence_target_key(app: &mut App, key: KeyEvent) {
let Some(Modal::DivergenceTarget(form)) = app.modals.last_mut() else {
return;
};
let last = form.targets.len(); match key.code {
KeyCode::Up => {
form.cursor = if form.cursor == 0 {
last
} else {
form.cursor - 1
};
}
KeyCode::Down => {
form.cursor = if form.cursor >= last {
0
} else {
form.cursor + 1
};
}
KeyCode::Esc | KeyCode::Char('q') => {
app.modals.pop();
}
KeyCode::Enter | KeyCode::Char(' ') => {
let cursor = form.cursor;
let Some(Modal::DivergenceTarget(form)) = app.modals.pop() else {
return;
};
if cursor == 0 {
begin_capture(app, true);
return;
}
let Some(target) = form.targets.get(cursor - 1).cloned() else {
return;
};
let Some(snapshot) = capture_live_or_toast(app) else {
return;
};
app.modals.push(Modal::Confirm(ConfirmState {
message: format!("Save the live login into '{target}'?"),
detail: Some(format!(
"'{target}' becomes the active account; its old credentials are replaced. Usage history, env, and model settings are kept."
)),
choice: false,
on_confirm: ConfirmAction::AdoptDivergence(Box::new(snapshot), target),
}));
}
_ => {}
}
}
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 | KeyCode::Char('q') => {
app.modals.pop();
}
KeyCode::Enter => {
let name = form.input.trimmed().to_string();
if let Err(e) = validate_profile_name(&name, &[], None) {
app.toast(ToastKind::Danger, format!("{e}"));
return;
}
let collision = {
let cfg = app.config();
cfg.canonical_name(&name)
};
let Some(Modal::CaptureName(form)) = app.modals.pop() else {
return;
};
let CaptureNameForm {
snapshot,
from_divergence,
..
} = form;
if let Some(existing) = collision {
app.modals.push(Modal::Confirm(ConfirmState {
message: format!("Profile '{existing}' already exists."),
detail: Some(
"Overwrite its credentials with the captured login? Usage history, env, and model settings are kept.".to_string(),
),
choice: false,
on_confirm: ConfirmAction::CaptureOverwrite(
snapshot,
existing,
from_divergence,
),
}));
return;
}
if 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,
mut 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 = crate::format::truncate(&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;
incidents.sort_by(|a, b| match (a.is_active(), b.is_active()) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => b.started_ms.cmp(&a.started_ms),
});
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 drain_tokens_events(app: &mut App) {
while let Ok(ev) = app.tokens_events.try_recv() {
match ev {
crate::tokens::TokensEvent::Base(stats) => {
app.tokens_failed = false;
if app.token_stats.is_none() {
app.token_stats = Some(*stats);
app.tokens_topping_up = true;
}
}
crate::tokens::TokensEvent::Progress { done, total } => {
app.tokens_progress = Some((done, total));
}
crate::tokens::TokensEvent::Loaded(stats) => {
app.tokens_failed = false;
app.tokens_topping_up = false;
app.tokens_progress = None;
app.token_stats = Some(*stats);
let len = token_model_count(app);
if len > 0 && app.token_model_cursor >= len {
app.token_model_cursor = len - 1;
}
}
crate::tokens::TokensEvent::Failed => {
app.tokens_topping_up = false;
app.tokens_progress = None;
if app.token_stats.is_none() {
app.tokens_failed = true;
}
}
}
}
}
fn drain_pricing_events(app: &mut App) {
while let Ok(ev) = app.pricing_events.try_recv() {
match ev {
crate::pricing::PricingEvent::Loaded(table) => {
app.price_table = Some(*table);
}
crate::pricing::PricingEvent::Failed => {}
}
}
}
fn drain_login_events(app: &mut App) {
while let Ok((generation, event)) = app.login_event_rx.try_recv() {
if let Some(session) = app.login.as_mut()
&& session.generation == generation
{
match event {
LoginEvent::Url(url) => session.url = Some(url),
LoginEvent::Stage(stage) => session.stage = stage,
}
}
}
while let Ok((generation, result)) = app.login_result_rx.try_recv() {
if app.login.as_ref().map(|s| s.generation) != Some(generation) {
continue; }
let Some(session) = app.login.take() else {
continue;
};
close_login_modal(app);
match result {
Ok(creds) => apply_login(app, session, creds),
Err(e) => app.toast(
ToastKind::Danger,
format!("login for '{}' failed: {e}", session.name),
),
}
}
}
fn apply_login(app: &mut App, session: LoginSession, creds: crate::profile::ClaudeCredentials) {
if session.is_new {
let stashed = match app
.config_draft
.as_mut()
.filter(|d| d.editing_name.is_none())
{
Some(draft) => {
draft.captured_creds = Some(Box::new(creds));
true
}
None => false,
};
if stashed {
if let Some(idx) = config_rows(app)
.iter()
.position(|r| *r == ConfigRow::Create)
{
app.config_action_cursor = idx;
}
app.toast(ToastKind::Success, "logged in · create account saves it");
} else {
app.toast(
ToastKind::Warning,
"login finished but the new-account form is no longer open · log in again",
);
}
return;
}
let (exists, has_creds) = {
let cfg = app.config();
let profile = cfg.find(&session.name);
(
profile.is_some(),
profile.and_then(|p| p.credentials.as_ref()).is_some(),
)
};
if !exists {
app.toast(
ToastKind::Danger,
format!("login failed: profile '{}' no longer exists", session.name),
);
return;
}
let snapshot = CaptureSnapshot {
credentials: Some(creds),
base_url: None,
api_key: None,
};
let apply_now = !has_creds
|| matches!(
app.config().state.default_divergence,
Some(DivergenceChoice::Overwrite)
);
if !apply_now {
app.modals.push(Modal::Confirm(ConfirmState {
message: format!("Replace the stored credentials for '{}'?", session.name),
detail: Some(
"A fresh browser login finished for this account. The old tokens are dropped; chain slot, env, and model settings stay."
.to_string(),
),
choice: false,
on_confirm: ConfirmAction::CaptureOverwrite(Box::new(snapshot), session.name, false),
}));
return;
}
let result = {
let mut cfg = app.config();
overwrite_captured_profile(&mut cfg, &session.name, snapshot)
};
match result {
Ok(()) => {
app.refresh_tokens();
app.last_state_mtime = app_state_mtime();
app.toast(ToastKind::Success, format!("logged in '{}'", session.name));
}
Err(e) => app.toast(ToastKind::Danger, format!("login failed: {e}")),
}
}
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);
drain_tokens_events(app);
drain_pricing_events(app);
drain_login_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);
poll_plugin_refresh(app);
update_banner(app);
app.prune_toasts();
}
fn poll_plugin_refresh(app: &mut App) {
const PLUGIN_REFRESH_INTERVAL: Duration = Duration::from_secs(1);
if app.tab != Tab::Plugin || !app.modals.is_empty() {
return;
}
if app.last_plugin_refresh.elapsed() < PLUGIN_REFRESH_INTERVAL {
return;
}
app.last_plugin_refresh = Instant::now();
recompute_plugin_checks(app, false);
}
fn update_banner(app: &mut App) {
let cfg = app.config();
let no_active = !cfg.profiles.is_empty() && cfg.state.active_profile.is_none();
let any_spent = no_active && cfg.profiles.iter().any(crate::fallback::is_exhausted);
drop(cfg);
app.banner = if no_active {
let message = if any_spent {
"all accounts spent · switch to a profile to resume"
} else {
"no active profile · select one to resume"
};
Some(Banner {
severity: BannerSeverity::Danger,
message: message.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, outcome }) = app.op_results.try_recv() {
if let Ok(mut a) = app.activity.lock()
&& a.get(&name).copied() == Some(ProfileActivity::Refreshing)
{
a.remove(&name);
}
match outcome {
Ok(()) => {
needs_token_snapshot_rebuild = true;
app.toast(ToastKind::Info, format!("rotated token for '{name}'"));
app.set_tab_activity(Tab::Usage, ToastKind::Info);
}
Err(e) => {
app.toast(
ToastKind::Danger,
format!("refresh for '{name}' failed: {e}"),
);
app.set_tab_activity(Tab::Usage, 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;
let default = app.config().state.default_divergence;
if let Some(choice) = default {
run_divergence_choice(app, &active, choice);
} else {
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)
) {
app.divergence_snooze = None; 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;
}
let default = app.config().state.default_divergence;
if let Some(choice) = default {
run_divergence_choice(app, &active, choice);
return;
}
let fingerprint = live_creds_fingerprint();
if fingerprint.is_some() && fingerprint == app.divergence_snooze {
return;
}
app.divergence_snooze = None;
app.modals
.push(Modal::Divergence(DivergenceForm { active, cursor: 0 }));
}
fn live_creds_fingerprint() -> Option<u64> {
use std::hash::{DefaultHasher, Hash, Hasher};
let creds = read_claude_credentials().ok().flatten()?;
let token = creds.access_token().filter(|t| !t.is_empty())?;
let mut hasher = DefaultHasher::new();
token.hash(&mut hasher);
Some(hasher.finish())
}
struct BootstrapDoneGuard {
bootstrap_active: Arc<AtomicBool>,
startup_sender: StartupSender,
}
impl Drop for BootstrapDoneGuard {
fn drop(&mut self) {
self.bootstrap_active.store(false, Ordering::SeqCst);
let _ = self.startup_sender.send(StartupSignal::BootstrapDone);
}
}
fn spawn_worker<F>(f: F)
where
F: FnOnce() + Send + 'static,
{
std::thread::spawn(move || {
let _ = catch_unwind(AssertUnwindSafe(f));
});
}
pub(crate) fn shutdown(app: &mut App) -> Result<()> {
app.shutting_down.store(true, Ordering::SeqCst);
let wait_start = Instant::now();
while wait_start.elapsed() < Duration::from_millis(2000) {
if !any_busy(&app.activity) {
break;
}
std::thread::sleep(Duration::from_millis(50));
}
if let Some(handle) = app.update_handle.take() {
let deadline = Instant::now() + Duration::from_secs(5);
while !handle.is_finished() && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(50));
}
drop(handle);
}
{
let mut cfg = app.config();
let _ = snapshot_active_credentials(&mut cfg);
let _ = save_app_state(&cfg.state);
}
let _ = detach_credentials_link();
Ok(())
}
#[cfg(test)]
#[path = "../../tests/inline/tui_app.rs"]
mod tests;