1use serde::Serialize;
16use std::collections::BTreeMap;
17use std::sync::{Mutex, OnceLock};
18
19#[derive(Clone, Debug, Serialize, PartialEq)]
20pub struct Window {
21 pub window: String,
23 #[serde(skip_serializing_if = "Option::is_none")]
24 pub used_percent: Option<f64>,
25 #[serde(skip_serializing_if = "Option::is_none")]
27 pub remaining_percent: Option<f64>,
28 #[serde(skip_serializing_if = "Option::is_none")]
30 pub raw: Option<RawReading>,
31 #[serde(skip_serializing_if = "Option::is_none")]
33 pub resets_at_ms: Option<u64>,
34 #[serde(skip_serializing_if = "Option::is_none")]
35 pub resets_text: Option<String>,
36}
37
38#[derive(Clone, Debug, Serialize, PartialEq)]
40pub struct RawReading {
41 pub text: String,
43 pub value: Option<f64>,
44 pub meaning: &'static str,
46}
47
48pub fn reading(line: &str) -> Option<RawReading> {
50 let value = pct(line)?;
51 let low = line.to_lowercase();
52 let after = &low[low.find('%').unwrap_or(0)..];
53 let meaning = if after.contains("left") || after.contains("remaining") {
54 "left"
55 } else if after.contains("used") {
56 "used"
57 } else {
58 "unknown"
59 };
60 let text: String = line.trim().trim_matches(|c: char| c == '│' || c == '|').trim().chars().filter(|c| !c.is_control()).take(160).collect();
61 Some(RawReading { text, value: Some(value), meaning })
62}
63
64pub fn window_from(name: &str, raw: RawReading, resets_at_ms: Option<u64>, resets_text: Option<String>) -> Window {
67 let used = match (raw.meaning, raw.value) {
68 ("used", Some(v)) => Some(v),
69 ("left", Some(v)) => Some(100.0 - v),
70 _ => None,
71 };
72 Window { window: name.into(), used_percent: used, remaining_percent: used.map(|u| 100.0 - u), raw: Some(raw), resets_at_ms, resets_text }
73}
74
75#[derive(Clone, Debug, Serialize, PartialEq)]
76pub struct ProviderLimits {
77 pub provider: String,
78 pub source: String,
80 #[serde(skip_serializing_if = "Option::is_none")]
81 pub tier: Option<String>,
82 pub windows: Vec<Window>,
83 pub observed_at_ms: u64,
84}
85
86fn pct(line: &str) -> Option<f64> {
87 let i = line.find('%')?;
88 let head = &line[..i];
89 let start = head.rfind(|c: char| !(c.is_ascii_digit() || c == '.')).map_or(0, |p| p + 1);
90 head[start..].parse().ok().filter(|v: &f64| (0.0..=100.0).contains(v))
91}
92
93pub fn parse_claude_screen(screen: &str, now_ms: u64) -> Option<ProviderLimits> {
95 let lines: Vec<&str> = screen.lines().map(str::trim).collect();
96 let mut windows = Vec::new();
97 let mut tier = None;
98 for (i, l) in lines.iter().enumerate() {
99 let low = l.to_lowercase();
100 for t in ["max", "pro", "team", "enterprise"] {
101 if tier.is_none() && (low.contains(&format!("claude {t}")) || low.contains(&format!("{t} plan"))) {
102 tier = Some(t.to_owned());
103 }
104 }
105 let name = if low.starts_with("current session") {
106 "5h"
107 } else if low.starts_with("current week") && (low.contains("all models") || !low.contains('(')) {
108 "7d"
109 } else {
110 continue;
111 };
112 if windows.iter().any(|w: &Window| w.window == name) {
113 continue;
114 }
115 let (mut raw, mut resets_text) = (None, None);
116 for next in lines.iter().skip(i + 1).take(3) {
117 let nl = next.to_lowercase();
118 if nl.starts_with("current ") {
119 break;
120 }
121 if raw.is_none() && (nl.contains("used") || nl.contains("left") || nl.contains("remaining")) {
122 raw = reading(next);
123 }
124 if let Some(p) = nl.find("resets") {
125 resets_text = Some(next[p..].chars().take(80).collect());
126 }
127 }
128 if let Some(raw) = raw {
129 windows.push(window_from(name, raw, None, resets_text));
130 }
131 }
132 (!windows.is_empty() || tier.is_some()).then(|| ProviderLimits { provider: "claude".into(), source: "screen".into(), tier, windows, observed_at_ms: now_ms })
133}
134
135static OBSERVED: OnceLock<Mutex<BTreeMap<(String, String), ProviderLimits>>> = OnceLock::new();
137static CHANGED: OnceLock<tokio::sync::Notify> = OnceLock::new();
139
140pub fn changed() -> &'static tokio::sync::Notify {
141 CHANGED.get_or_init(tokio::sync::Notify::new)
142}
143
144fn remember(new: ProviderLimits) {
147 let key = (new.provider.clone(), new.source.clone());
148 let mut map = OBSERVED.get_or_init(Default::default).lock().unwrap_or_else(|p| p.into_inner());
149 let before = map.remove(&key);
150 let merged = match before.clone() {
151 Some(mut old) => {
152 if new.tier.is_some() {
153 old.tier = new.tier.clone();
154 }
155 for w in new.windows {
156 old.windows.retain(|o| o.window != w.window);
157 old.windows.push(w);
158 }
159 old.observed_at_ms = new.observed_at_ms;
160 old
161 }
162 None => new,
163 };
164 let fresh = before.as_ref().is_none_or(|b| b.windows != merged.windows || b.tier != merged.tier);
166 map.insert(key, merged);
167 drop(map);
168 if fresh {
169 changed().notify_one();
170 }
171}
172
173pub fn fresh_5h_used(provider: &str, max_age_ms: u64) -> Option<f64> {
175 let map = OBSERVED.get()?.lock().unwrap_or_else(|p| p.into_inner());
176 let now = now_ms();
177 map.iter()
178 .filter(|((p, _), l)| p == provider && now.saturating_sub(l.observed_at_ms) <= max_age_ms)
179 .max_by_key(|(_, l)| l.observed_at_ms)
180 .and_then(|(_, l)| l.windows.iter().find(|w| w.window == "5h").and_then(|w| w.used_percent))
181}
182
183pub fn now_ms() -> u64 {
184 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_millis() as u64)
185}
186
187pub fn parse_codex_status(screen: &str, now_ms: u64) -> Option<ProviderLimits> {
191 let mut windows: Vec<Window> = Vec::new();
192 for l in screen.lines() {
193 let l = l.trim().trim_matches(|c: char| c == '│' || c == '|').trim();
194 let low = l.to_lowercase();
195 let Some(colon) = low.find("limit:") else { continue };
196 let label = &low[..colon];
197 let name = if label.contains("5h") || label.contains("5-hour") || label.contains("session") {
198 "5h"
199 } else if label.contains("week") {
200 "7d"
201 } else {
202 continue;
203 };
204 let Some(raw) = reading(l) else { continue };
205 let resets_text = low.find("resets").filter(|_| low.len() == l.len()).map(|i| l[i..].trim_end_matches(|c: char| c == ')' || c.is_whitespace()).chars().take(80).collect::<String>());
206 if windows.iter().all(|w| w.window != name) {
207 let resets_at_ms = resets_text.as_deref().and_then(|t| codex_reset_ms(t, chrono::Local::now().fixed_offset()));
208 windows.push(window_from(name, raw, resets_at_ms, resets_text));
209 }
210 }
211 (!windows.is_empty()).then(|| ProviderLimits { provider: "codex".into(), source: "screen".into(), tier: None, windows, observed_at_ms: now_ms })
212}
213
214pub fn codex_reset_ms(text: &str, now: chrono::DateTime<chrono::FixedOffset>) -> Option<u64> {
218 use chrono::{Datelike, NaiveDate, NaiveTime, TimeZone};
219 let rest = text.trim().strip_prefix("resets")?.trim();
220 let (hm, day) = match rest.split_once(" on ") {
221 Some((hm, day)) => (hm.trim(), Some(day.trim())),
222 None => (rest, None),
223 };
224 let time = NaiveTime::parse_from_str(hm, "%H:%M").ok()?;
225 let tz = *now.offset();
226 let at = match day {
227 None => {
228 let today = tz.from_local_datetime(&now.date_naive().and_time(time)).single()?;
229 if today > now { today } else { today + chrono::Duration::days(1) }
230 }
231 Some(day) => {
232 let mut it = day.split_whitespace();
233 let d: u32 = it.next()?.parse().ok()?;
234 let m = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"]
235 .iter()
236 .position(|x| it.clone().next().is_some_and(|w| w.to_lowercase().starts_with(x)))? as u32
237 + 1;
238 let mut at = tz.from_local_datetime(&NaiveDate::from_ymd_opt(now.year(), m, d)?.and_time(time)).single()?;
239 if at < now - chrono::Duration::days(1) {
240 at = tz.from_local_datetime(&NaiveDate::from_ymd_opt(now.year() + 1, m, d)?.and_time(time)).single()?;
241 }
242 at
243 }
244 };
245 u64::try_from(at.timestamp_millis()).ok()
246}
247
248pub fn parse_grok_usage(screen: &str, now_ms: u64) -> Option<ProviderLimits> {
252 let clean = |l: &str| l.trim().trim_matches(|c: char| c == '│' || c == '|').trim().to_owned();
253 let lines: Vec<String> = screen.lines().map(clean).collect();
254 let (mut windows, mut tier) = (Vec::<Window>::new(), None);
255 for (i, l) in lines.iter().enumerate() {
256 let low = l.to_lowercase();
257 let name = if low.starts_with("weekly limit") {
258 "7d"
259 } else if low.starts_with("5h limit") || low.starts_with("5-hour limit") || low.starts_with("session limit") {
260 "5h"
261 } else {
262 continue;
263 };
264 if windows.iter().any(|w| w.window == name) {
265 continue;
266 }
267 if tier.is_none() {
268 if let (Some(a), Some(b)) = (l.find('('), l.rfind(')')) {
269 let t: String = l[a + 1..b].trim().to_lowercase().split_whitespace().collect::<Vec<_>>().join("-").chars().take(32).collect();
271 tier = (!t.is_empty()).then_some(t);
272 }
273 }
274 let (mut raw, mut resets_text) = (None, None);
275 for next in lines.iter().skip(i).take(5) {
276 let nl = next.to_lowercase();
277 if raw.is_none() && nl.contains('%') {
278 raw = reading(next).map(|mut r| {
279 if r.meaning == "unknown" {
280 r.meaning = "used";
281 }
282 r
283 });
284 }
285 if resets_text.is_none() {
286 if let Some(p) = nl.find("resets") {
287 resets_text = Some(next[p..].chars().take(80).collect());
288 }
289 }
290 }
291 if let Some(raw) = raw {
292 windows.push(window_from(name, raw, None, resets_text));
293 }
294 }
295 (!windows.is_empty()).then(|| ProviderLimits { provider: "grok".into(), source: "screen".into(), tier, windows, observed_at_ms: now_ms })
296}
297
298pub fn parse_kimi_usage(screen: &str, now_ms: u64) -> Option<ProviderLimits> {
302 let clean = |l: &str| l.trim().trim_matches(|c: char| c == '│' || c == '|').trim().to_owned();
303 let lines: Vec<String> = screen.lines().map(clean).collect();
304 let mut windows = Vec::<Window>::new();
305 for (i, l) in lines.iter().enumerate() {
306 let low = l.to_lowercase();
307 let Some(at) = low.find("limit") else { continue };
308 let label = &low[..at];
309 let name = if label.contains("5h") || label.contains("5-hour") || label.contains("5 hour") {
310 "5h"
311 } else if label.contains("week") || label.contains("7d") {
312 "7d"
313 } else {
314 continue;
315 };
316 if windows.iter().any(|w| w.window == name) {
317 continue;
318 }
319 let near: Vec<&String> = lines.iter().skip(i).take(3).collect();
320 let Some(raw) = near.iter().find(|x| x.contains('%')).and_then(|x| reading(x)) else { continue };
321 let resets_text = near.iter().find_map(|x| x.to_lowercase().find("reset").map(|p| x[p..].trim_end_matches(|c: char| c == ')' || c.is_whitespace()).chars().take(80).collect::<String>()));
322 windows.push(window_from(name, raw, None, resets_text));
323 }
324 (!windows.is_empty()).then(|| ProviderLimits { provider: "kimi".into(), source: "screen".into(), tier: None, windows, observed_at_ms: now_ms })
325}
326
327pub fn says_not_signed_in(provider: &str, screen: &str) -> bool {
329 let low = screen.to_lowercase();
330 match provider {
331 "kimi" => low.contains("run /login or /provider") || low.contains("model: not set"),
332 "grok" => low.contains("approve in your browser to finish signing in") || low.contains("waiting for approval") || low.contains("sign in to grok"),
334 _ => false,
335 }
336}
337
338pub fn parse_screen(provider: &str, screen: &str, now_ms: u64) -> Option<ProviderLimits> {
340 match provider {
341 "claude" if screen.contains("Current session") || screen.contains("Current week") || screen.contains("Claude ") => parse_claude_screen(screen, now_ms),
342 "codex" if screen.contains("limit:") => parse_codex_status(screen, now_ms),
343 "grok" if screen.contains("Weekly limit") || screen.contains("Usage limit") => parse_grok_usage(screen, now_ms),
344 "kimi" if screen.to_lowercase().contains("limit") && screen.contains('%') => parse_kimi_usage(screen, now_ms),
345 _ => None,
346 }
347}
348
349pub fn observe_screen(provider: &str, screen: &str) {
351 observe_screen_as(provider, screen, "screen");
352}
353
354pub fn observe_screen_as(provider: &str, screen: &str, source: &str) -> bool {
356 match parse_screen(provider, screen, now_ms()) {
357 Some(mut new) => {
358 new.source = source.to_owned();
359 remember(new);
360 true
361 }
362 None => false,
363 }
364}
365
366pub fn collect() -> Vec<ProviderLimits> {
368 OBSERVED.get().map(|m| m.lock().unwrap_or_else(|p| p.into_inner()).values().cloned().collect()).unwrap_or_default()
369}
370
371#[cfg(test)]
372mod tests {
373 use super::*;
374
375 #[test]
376 fn raw_reading_keeps_meaning_and_normalizes_both_ways() {
377 let codex = "│ 5h limit: [██████░░] 30% used (resets 14:05) │\n│ Weekly limit: [█░░░░░░░] 95% left (resets 09:00 on 23 Oct) │";
378 let l = super::parse_codex_status(codex, 0).unwrap();
379 let w: Vec<_> = l.windows.iter().map(|w| (w.window.as_str(), w.used_percent, w.remaining_percent, w.raw.as_ref().map(|r| (r.value, r.meaning)))).collect();
380 assert_eq!(w, vec![("5h", Some(30.0), Some(70.0), Some((Some(30.0), "used"))), ("7d", Some(5.0), Some(95.0), Some((Some(95.0), "left")))]);
381 assert!(l.windows[1].raw.as_ref().unwrap().text.starts_with("Weekly limit:"));
382 let sample = include_str!("../tests/fixtures/codex-status-v0.163.0.txt");
383 let l = super::parse_codex_status(sample, 0).unwrap();
384 assert_eq!((l.windows[0].used_percent, l.windows[0].remaining_percent, l.windows[0].raw.as_ref().unwrap().meaning), (Some(100.0), Some(0.0), "left"));
385 let claude = "Current session\n███▌ 82% used\nResets 3:40pm (Asia/Yekaterinburg)\nCurrent week (all models)\n 84% left\n";
386 let l = super::parse_claude_screen(claude, 0).unwrap();
387 let w: Vec<_> = l.windows.iter().map(|w| (w.window.as_str(), w.used_percent, w.remaining_percent, w.raw.as_ref().unwrap().meaning)).collect();
388 assert_eq!(w, vec![("5h", Some(82.0), Some(18.0), "used"), ("7d", Some(16.0), Some(84.0), "left")]);
389 let bare = super::window_from("5h", super::reading("5h: 40%").unwrap(), None, None);
390 assert_eq!((bare.used_percent, bare.remaining_percent, bare.raw.unwrap().meaning), (None, None, "unknown"));
391 }
392
393 const USAGE: &str = " Settings: Status Config Usage\n\n Current session\n █████▌ 11% used\n Resets 3pm (Asia/Yekaterinburg)\n\n Current week (all models)\n ███████████ 22% used\n Resets Oct 23, 9am (Asia/Yekaterinburg)\n\n Current week (Opus)\n █ 2% used\n";
394
395 #[test]
396 fn claude_usage_screen() {
397 let l = parse_claude_screen(USAGE, 7).unwrap();
398 assert_eq!(l.windows.len(), 2);
399 assert_eq!(l.windows[0].window, "5h");
400 assert_eq!(l.windows[0].used_percent, Some(11.0));
401 assert_eq!(l.windows[0].resets_text.as_deref(), Some("Resets 3pm (Asia/Yekaterinburg)"));
402 assert_eq!(l.windows[1].window, "7d");
403 assert_eq!(l.windows[1].used_percent, Some(22.0));
404 }
405
406 #[test]
408 fn claude_usage_screen_live_capture() {
409 let screen = " Settings Status Config Usage Stats\n\n Session\n\n Total cost: $0.0000\n Usage: 0 input, 0 output, 0 cache read, 0 cache write\n\n Current session\n ▌ 1% used\n Resets 10:39am (Asia/Yekaterinburg)\n\n Current week (all models)\n ▌ 1% used\n Resets Oct 21, 11:59pm (Asia/Yekaterinburg)\n\n What's contributing to your limits usage?\n 31% of your usage came from the MCP server \"m4a\"\n";
410 let l = parse_claude_screen(screen, 0).unwrap();
411 assert_eq!(l.windows.iter().map(|w| (w.window.as_str(), w.used_percent)).collect::<Vec<_>>(), vec![("5h", Some(1.0)), ("7d", Some(1.0))]);
412 assert_eq!(l.windows[1].resets_text.as_deref(), Some("Resets Oct 21, 11:59pm (Asia/Yekaterinburg)"));
413 }
414
415 #[test]
416 fn claude_status_tier_and_noise() {
417 assert_eq!(parse_claude_screen("Login method: Claude Max Account", 1).unwrap().tier.as_deref(), Some("max"));
418 assert!(parse_claude_screen("> fix the tests\n● done", 1).is_none());
419 }
420
421 #[test]
422 fn codex_status_screen() {
423 let screen = "╭──────────────────────────────────────────────╮\n│ >_ OpenAI Codex (v0.160.0) │\n│ Account: someone@example.com (Plus) │\n│ 5h limit: [██████░░░░░░░░░░░░░░] 30% used (resets 14:05) │\n│ Weekly limit: [█░░░░░░░░░░░░░░░░░░░] 95% left (resets 09:00 on 23 Oct) │\n╰──────────────────────────────────────────────╯";
424 let l = parse_codex_status(screen, 5).unwrap();
425 assert_eq!(l.windows.iter().map(|w| (w.window.as_str(), w.used_percent)).collect::<Vec<_>>(), vec![("5h", Some(30.0)), ("7d", Some(5.0))]);
426 assert_eq!(l.windows[0].resets_text.as_deref(), Some("resets 14:05"));
427 assert_eq!(l.windows[1].resets_text.as_deref(), Some("resets 09:00 on 23 Oct"));
428 assert!(!format!("{l:?}").contains('@'));
430 assert!(parse_codex_status("› explain the rate limit: code in src/x.rs", 5).is_none());
431 }
432
433 #[test]
435 fn codex_status_fixture_v0_163() {
436 let screen = include_str!("../tests/fixtures/codex-status-v0.163.0.txt");
437 let l = parse_codex_status(screen, 5).unwrap();
438 assert_eq!(l.windows.iter().map(|w| (w.window.as_str(), w.used_percent)).collect::<Vec<_>>(), vec![("5h", Some(100.0)), ("7d", Some(16.0))]);
439 assert_eq!(l.windows[0].resets_text.as_deref(), Some("resets 12:11"));
440 assert_eq!(l.windows[1].resets_text.as_deref(), Some("resets 06:27 on 25 Oct"));
441 assert!(l.windows.iter().all(|w| w.resets_at_ms.is_some()));
442 let dbg = format!("{l:?}");
443 assert!(!dbg.contains('@') && !dbg.contains("00000000-"), "no account or session id kept");
444 }
445
446 const GROK_USAGE: &str = " E:\\D\\h\\w\\.hatchery-sessions\\grok-29 1.8K / 256K │ [Dashboard]\n ┌──────────────── [x] ─┐\n │ Context usage Usage limit Session info │\n │─────────────────────────│\n │ Weekly limit (SuperGrok Heavy) │\n │ │\n │ ██████████████████████░░░░░░░░ 74% │\n │ Resets: October 23, 18:49 │\n │ │\n │ Session usage: no model calls yet in this session. │\n";
448
449 #[test]
450 fn grok_usage_panel() {
451 let l = parse_screen("grok", GROK_USAGE, 3).unwrap();
452 assert_eq!(l.tier.as_deref(), Some("supergrok-heavy"));
453 let w = &l.windows[0];
454 assert_eq!((w.window.as_str(), w.used_percent, w.remaining_percent, w.raw.as_ref().unwrap().meaning), ("7d", Some(74.0), Some(26.0), "used"));
455 assert_eq!(w.resets_text.as_deref(), Some("Resets: October 23, 18:49"));
456 assert!(parse_screen("grok", "> what is my weekly limit?", 3).is_none());
457 }
458
459 #[test]
460 fn kimi_usage_screen() {
461 let screen = "│ Weekly limit ████████░░░░░░░░ 50% left (resets in 3d 4h) │\n│ 5h limit ████████████░░░░ 88% left │\n│ resets in 2h 10m │";
462 let l = parse_screen("kimi", screen, 3).unwrap();
463 let w: Vec<_> = l.windows.iter().map(|w| (w.window.as_str(), w.used_percent, w.raw.as_ref().unwrap().meaning)).collect();
464 assert_eq!(w, vec![("7d", Some(50.0), "left"), ("5h", Some(12.0), "left")]);
465 assert_eq!(l.windows[0].resets_text.as_deref(), Some("resets in 3d 4h"));
466 assert_eq!(l.windows[1].resets_text.as_deref(), Some("resets in 2h 10m"));
467 let welcome = "Welcome to Kimi Code!\nRun /login or /provider to get started.\nModel: not set, run /login or /provider\ncontext: 0%";
468 assert!(parse_screen("kimi", welcome, 3).is_none());
469 assert!(says_not_signed_in("kimi", welcome));
470 assert!(!says_not_signed_in("kimi", screen));
471 let grok_login = " ~/w/.hatchery-sessions/grok-1\n Approve in your browser to finish signing in.\n XXXX-XXXX\n Make sure your browser shows this code.\n Waiting for approval...\n ctrl+q quit";
473 assert!(says_not_signed_in("grok", grok_login) && !says_not_signed_in("grok", GROK_USAGE));
474 }
475
476 #[test]
477 fn codex_reset_is_node_local_time() {
478 use chrono::TimeZone;
479 let tz = chrono::FixedOffset::east_opt(5 * 3600).unwrap();
480 let now = tz.with_ymd_and_hms(2026, 10, 19, 11, 41, 0).unwrap();
481 let at = |y, mo, d, h, mi| u64::try_from(tz.with_ymd_and_hms(y, mo, d, h, mi, 0).unwrap().timestamp_millis()).unwrap();
482 assert_eq!(codex_reset_ms("resets 12:11", now), Some(at(2026, 10, 19, 12, 11)));
483 assert_eq!(codex_reset_ms("resets 09:00", now), Some(at(2026, 10, 20, 9, 0)), "already past today: tomorrow");
484 assert_eq!(codex_reset_ms("resets 06:27 on 25 Oct", now), Some(at(2026, 10, 25, 6, 27)));
485 assert_eq!(codex_reset_ms("resets 06:27 on 2 Jan", now), Some(at(2027, 1, 2, 6, 27)));
486 assert_eq!(codex_reset_ms("resets soon", now), None);
487 }
488
489 #[test]
490 fn screens_are_remembered_with_their_source() {
491 assert!(observe_screen_as("codex", " 5h limit: [█] 88% used (resets 10:00)", "heartbeat"));
492 assert!(!observe_screen_as("codex", "> hello", "heartbeat"));
493 assert!(!observe_screen_as("gemini", "5h limit: 10% used", "heartbeat"));
494 let got = collect().into_iter().find(|l| l.provider == "codex" && l.source == "heartbeat").unwrap();
495 assert_eq!(got.windows[0].used_percent, Some(88.0));
496 }
497}