Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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

Expand Down
144 changes: 144 additions & 0 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<f64>,
eta_secs: Option<u64>,
}

/// Produce a terminal-safe fallback summary from a raw prompt.
fn sanitize_fallback(prompt: &str, max_len: usize) -> String {
prompt
Expand Down Expand Up @@ -93,6 +107,8 @@ pub struct App {
pub rate_limits: Vec<RateLimitInfo>,
/// 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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -1062,6 +1084,85 @@ fn save_summary_cache(summaries: &HashMap<String, String>) {
}
}

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<f64>,
updated_at: Option<u64>,
now: u64,
samples: &mut HashMap<(String, RateLimitWindow), RateLimitSpendSample>,
) -> (Option<f64>, Option<u64>) {
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.
Expand Down Expand Up @@ -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
Expand Down
19 changes: 18 additions & 1 deletion src/collector/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1243,7 +1243,9 @@ fn parse_codex_jsonl(path: &Path) -> Option<CodexJSONLResult> {
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") => {
Expand Down Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions src/collector/rate_limit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,5 +126,6 @@ fn read_rate_file(path: &Path, default_source: &str) -> Option<RateLimitInfo> {
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()
})
}
2 changes: 2 additions & 0 deletions src/demo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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()
},
];

Expand Down
22 changes: 22 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
8 changes: 8 additions & 0 deletions src/locale.rs
Original file line number Diff line number Diff line change
Expand Up @@ -202,8 +202,12 @@ static LOCALE_EN: LazyLock<std::collections::HashMap<&str, &str>> = 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");
Expand Down Expand Up @@ -447,8 +451,12 @@ static LOCALE_ZH: LazyLock<std::collections::HashMap<&str, &str>> = 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");
Expand Down
8 changes: 8 additions & 0 deletions src/model/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,18 @@ pub struct RateLimitInfo {
pub five_hour_pct: Option<f64>,
/// 5-hour window reset timestamp (epoch seconds)
pub five_hour_resets_at: Option<u64>,
/// Observed 5-hour usage burn rate, in percentage points per hour.
pub five_hour_burn_pct_per_hour: Option<f64>,
/// Estimated seconds until the 5-hour window reaches 100% at current burn.
pub five_hour_eta_secs: Option<u64>,
/// 7-day window usage percentage (0-100)
pub seven_day_pct: Option<f64>,
/// 7-day window reset timestamp (epoch seconds)
pub seven_day_resets_at: Option<u64>,
/// Observed 7-day usage burn rate, in percentage points per hour.
pub seven_day_burn_pct_per_hour: Option<f64>,
/// Estimated seconds until the 7-day window reaches 100% at current burn.
pub seven_day_eta_secs: Option<u64>,
/// When this data was last updated
pub updated_at: Option<u64>,
}
Expand Down
Loading