From 0024e5b252098d50cdc0f88264f94fedc090c1f2 Mon Sep 17 00:00:00 2001 From: xodapi <4956501+xodapi@users.noreply.github.com> Date: Sun, 21 Jun 2026 16:02:12 +0700 Subject: [PATCH] Add quota spend forecasting --- README.md | 9 +- src/app.rs | 144 ++++++++++++++++++++++ src/collector/codex.rs | 19 ++- src/collector/rate_limit.rs | 1 + src/demo.rs | 2 + src/lib.rs | 22 ++++ src/locale.rs | 8 ++ src/model/session.rs | 8 ++ src/snapshot.rs | 223 ++++++++++++++++++++++++++++++++++ src/ui/quota.rs | 230 ++++++++++++++++++++++++++++++------ src/ui/sessions.rs | 17 ++- 11 files changed, 643 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 3191e10..b202027 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ Pre-built binaries for all platforms are available on the [GitHub Releases](http abtop # Launch TUI abtop --once # Print snapshot and exit abtop --json # Print one JSON snapshot and exit (for scripts/tools) +abtop --status-json # Print compact status JSON without local paths/prompts abtop --setup # Install rate limit collection hook abtop --theme dracula # Launch with a specific theme ``` @@ -161,6 +162,9 @@ state the TUI renders. ```bash abtop --json # one-shot JSON snapshot for scripts +abtop --status-json +# compact status summary for widgets/mobile clients; omits paths, prompts, +# chat text, session ids, tool args, child commands, and project names ``` For long-running consumers, build an `App`, refresh it with @@ -178,6 +182,7 @@ let mut app = App::new_with_config_and_claude_dirs( ); app.tick_no_summaries(); let json = serde_json::to_string(&app.to_snapshot(2_000)).unwrap(); +let status_json = serde_json::to_string(&app.to_status_summary(2_000)).unwrap(); ``` `App` is not `Send` (it owns the collectors), so keep it on one thread and pass @@ -188,7 +193,9 @@ is a reference consumer: a local-first web dashboard built on exactly this API. abtop reads local files and local process/open-file metadata only. No API keys, no auth. In the TUI and `--once` output, tool names and file paths are shown, but file contents and prompt text are never displayed. Session summaries are generated via `claude --print`, which makes its own API call — this is the only indirect network usage. -The JSON snapshot includes richer local dashboard data, including `summary`, `chat_messages`, working directories, config roots, tool-call previews, child process commands, token counts, and port metadata. Chat text is bounded and redacted by the collectors, but it is still derived from local transcripts and may contain sensitive project context. Treat JSON snapshots as local/private data and avoid writing them to shared logs or exposing them on a network without your own access controls. +The full JSON snapshot includes richer local dashboard data, including `summary`, `chat_messages`, working directories, config roots, tool-call previews, child process commands, token counts, and port metadata. Chat text is bounded and redacted by the collectors, but it is still derived from local transcripts and may contain sensitive project context. Treat full JSON snapshots as local/private data and avoid writing them to shared logs or exposing them on a network without your own access controls. + +For lower-risk integrations, `--status-json` emits only aggregate health/quota fields and intentionally omits local paths, prompts, chat text, session identifiers, tool arguments, child commands, and project names. ## Acknowledgements diff --git a/src/app.rs b/src/app.rs index 23783a8..b1889b7 100644 --- a/src/app.rs +++ b/src/app.rs @@ -14,6 +14,20 @@ const MAX_SUMMARY_JOBS: usize = 3; /// Max summary attempts per session before giving up. const MAX_SUMMARY_RETRIES: u32 = 2; +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +enum RateLimitWindow { + FiveHour, + SevenDay, +} + +#[derive(Clone, Copy, Debug, Default)] +struct RateLimitSpendSample { + pct: f64, + observed_at: u64, + burn_pct_per_hour: Option, + eta_secs: Option, +} + /// Produce a terminal-safe fallback summary from a raw prompt. fn sanitize_fallback(prompt: &str, max_len: usize) -> String { prompt @@ -93,6 +107,8 @@ pub struct App { pub rate_limits: Vec, /// Per-session previous token totals, keyed by (agent_cli, session_id). prev_tokens: HashMap<(String, String), u64>, + /// Last observed quota percentages, keyed by (source, window), for burn-rate estimates. + rate_limit_spend_samples: HashMap<(String, RateLimitWindow), RateLimitSpendSample>, /// Rate limit poll counter (read every 5 ticks = 10s) rate_limit_counter: u32, collector: MultiCollector, @@ -178,6 +194,7 @@ impl App { token_rates: VecDeque::with_capacity(GRAPH_HISTORY_LEN), rate_limits: Vec::new(), prev_tokens: HashMap::new(), + rate_limit_spend_samples: HashMap::new(), rate_limit_counter: 5, collector, summaries, @@ -541,6 +558,11 @@ impl App { self.rate_limits = read_rate_limits(&extra_dirs); // Merge live rate limits from agent collectors (e.g. Codex JSONL parsing) self.rate_limits.extend(self.collector.agent_rate_limits()); + annotate_rate_limit_spend( + &mut self.rate_limits, + &mut self.rate_limit_spend_samples, + now_secs(), + ); } else { self.rate_limit_counter += 1; } @@ -1062,6 +1084,85 @@ fn save_summary_cache(summaries: &HashMap) { } } +fn annotate_rate_limit_spend( + rate_limits: &mut [RateLimitInfo], + samples: &mut HashMap<(String, RateLimitWindow), RateLimitSpendSample>, + now: u64, +) { + for rl in rate_limits { + let (burn, eta) = update_rate_limit_spend_sample( + &rl.source, + RateLimitWindow::FiveHour, + rl.five_hour_pct, + rl.updated_at, + now, + samples, + ); + rl.five_hour_burn_pct_per_hour = burn; + rl.five_hour_eta_secs = eta; + + let (burn, eta) = update_rate_limit_spend_sample( + &rl.source, + RateLimitWindow::SevenDay, + rl.seven_day_pct, + rl.updated_at, + now, + samples, + ); + rl.seven_day_burn_pct_per_hour = burn; + rl.seven_day_eta_secs = eta; + } +} + +fn update_rate_limit_spend_sample( + source: &str, + window: RateLimitWindow, + pct: Option, + updated_at: Option, + now: u64, + samples: &mut HashMap<(String, RateLimitWindow), RateLimitSpendSample>, +) -> (Option, Option) { + let key = (source.to_ascii_lowercase(), window); + let Some(pct) = pct.map(|value| value.clamp(0.0, 100.0)) else { + samples.remove(&key); + return (None, None); + }; + let observed_at = updated_at.unwrap_or(now); + + let mut current = RateLimitSpendSample { + pct, + observed_at, + ..Default::default() + }; + + if let Some(prev) = samples.get(&key).copied() { + if observed_at == prev.observed_at { + current.burn_pct_per_hour = prev.burn_pct_per_hour; + current.eta_secs = prev.eta_secs; + } else if observed_at > prev.observed_at && pct > prev.pct { + let elapsed_secs = observed_at - prev.observed_at; + let burn_pct_per_hour = (pct - prev.pct) * 3600.0 / elapsed_secs as f64; + let remaining_pct = (100.0 - pct).max(0.0); + current.burn_pct_per_hour = Some(burn_pct_per_hour); + current.eta_secs = if burn_pct_per_hour > 0.0 { + Some((remaining_pct * 3600.0 / burn_pct_per_hour).ceil() as u64) + } else { + None + }; + } + } + + samples.insert(key, current); + (current.burn_pct_per_hour, current.eta_secs) +} + +fn now_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + /// Threshold above which a rate-limited bucket is surfaced as RateLimited /// in the session list. 90% leaves enough headroom to catch near-saturation /// before the account actually blocks. @@ -1154,9 +1255,52 @@ mod tests { seven_day_pct: None, seven_day_resets_at: None, updated_at: None, + ..Default::default() } } + #[test] + fn rate_limit_spend_estimates_burn_and_eta() { + let mut samples = HashMap::new(); + let mut limits = vec![RateLimitInfo { + source: "codex".to_string(), + five_hour_pct: Some(10.0), + five_hour_resets_at: Some(20_000), + updated_at: Some(1_000), + ..Default::default() + }]; + + annotate_rate_limit_spend(&mut limits, &mut samples, 1_000); + assert_eq!(limits[0].five_hour_burn_pct_per_hour, None); + assert_eq!(limits[0].five_hour_eta_secs, None); + + limits[0].five_hour_pct = Some(15.0); + limits[0].updated_at = Some(1_600); + annotate_rate_limit_spend(&mut limits, &mut samples, 1_600); + + assert_eq!(limits[0].five_hour_burn_pct_per_hour, Some(30.0)); + assert_eq!(limits[0].five_hour_eta_secs, Some(10_200)); + } + + #[test] + fn rate_limit_spend_clears_on_window_reset() { + let mut samples = HashMap::new(); + let mut limits = vec![RateLimitInfo { + source: "codex".to_string(), + five_hour_pct: Some(80.0), + updated_at: Some(1_000), + ..Default::default() + }]; + + annotate_rate_limit_spend(&mut limits, &mut samples, 1_000); + limits[0].five_hour_pct = Some(70.0); + limits[0].updated_at = Some(1_600); + annotate_rate_limit_spend(&mut limits, &mut samples, 1_600); + + assert_eq!(limits[0].five_hour_burn_pct_per_hour, None); + assert_eq!(limits[0].five_hour_eta_secs, None); + } + #[test] fn test_rate_limited_promotion_is_per_agent_cli() { // Claude is saturated, Codex is not. Only the Claude session should diff --git a/src/collector/codex.rs b/src/collector/codex.rs index 3c7b4c0..83f15c0 100644 --- a/src/collector/codex.rs +++ b/src/collector/codex.rs @@ -1243,7 +1243,9 @@ fn parse_codex_jsonl(path: &Path) -> Option { info.seven_day_resets_at = resets; } } - result.rate_limit = Some(info); + if info.five_hour_pct.is_some() || info.seven_day_pct.is_some() { + result.rate_limit = Some(info); + } } } Some("agent_message") => { @@ -1846,6 +1848,21 @@ mod tests { assert_eq!(rl.seven_day_pct, Some(14.0)); } + #[test] + fn test_parse_codex_ignores_empty_rate_limits() { + let mut file = tempfile::NamedTempFile::new().unwrap(); + write_lines( + &mut file, + &[ + SESSION_META, + r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:00Z","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1,"output_tokens":1},"last_token_usage":{"input_tokens":1,"output_tokens":1}},"rate_limits":{"limit_id":"codex"}}}"#, + ], + ); + + let result = parse_codex_jsonl(file.path()).unwrap(); + assert!(result.rate_limit.is_none()); + } + #[test] fn test_parse_codex_rate_limits_ignores_model_specific_limits() { let mut file = tempfile::NamedTempFile::new().unwrap(); diff --git a/src/collector/rate_limit.rs b/src/collector/rate_limit.rs index c177fda..b17ee9a 100644 --- a/src/collector/rate_limit.rs +++ b/src/collector/rate_limit.rs @@ -126,5 +126,6 @@ fn read_rate_file(path: &Path, default_source: &str) -> Option { seven_day_pct: file.seven_day.as_ref().map(|w| w.used_percentage), seven_day_resets_at: file.seven_day.as_ref().map(|w| w.resets_at), updated_at: file.updated_at, + ..Default::default() }) } diff --git a/src/demo.rs b/src/demo.rs index 06df788..27502a8 100644 --- a/src/demo.rs +++ b/src/demo.rs @@ -542,6 +542,7 @@ pub fn populate_demo(app: &mut App) { seven_day_pct: Some(12.0), seven_day_resets_at: Some(now_secs() + 5 * 24 * 3600), updated_at: Some(now_secs() - 10), + ..Default::default() }, RateLimitInfo { source: "codex".into(), @@ -550,6 +551,7 @@ pub fn populate_demo(app: &mut App) { seven_day_pct: Some(14.0), seven_day_resets_at: Some(now_secs() + 6 * 24 * 3600), updated_at: Some(now_secs() - 5), + ..Default::default() }, ]; diff --git a/src/lib.rs b/src/lib.rs index ab42504..60768eb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -165,6 +165,28 @@ pub fn run() -> io::Result<()> { } } + // --status-json flag: print a compact, privacy-preserving status summary + // for widgets/mobile clients. Unlike --json, it omits cwd, prompts, chat, + // session ids, tool args, child commands, and project names. + if std::env::args().any(|a| a == "--status-json") { + let mut app = build_app(initial_theme.unwrap_or_default(), &cfg); + if demo_mode { + demo::populate_demo(&mut app); + } else { + app.tick_no_summaries(); + } + match serde_json::to_string_pretty(&app.to_status_summary(2000)) { + Ok(json) => { + println!("{}", json); + return Ok(()); + } + Err(e) => { + eprintln!("failed to serialize status summary: {}", e); + std::process::exit(1); + } + } + } + // --once flag: print snapshot and exit if std::env::args().any(|a| a == "--once") { let mut app = build_app(initial_theme.unwrap_or_default(), &cfg); diff --git a/src/locale.rs b/src/locale.rs index 84e8de5..0894103 100644 --- a/src/locale.rs +++ b/src/locale.rs @@ -202,8 +202,12 @@ static LOCALE_EN: LazyLock> = LazyLock::ne m.insert("quota.no_data", "no data"); m.insert("quota.abtop_setup", "abtop --setup"); m.insert("quota.run_codex", "run codex once"); + m.insert("quota.usage_unknown", "usage unknown"); + m.insert("quota.codex_wait", "after next reply"); + m.insert("quota.claude_wait", "statusline hook"); m.insert("quota.total", "total"); m.insert("quota.in", "in"); + m.insert("quota.cap", "cap"); // Projects panel m.insert("projects.no_git", "no git"); @@ -447,8 +451,12 @@ static LOCALE_ZH: LazyLock> = LazyLock::ne m.insert("quota.no_data", "无数据"); m.insert("quota.abtop_setup", "abtop --setup"); m.insert("quota.run_codex", "运行一次 codex"); + m.insert("quota.usage_unknown", "用量未知"); + m.insert("quota.codex_wait", "下次回复后"); + m.insert("quota.claude_wait", "状态栏钩子"); m.insert("quota.total", "总计"); m.insert("quota.in", "还有"); + m.insert("quota.cap", "耗尽"); // Projects panel m.insert("projects.no_git", "非 Git"); diff --git a/src/model/session.rs b/src/model/session.rs index 2bc5f24..d6d965c 100644 --- a/src/model/session.rs +++ b/src/model/session.rs @@ -41,10 +41,18 @@ pub struct RateLimitInfo { pub five_hour_pct: Option, /// 5-hour window reset timestamp (epoch seconds) pub five_hour_resets_at: Option, + /// Observed 5-hour usage burn rate, in percentage points per hour. + pub five_hour_burn_pct_per_hour: Option, + /// Estimated seconds until the 5-hour window reaches 100% at current burn. + pub five_hour_eta_secs: Option, /// 7-day window usage percentage (0-100) pub seven_day_pct: Option, /// 7-day window reset timestamp (epoch seconds) pub seven_day_resets_at: Option, + /// Observed 7-day usage burn rate, in percentage points per hour. + pub seven_day_burn_pct_per_hour: Option, + /// Estimated seconds until the 7-day window reaches 100% at current burn. + pub seven_day_eta_secs: Option, /// When this data was last updated pub updated_at: Option, } diff --git a/src/snapshot.rs b/src/snapshot.rs index 3ce950d..7fa1b78 100644 --- a/src/snapshot.rs +++ b/src/snapshot.rs @@ -50,6 +50,63 @@ pub struct Snapshot { pub mcp_servers: Vec, } +/// Compact status payload for widgets, notifications, and mobile clients. +/// +/// Unlike [`Snapshot`], this intentionally omits cwd, prompts, chat text, +/// session identifiers, tool arguments, child commands, and project names. +#[derive(Debug, Clone, Serialize)] +pub struct StatusSummary { + /// Unix-epoch milliseconds when this summary was built. + pub generated_at_ms: u64, + /// Collector tick interval in milliseconds. + pub interval_ms: u64, + /// Most recent per-tick active-token delta across all sessions. + pub token_rate: f64, + /// Total live sessions across all supported agent CLIs. + pub sessions_total: usize, + /// Sessions currently doing work. + pub sessions_active: usize, + /// Per-agent aggregate status, without per-session identifiers. + pub agents: Vec, + /// Account quota windows, when an agent has reported them. + pub quota: Vec, +} + +/// Aggregated status for one agent CLI. +#[derive(Debug, Clone, Serialize)] +pub struct AgentStatusSummary { + pub agent_cli: &'static str, + pub sessions: usize, + pub active: usize, + pub waiting: usize, + pub rate_limited: usize, + pub total_tokens: u64, + pub active_tokens: u64, + pub avg_context_pct: f64, + pub max_context_pct: f64, + pub max_turn_count: u32, +} + +/// Compact quota state for one agent source. +#[derive(Debug, Clone, Serialize)] +pub struct QuotaStatusSummary { + pub source: String, + pub five_hour: Option, + pub seven_day: Option, +} + +/// Compact quota state for one reset window. +#[derive(Debug, Clone, Serialize)] +pub struct QuotaWindowStatus { + pub used_pct: f64, + pub remaining_pct: f64, + pub burn_pct_per_hour: Option, + pub eta_secs: Option, + pub resets_at: Option, + pub cap_before_reset: bool, + pub level: &'static str, +} + /// One chat line from the transcript tail (detail view only). #[derive(Debug, Clone, Serialize)] pub struct ChatMsgView { @@ -285,6 +342,133 @@ impl App { mcp_servers, } } + + /// Build a compact, privacy-preserving JSON summary for external clients. + /// + /// This is intended for dashboards/widgets that need health and quota state + /// without local paths, prompts, chat text, tool arguments, or session IDs. + pub fn to_status_summary(&self, interval_ms: u64) -> StatusSummary { + let now = SystemTime::now(); + let generated_at_ms = epoch_ms(now).unwrap_or(0); + let now_secs = generated_at_ms / 1000; + + let mut agents = Vec::new(); + for agent_cli in ["claude", "codex", "opencode"] { + let matching: Vec<_> = self + .sessions + .iter() + .filter(|s| s.agent_cli.eq_ignore_ascii_case(agent_cli)) + .collect(); + if matching.is_empty() { + continue; + } + + let sessions = matching.len(); + let active = matching.iter().filter(|s| s.status.is_active()).count(); + let waiting = matching + .iter() + .filter(|s| matches!(s.status, SessionStatus::Waiting)) + .count(); + let rate_limited = matching + .iter() + .filter(|s| matches!(s.status, SessionStatus::RateLimited)) + .count(); + let total_tokens = matching.iter().map(|s| s.total_tokens()).sum(); + let active_tokens = matching.iter().map(|s| s.active_tokens()).sum(); + let max_context_pct = matching + .iter() + .map(|s| s.context_percent) + .fold(0.0, f64::max); + let context_sum: f64 = matching.iter().map(|s| s.context_percent).sum(); + let avg_context_pct = if sessions > 0 { + context_sum / sessions as f64 + } else { + 0.0 + }; + let max_turn_count = matching.iter().map(|s| s.turn_count).max().unwrap_or(0); + + agents.push(AgentStatusSummary { + agent_cli, + sessions, + active, + waiting, + rate_limited, + total_tokens, + active_tokens, + avg_context_pct, + max_context_pct, + max_turn_count, + }); + } + + let quota = self + .rate_limits + .iter() + .map(|rl| QuotaStatusSummary { + source: rl.source.clone(), + five_hour: quota_window_status( + rl.five_hour_pct, + rl.five_hour_resets_at, + rl.five_hour_burn_pct_per_hour, + rl.five_hour_eta_secs, + now_secs, + ), + seven_day: quota_window_status( + rl.seven_day_pct, + rl.seven_day_resets_at, + rl.seven_day_burn_pct_per_hour, + rl.seven_day_eta_secs, + now_secs, + ), + }) + .collect(); + + StatusSummary { + generated_at_ms, + interval_ms, + token_rate: self.token_rates.back().copied().unwrap_or(0.0), + sessions_total: self.sessions.len(), + sessions_active: self + .sessions + .iter() + .filter(|s| s.status.is_active()) + .count(), + agents, + quota, + } + } +} + +fn quota_window_status( + used_pct: Option, + resets_at: Option, + burn_pct_per_hour: Option, + eta_secs: Option, + now_secs: u64, +) -> Option { + let used_pct = used_pct?; + let remaining_pct = (100.0 - used_pct).clamp(0.0, 100.0); + let cap_before_reset = match (eta_secs, resets_at.and_then(|ts| ts.checked_sub(now_secs))) { + (Some(eta), Some(reset_secs)) => eta < reset_secs, + _ => false, + }; + let level = if used_pct >= 95.0 || remaining_pct <= 5.0 || cap_before_reset { + "danger" + } else if used_pct >= 75.0 || eta_secs.is_some_and(|eta| eta <= 3600) { + "warning" + } else { + "ok" + }; + + Some(QuotaWindowStatus { + used_pct, + remaining_pct, + burn_pct_per_hour, + eta_secs, + resets_at, + cap_before_reset, + level, + }) } #[cfg(test)] @@ -381,12 +565,51 @@ mod tests { assert!(parsed["sessions"].is_array()); } + #[test] + fn status_summary_aggregates_without_session_details() { + let summary = demo_app().to_status_summary(2_000); + + assert_eq!(summary.interval_ms, 2_000); + assert!(summary.generated_at_ms > 0); + assert!(summary.sessions_total > 0); + assert!(!summary.agents.is_empty()); + assert!(summary + .agents + .iter() + .any(|agent| agent.agent_cli == "codex" && agent.sessions > 0)); + } + + #[test] + fn status_summary_json_omits_private_session_fields() { + let json = + serde_json::to_string(&demo_app().to_status_summary(2_000)).expect("status serializes"); + + for forbidden in [ + "cwd", + "session_id", + "project_name", + "summary", + "current_task", + "chat_messages", + "tool_calls", + "children", + "config_root", + ] { + assert!( + !json.contains(forbidden), + "status summary leaked `{forbidden}` in {json}" + ); + } + } + #[test] fn readme_documents_json_snapshot_privacy_surface() { let readme = include_str!("../README.md"); assert!(readme.contains("--json")); + assert!(readme.contains("--status-json")); assert!(readme.contains("JSON snapshot includes")); assert!(readme.contains("chat_messages")); assert!(readme.contains("summary")); + assert!(readme.contains("omits local paths")); } } diff --git a/src/ui/quota.rs b/src/ui/quota.rs index 72b19e0..ca1fc68 100644 --- a/src/ui/quota.rs +++ b/src/ui/quota.rs @@ -45,14 +45,15 @@ pub(crate) fn draw_quota_panel_active( let ticks_per_min = 30usize; let tokens_per_min: f64 = rates.iter().rev().take(ticks_per_min).sum(); - // Split into side-by-side columns: one per known source (CLAUDE | CODEX). - // Columns are always rendered so the panel layout stays stable even when a - // source has no data yet. - let num_sources = SOURCES.len() as u16; + // Split into side-by-side columns for active sources. When a workspace is + // Codex-only, give Codex the full quota panel instead of spending half the + // space on an empty Claude column. + let sources = active_quota_sources(app); + let num_sources = sources.len() as u16; let col_w = inner.width / num_sources; let content_h = inner.height.saturating_sub(1); // reserve last row for totals - for (i, source) in SOURCES.iter().enumerate() { + for (i, source) in sources.iter().enumerate() { let col_x = inner.x + (i as u16) * col_w; let this_w = if i as u16 == num_sources - 1 { inner.width - (i as u16) * col_w @@ -108,12 +109,13 @@ fn draw_source_column( let bar_w = col_w_usize.saturating_sub(10).clamp(2, 8); let Some(rl) = rl else { - let hint = if source.eq_ignore_ascii_case("claude") { - t("quota.abtop_setup") + let hint = if source.eq_ignore_ascii_case("codex") { + t("quota.codex_wait") + } else if source.eq_ignore_ascii_case("claude") { + t("quota.claude_wait") } else { - t("quota.run_codex") + t("quota.no_data") }; - let no_data = t("quota.no_data"); let lines = vec![ Line::from(Span::styled( format!(" {}", source.to_uppercase()), @@ -122,11 +124,11 @@ fn draw_source_column( .add_modifier(Modifier::BOLD), )), Line::from(Span::styled( - format!(" — {}", no_data), + format!(" — {}", t("quota.usage_unknown")), Style::default().fg(theme.inactive_fg), )), Line::from(Span::styled( - format!(" {}", hint), + format!(" {}", hint), Style::default().fg(theme.graph_text), )), ]; @@ -167,10 +169,13 @@ fn draw_source_column( if let Some(used_pct) = rl.five_hour_pct { let remaining = (100.0 - used_pct).clamp(0.0, 100.0); - let reset = if show_reset { - rl.five_hour_resets_at - .map(format_reset_time) - .unwrap_or_default() + let detail = if show_reset { + format_quota_detail( + rl.five_hour_resets_at, + rl.five_hour_burn_pct_per_hour, + rl.five_hour_eta_secs, + now, + ) } else { String::new() }; @@ -190,20 +195,23 @@ fn draw_source_column( // when there's nothing meaningful to show (stale source or the // cached reset moment is past), render it blank. lines.push(Line::from(Span::styled( - if reset.is_empty() { + if detail.is_empty() { String::new() } else { - format!(" {}", reset) + format!(" {}", detail) }, Style::default().fg(theme.graph_text), ))); } if let Some(used_pct) = rl.seven_day_pct { let remaining = (100.0 - used_pct).clamp(0.0, 100.0); - let reset = if show_reset { - rl.seven_day_resets_at - .map(format_reset_time) - .unwrap_or_default() + let detail = if show_reset { + format_quota_detail( + rl.seven_day_resets_at, + rl.seven_day_burn_pct_per_hour, + rl.seven_day_eta_secs, + now, + ) } else { String::new() }; @@ -223,10 +231,10 @@ fn draw_source_column( // when there's nothing meaningful to show (stale source or the // cached reset moment is past), render it blank. lines.push(Line::from(Span::styled( - if reset.is_empty() { + if detail.is_empty() { String::new() } else { - format!(" {}", reset) + format!(" {}", detail) }, Style::default().fg(theme.graph_text), ))); @@ -235,18 +243,29 @@ fn draw_source_column( f.render_widget(Paragraph::new(lines), area); } -/// Format a reset timestamp as a human countdown labeled "in X" so the -/// row reads as a time-until-reset. Returns an empty string when the -/// reset is already in the past — the actual next reset depends on the -/// window length which the caller doesn't track, and showing a "now" -/// sentinel was misleading on stale sources where the window had -/// already rolled over multiple times. Callers skip the row when this -/// returns empty. -pub(crate) fn format_reset_time(reset_ts: u64) -> String { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); +fn active_quota_sources(app: &App) -> Vec<&'static str> { + let active: Vec<&'static str> = SOURCES + .iter() + .copied() + .filter(|source| { + app.sessions + .iter() + .any(|s| s.agent_cli.eq_ignore_ascii_case(source)) + || app + .rate_limits + .iter() + .any(|r| r.source.eq_ignore_ascii_case(source)) + }) + .collect(); + + if active.is_empty() { + SOURCES.to_vec() + } else { + active + } +} + +fn format_reset_time_at(reset_ts: u64, now: u64) -> String { if reset_ts <= now { return String::new(); } @@ -266,3 +285,144 @@ pub(crate) fn format_reset_time(reset_ts: u64) -> String { format!("{} {}{} {}{}", prefix, d, t("time.d"), h, t("time.h")) } } + +fn format_quota_detail( + reset_ts: Option, + burn_pct_per_hour: Option, + eta_secs: Option, + now: u64, +) -> String { + let reset = reset_ts + .map(|ts| format_reset_time_at(ts, now)) + .unwrap_or_default(); + let Some(burn) = burn_pct_per_hour.filter(|burn| *burn >= 0.05) else { + return reset; + }; + let burn = format_burn_rate(burn); + + if let (Some(eta), Some(reset_secs)) = (eta_secs, reset_ts.and_then(|ts| ts.checked_sub(now))) { + if eta < reset_secs { + return format!("{} {} {}", t("quota.cap"), format_duration_short(eta), burn); + } + } + + if reset.is_empty() { + burn + } else { + format!("{} {}", reset, burn) + } +} + +fn format_burn_rate(burn_pct_per_hour: f64) -> String { + if burn_pct_per_hour >= 10.0 { + format!("+{:.0}%/h", burn_pct_per_hour) + } else { + format!("+{:.1}%/h", burn_pct_per_hour) + } +} + +fn format_duration_short(secs: u64) -> String { + if secs < 60 { + format!("{}{}", secs, t("time.s")) + } else if secs < 3600 { + format!("{}{}", secs / 60, t("time.m")) + } else if secs < 86400 { + let h = secs / 3600; + let m = (secs % 3600) / 60; + if m == 0 { + format!("{}{}", h, t("time.h")) + } else { + format!("{}{} {}{}", h, t("time.h"), m, t("time.m")) + } + } else { + let d = secs / 86400; + let h = (secs % 86400) / 3600; + if h == 0 { + format!("{}{}", d, t("time.d")) + } else { + format!("{}{} {}{}", d, t("time.d"), h, t("time.h")) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::PanelVisibility; + use crate::model::{AgentSession, SessionStatus}; + + fn test_app() -> App { + App::new_with_config(Theme::default(), &[], PanelVisibility::default()) + } + + fn test_session(agent_cli: &'static str) -> AgentSession { + AgentSession { + agent_cli, + pid: 1, + session_id: String::new(), + cwd: String::new(), + project_name: String::new(), + started_at: 0, + status: SessionStatus::Waiting, + model: String::new(), + effort: String::new(), + context_percent: 0.0, + total_input_tokens: 0, + total_output_tokens: 0, + total_cache_read: 0, + total_cache_create: 0, + turn_count: 0, + current_tasks: Vec::new(), + mem_mb: 0, + version: String::new(), + git_branch: String::new(), + git_added: 0, + git_modified: 0, + token_history: Vec::new(), + context_history: Vec::new(), + compaction_count: 0, + context_window: 0, + subagents: Vec::new(), + mem_file_count: 0, + mem_line_count: 0, + children: Vec::new(), + initial_prompt: String::new(), + first_assistant_text: String::new(), + chat_messages: Vec::new(), + tool_calls: Vec::new(), + pending_since_ms: 0, + thinking_since_ms: 0, + file_accesses: Vec::new(), + config_root: String::new(), + } + } + + #[test] + fn quota_sources_focus_codex_only_sessions() { + let mut app = test_app(); + app.sessions.push(test_session("codex")); + + assert_eq!(active_quota_sources(&app), vec!["codex"]); + } + + #[test] + fn quota_sources_default_when_runtime_is_empty() { + let app = test_app(); + + assert_eq!(active_quota_sources(&app), vec!["claude", "codex"]); + } + + #[test] + fn quota_detail_warns_when_cap_arrives_before_reset() { + let detail = format_quota_detail(Some(10_000), Some(50.0), Some(3_600), 1_000); + + assert_eq!(detail, "cap 1h +50%/h"); + } + + #[test] + fn quota_detail_keeps_reset_when_reset_arrives_first() { + let detail = format_quota_detail(Some(4_600), Some(10.0), Some(7_200), 1_000); + + assert_eq!(detail, "in 1h 0m +10%/h"); + } +} diff --git a/src/ui/sessions.rs b/src/ui/sessions.rs index e7a745d..118ebc4 100644 --- a/src/ui/sessions.rs +++ b/src/ui/sessions.rs @@ -162,8 +162,8 @@ pub(crate) fn draw_sessions_panel_active( let is_done = matches!(session.status, crate::model::SessionStatus::Done); let row_style = if selected { Style::default() - .bg(theme.selected_bg) - .fg(theme.selected_fg) + .bg(theme.div_line) + .fg(theme.main_fg) .add_modifier(Modifier::BOLD) } else if is_done { Style::default().fg(theme.inactive_fg) @@ -881,12 +881,23 @@ pub(crate) fn draw_sessions_panel_active( } else { format!(" · effort: {}", session.effort) }; + let avg_tokens = if session.turn_count > 0 { + format!( + " · avg {}/t", + fmt_tokens(session.total_tokens() / session.turn_count as u64) + ) + } else { + String::new() + }; footer_lines.push(Line::from(Span::styled( format!( - " {} · {} · {} turns{}", + " {} · {} · {} turns · active {} · total {}{}{}", session.version, session.elapsed_display(), session.turn_count, + fmt_tokens(session.active_tokens()), + fmt_tokens(session.total_tokens()), + avg_tokens, effort_part, ), Style::default().fg(theme.inactive_fg),