From 198e804ad8b73248a1e70fe9287037b8480f9b8c Mon Sep 17 00:00:00 2001 From: fredshino Date: Wed, 1 Jul 2026 18:48:23 -0400 Subject: [PATCH] Add Fable 5 weekly usage meter for Claude Code The Claude usage endpoint (/api/oauth/usage) now returns a Fable-scoped weekly limit inside its `limits` array as a `weekly_scoped` entry with scope.model.display_name == "Fable". The poller previously only read the `five_hour` and `seven_day` buckets, so this data was ignored. - Parse the `limits` array into a new optional `UsageData.fable` section. - Render the Fable meter as an extra bar in the weekly (7d) row using a distinct violet accent, with a "Fable" heading in the 5h row above it. The widget is height-locked to the taskbar, so the bar extends horizontally and the widget auto-widens to fit it. - Add a checkable "Fable" toggle under the right-click Models submenu (shown only when Claude Code is enabled), persisted via a new `show_fable` setting (default on). It is exempt from the "at least one model enabled" constraint since it is an optional extra bar. - Surface Fable usage in the Claude tray tooltip when active. The meter appears automatically only when the account has a Fable-scoped limit. Adds unit tests for the new parsing. Co-Authored-By: Claude Opus 4.8 --- src/models.rs | 3 + src/poller.rs | 98 ++++++++++++++++++++++- src/window.rs | 212 +++++++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 294 insertions(+), 19 deletions(-) diff --git a/src/models.rs b/src/models.rs index da49ef1..971e3db 100644 --- a/src/models.rs +++ b/src/models.rs @@ -10,6 +10,9 @@ pub struct UsageSection { pub struct UsageData { pub session: UsageSection, pub weekly: UsageSection, + /// Weekly, model-scoped usage for Fable (Claude Code only). Present only + /// when the usage endpoint reports a Fable-scoped weekly limit. + pub fable: Option, } #[derive(Clone, Debug, Default)] diff --git a/src/poller.rs b/src/poller.rs index a29cd0d..30dffd7 100644 --- a/src/poller.rs +++ b/src/poller.rs @@ -47,6 +47,9 @@ pub type CredentialWatchSnapshot = Vec; struct UsageResponse { five_hour: Option, seven_day: Option, + /// Per-limit breakdown. The Fable weekly meter arrives here as a + /// `weekly_scoped` entry whose scope model display name is "Fable". + limits: Option>, } #[derive(Deserialize)] @@ -55,6 +58,24 @@ struct UsageBucket { resets_at: Option, } +#[derive(Deserialize)] +struct UsageLimit { + percent: Option, + resets_at: Option, + scope: Option, +} + +#[derive(Deserialize)] +struct UsageLimitScope { + model: Option, +} + +#[derive(Deserialize)] +struct UsageLimitModel { + #[serde(rename = "display_name")] + display_name: Option, +} + #[derive(Deserialize)] struct CodexAuthFile { tokens: Option, @@ -719,9 +740,30 @@ fn try_usage_endpoint(token: &str) -> Result, PollError> { data.weekly.resets_at = parse_iso8601(bucket.resets_at.as_deref()); } + data.fable = extract_fable_section(response.limits.as_deref()); + Ok(Some(data)) } +/// Locate the Fable weekly meter within the usage endpoint's `limits` array. +/// It is exposed as a `weekly_scoped` limit whose scope model display name is +/// "Fable". Returns None when the account has no Fable-scoped limit. +fn extract_fable_section(limits: Option<&[UsageLimit]>) -> Option { + let limit = limits?.iter().find(|limit| { + limit + .scope + .as_ref() + .and_then(|scope| scope.model.as_ref()) + .and_then(|model| model.display_name.as_deref()) + .is_some_and(|name| name.eq_ignore_ascii_case("fable")) + })?; + + Some(UsageSection { + percentage: limit.percent.unwrap_or(0.0), + resets_at: parse_iso8601(limit.resets_at.as_deref()), + }) +} + fn fetch_usage_via_messages(token: &str) -> Result { let agent = build_agent()?; @@ -909,7 +951,11 @@ fn fetch_antigravity_usage_from_endpoint( let session = fetch_antigravity_model_quota(base_url, token, project.as_deref())?; let weekly = UsageSection::default(); - Ok(UsageData { session, weekly }) + Ok(UsageData { + session, + weekly, + fable: None, + }) } fn fetch_antigravity_project(base_url: &str, token: &str) -> Result, PollError> { @@ -1611,6 +1657,7 @@ mod tests { resets_at: None, }, weekly: UsageSection::default(), + fable: None, } } @@ -1677,6 +1724,55 @@ mod tests { assert_eq!(data.codex.unwrap().session.percentage, 42.0); } + #[test] + fn extracts_fable_weekly_scoped_limit() { + let response: UsageResponse = serde_json::from_str( + r#"{ + "five_hour": { "utilization": 9.0, "resets_at": "2026-07-02T02:09:59.4+00:00" }, + "seven_day": { "utilization": 1.0, "resets_at": "2026-07-04T13:59:59.4+00:00" }, + "limits": [ + { + "kind": "session", "group": "session", "percent": 9, + "resets_at": "2026-07-02T02:09:59.4+00:00", "scope": null, "is_active": true + }, + { + "kind": "weekly_all", "group": "weekly", "percent": 1, + "resets_at": "2026-07-04T13:59:59.4+00:00", "scope": null, "is_active": false + }, + { + "kind": "weekly_scoped", "group": "weekly", "percent": 2, + "resets_at": "2026-07-04T14:00:00.4+00:00", + "scope": { "model": { "id": null, "display_name": "Fable" }, "surface": null }, + "is_active": false + } + ] + }"#, + ) + .expect("usage response should deserialize"); + + let fable = + extract_fable_section(response.limits.as_deref()).expect("Fable limit should be found"); + assert_eq!(fable.percentage, 2.0); + assert!(fable.resets_at.is_some()); + } + + #[test] + fn no_fable_section_when_absent() { + let response: UsageResponse = serde_json::from_str( + r#"{ + "five_hour": { "utilization": 9.0, "resets_at": null }, + "seven_day": { "utilization": 1.0, "resets_at": null }, + "limits": [ + { "kind": "session", "group": "session", "percent": 9, "resets_at": null, "scope": null } + ] + }"#, + ) + .expect("usage response should deserialize"); + + assert!(extract_fable_section(response.limits.as_deref()).is_none()); + assert!(extract_fable_section(None).is_none()); + } + #[test] fn antigravity_summary_prefers_gemini_group() { let response: AntigravityQuotaSummaryResponse = serde_json::from_str( diff --git a/src/window.rs b/src/window.rs index f6d261e..b2b82ef 100644 --- a/src/window.rs +++ b/src/window.rs @@ -67,9 +67,13 @@ struct AppState { antigravity_session_text: String, antigravity_weekly_percent: f64, antigravity_weekly_text: String, + fable_percent: f64, + fable_text: String, + fable_active: bool, show_claude_code: bool, show_codex: bool, show_antigravity: bool, + show_fable: bool, data: Option, @@ -131,6 +135,7 @@ const IDM_LANG_PORTUGUESE_BRAZIL: u16 = 50; const IDM_MODEL_CLAUDE_CODE: u16 = 60; const IDM_MODEL_CODEX: u16 = 61; const IDM_MODEL_ANTIGRAVITY: u16 = 62; +const IDM_MODEL_FABLE: u16 = 63; const WM_DPICHANGED_MSG: u32 = 0x02E0; const WM_APP_UPDATE_CHECK_COMPLETE: u32 = WM_APP + 2; @@ -316,6 +321,8 @@ struct SettingsFile { show_codex: bool, #[serde(default = "default_show_antigravity")] show_antigravity: bool, + #[serde(default = "default_show_fable")] + show_fable: bool, } impl Default for SettingsFile { @@ -330,6 +337,7 @@ impl Default for SettingsFile { show_claude_code: true, show_codex: false, show_antigravity: false, + show_fable: true, } } } @@ -354,6 +362,10 @@ fn default_show_antigravity() -> bool { false } +fn default_show_fable() -> bool { + true +} + fn load_settings() -> SettingsFile { let content = match std::fs::read_to_string(settings_path()) { Ok(c) => c, @@ -391,6 +403,7 @@ fn save_state_settings() { show_claude_code: s.show_claude_code, show_codex: s.show_codex, show_antigravity: s.show_antigravity, + show_fable: s.show_fable, }); } } @@ -401,15 +414,21 @@ fn tray_icon_data_from_state() -> Vec { Some(s) if s.last_poll_ok => { let mut icons = Vec::new(); if s.show_claude_code { + let claude_tooltip = format!( + "{} 5h: {} | 7d: {}", + s.language.strings().claude_code_model, + s.session_text, + s.weekly_text + ); + let claude_tooltip = if s.fable_active && s.show_fable { + format!("{claude_tooltip} | Fable 7d: {}", s.fable_text) + } else { + claude_tooltip + }; icons.push(tray_icon::TrayIconData { kind: tray_icon::TrayIconKind::Claude, percent: Some(s.session_percent), - tooltip: format!( - "{} 5h: {} | 7d: {}", - s.language.strings().claude_code_model, - s.session_text, - s.weekly_text - ), + tooltip: claude_tooltip, }); } if s.show_codex { @@ -647,6 +666,9 @@ fn refresh_usage_texts(state: &mut AppState) { if let Some(claude_code) = data.claude_code.as_ref() { state.session_text = poller::format_line(&claude_code.session, strings); state.weekly_text = poller::format_line(&claude_code.weekly, strings); + if let Some(fable) = claude_code.fable.as_ref() { + state.fable_text = poller::format_line(fable, strings); + } } else if state.show_claude_code { state.session_text = "!".to_string(); state.weekly_text = "!".to_string(); @@ -1091,38 +1113,55 @@ fn row_bar_segment_count(active_models: i32) -> i32 { } } -fn total_widget_width_for(active_models: i32) -> i32 { +fn total_widget_width_for(active_models: i32, fable_active: bool) -> i32 { let bar_segments = row_bar_segment_count(active_models); let model_width = (sc(SEGMENT_W) + sc(SEGMENT_GAP)) * bar_segments - sc(SEGMENT_GAP) + sc(BAR_RIGHT_MARGIN) + sc(TEXT_WIDTH); + // The Fable meter adds one extra bar to the weekly row only; reserve room + // for it so the widget is wide enough for its widest row. + let fable_extra = if fable_active { + model_width + sc(MODEL_RIGHT_MARGIN) + } else { + 0 + }; + sc(LEFT_DIVIDER_W) + sc(DIVIDER_RIGHT_MARGIN) + sc(LABEL_WIDTH) + sc(LABEL_RIGHT_MARGIN) + model_width * active_models + sc(MODEL_RIGHT_MARGIN) * (active_models - 1) + + fable_extra + sc(RIGHT_MARGIN) } fn total_widget_width_for_state(state: &AppState) -> i32 { - total_widget_width_for(active_model_count( - state.show_claude_code, - state.show_codex, - state.show_antigravity, - )) + total_widget_width_for( + active_model_count( + state.show_claude_code, + state.show_codex, + state.show_antigravity, + ), + state.fable_active && state.show_claude_code && state.show_fable, + ) } fn total_widget_width() -> i32 { - let active_models = { + let (active_models, fable_active) = { let state = lock_state(); state .as_ref() - .map(|s| active_model_count(s.show_claude_code, s.show_codex, s.show_antigravity)) - .unwrap_or(1) + .map(|s| { + ( + active_model_count(s.show_claude_code, s.show_codex, s.show_antigravity), + s.fable_active && s.show_claude_code && s.show_fable, + ) + }) + .unwrap_or((1, false)) }; - total_widget_width_for(active_models) + total_widget_width_for(active_models, fable_active) } fn claude_accent_color() -> Color { @@ -1141,6 +1180,21 @@ fn antigravity_accent_color() -> Color { Color::from_hex("#4285F4") } +/// Distinct accent for the Fable weekly meter — a muted violet that reads on +/// both the light and dark taskbar backgrounds and is clearly separable from +/// the Claude (terracotta) and Antigravity (blue) accents. +fn fable_accent_color() -> Color { + Color::from_hex("#7C6BB0") +} + +fn fable_usage_text_color(is_dark: bool) -> Color { + if is_dark { + Color::from_hex("#B5A8E0") + } else { + Color::from_hex("#5B4B8A") + } +} + fn claude_usage_text_color(is_dark: bool) -> Color { if is_dark { Color::from_hex("#F09A7A") @@ -1252,7 +1306,7 @@ pub fn run() { WS_POPUP, 0, 0, - total_widget_width_for(initial_model_count), + total_widget_width_for(initial_model_count, false), sc(WIDGET_HEIGHT), HWND::default(), HMENU::default(), @@ -1307,9 +1361,13 @@ pub fn run() { antigravity_session_text: "--".to_string(), antigravity_weekly_percent: 0.0, antigravity_weekly_text: "--".to_string(), + fable_percent: 0.0, + fable_text: "--".to_string(), + fable_active: false, show_claude_code: settings.show_claude_code, show_codex: settings.show_codex, show_antigravity: settings.show_antigravity, + show_fable: settings.show_fable, data: None, poll_interval_ms: settings.poll_interval_ms, retry_count: 0, @@ -1432,6 +1490,9 @@ fn render_layered() { antigravity_session_text, antigravity_weekly_pct, antigravity_weekly_text, + fable_pct, + fable_text, + fable_active, show_claude_code, show_codex, show_antigravity, @@ -1455,6 +1516,9 @@ fn render_layered() { s.antigravity_session_text.clone(), s.antigravity_weekly_percent, s.antigravity_weekly_text.clone(), + s.fable_percent, + s.fable_text.clone(), + s.fable_active && s.show_fable, s.show_claude_code, s.show_codex, s.show_antigravity, @@ -1479,6 +1543,7 @@ fn render_layered() { let accent = claude_accent_color(); let codex_accent = codex_accent_color(is_dark); let antigravity_accent = antigravity_accent_color(); + let fable_accent = fable_accent_color(); let track = if is_dark { Color::from_hex("#444444") } else { @@ -1550,11 +1615,15 @@ fn render_layered() { &antigravity_session_text, antigravity_weekly_pct, &antigravity_weekly_text, + fable_pct, + &fable_text, + fable_active, show_claude_code, show_codex, show_antigravity, &codex_accent, &antigravity_accent, + &fable_accent, ); // Background pixels → alpha 1 (nearly invisible but still hittable for right-click). @@ -1626,11 +1695,15 @@ fn paint_content( antigravity_session_text: &str, antigravity_weekly_pct: f64, antigravity_weekly_text: &str, + fable_pct: f64, + fable_text: &str, + fable_active: bool, show_claude_code: bool, show_codex: bool, show_antigravity: bool, codex_accent: &Color, antigravity_accent: &Color, + fable_accent: &Color, ) { unsafe { let client_rect = RECT { @@ -1707,6 +1780,15 @@ fn paint_content( ); let old_font = SelectObject(hdc, font); + // The Fable meter's bar lives in the weekly row; the session row shows a + // "Fable" heading above it so the extra bar is clearly labelled. + let fable_bar = if fable_active { + Some((fable_pct, fable_text, fable_accent)) + } else { + None + }; + let fable_label = if fable_active { Some("Fable") } else { None }; + draw_row( hdc, content_x, @@ -1727,6 +1809,9 @@ fn paint_content( codex_accent, antigravity_accent, track, + // Session row has no Fable bar, only the heading label. + None, + fable_label, ); draw_row( hdc, @@ -1748,6 +1833,9 @@ fn paint_content( codex_accent, antigravity_accent, track, + fable_bar, + // Weekly row draws the Fable bar itself, no separate heading. + None, ); SelectObject(hdc, old_font); @@ -1772,9 +1860,16 @@ fn do_poll(send_hwnd: SendHwnd) { if let Some(claude_code) = data.claude_code.as_ref() { s.session_percent = claude_code.session.percentage; s.weekly_percent = claude_code.weekly.percentage; + if let Some(fable) = claude_code.fable.as_ref() { + s.fable_percent = fable.percentage; + s.fable_active = true; + } else { + s.fable_active = false; + } } else if s.show_claude_code { s.session_percent = 0.0; s.weekly_percent = 0.0; + s.fable_active = false; } if let Some(codex) = data.codex.as_ref() { s.codex_session_percent = codex.session.percentage; @@ -2594,7 +2689,10 @@ unsafe extern "system" fn wnd_proc( // Reset the poll timer with the new interval SetTimer(hwnd, TIMER_POLL, new_interval, None); } - IDM_MODEL_CLAUDE_CODE | IDM_MODEL_CODEX | IDM_MODEL_ANTIGRAVITY => { + IDM_MODEL_CLAUDE_CODE + | IDM_MODEL_CODEX + | IDM_MODEL_ANTIGRAVITY + | IDM_MODEL_FABLE => { { let mut state = lock_state(); if let Some(s) = state.as_mut() { @@ -2614,6 +2712,12 @@ unsafe extern "system" fn wnd_proc( s.show_antigravity = !s.show_antigravity; } } + // The Fable meter is an optional extra bar; it can + // be toggled freely without the "at least one model" + // constraint that applies to the providers above. + IDM_MODEL_FABLE => { + s.show_fable = !s.show_fable; + } _ => {} } s.session_text = "...".to_string(); @@ -2715,6 +2819,7 @@ fn show_context_menu(hwnd: HWND) { show_claude_code, show_codex, show_antigravity, + show_fable, ) = { let state = lock_state(); match state.as_ref() { @@ -2729,6 +2834,7 @@ fn show_context_menu(hwnd: HWND) { s.show_claude_code, s.show_codex, s.show_antigravity, + s.show_fable, ), None => ( POLL_15_MIN, @@ -2741,6 +2847,7 @@ fn show_context_menu(hwnd: HWND) { true, false, false, + true, ), } }; @@ -2827,6 +2934,23 @@ fn show_context_menu(hwnd: HWND) { PCWSTR::from_raw(antigravity_model.as_ptr()), ); + // Fable is a Claude Code sub-meter, so its toggle is only meaningful + // (and only shown) when Claude Code itself is enabled. + if show_claude_code { + let fable_model = native_interop::wide_str("Fable"); + let fable_flags = if show_fable { + MF_CHECKED + } else { + MENU_ITEM_FLAGS(0) + }; + let _ = AppendMenuW( + models_menu, + fable_flags, + IDM_MODEL_FABLE as usize, + PCWSTR::from_raw(fable_model.as_ptr()), + ); + } + let models_label = native_interop::wide_str(strings.models); let _ = AppendMenuW( menu, @@ -2984,6 +3108,9 @@ fn paint(hdc: HDC, hwnd: HWND) { antigravity_session_text, antigravity_weekly_pct, antigravity_weekly_text, + fable_pct, + fable_text, + fable_active, show_claude_code, show_codex, show_antigravity, @@ -3005,6 +3132,9 @@ fn paint(hdc: HDC, hwnd: HWND) { s.antigravity_session_text.clone(), s.antigravity_weekly_percent, s.antigravity_weekly_text.clone(), + s.fable_percent, + s.fable_text.clone(), + s.fable_active && s.show_fable, s.show_claude_code, s.show_codex, s.show_antigravity, @@ -3016,6 +3146,7 @@ fn paint(hdc: HDC, hwnd: HWND) { let accent = claude_accent_color(); let codex_accent = codex_accent_color(is_dark); let antigravity_accent = antigravity_accent_color(); + let fable_accent = fable_accent_color(); let track = if is_dark { Color::from_hex("#444444") } else { @@ -3068,11 +3199,15 @@ fn paint(hdc: HDC, hwnd: HWND) { &antigravity_session_text, antigravity_weekly_pct, &antigravity_weekly_text, + fable_pct, + &fable_text, + fable_active, show_claude_code, show_codex, show_antigravity, &codex_accent, &antigravity_accent, + &fable_accent, ); let _ = BitBlt(hdc, 0, 0, width, height, mem_dc, 0, 0, SRCCOPY); @@ -3103,6 +3238,8 @@ fn draw_row( codex_accent: &Color, antigravity_accent: &Color, track: &Color, + fable: Option<(f64, &str, &Color)>, + fable_label: Option<&str>, ) { let seg_h = sc(SEGMENT_H); let active_models = active_model_count(show_claude_code, show_codex, show_antigravity); @@ -3123,6 +3260,9 @@ fn draw_row( } else { *text_color }; + // The Fable value is always tinted so it is distinguishable from the + // adjacent weekly-all bar even when Claude Code is the only provider. + let fable_value_color = fable_usage_text_color(is_dark); unsafe { let _ = SetTextColor(hdc, COLORREF(text_color.to_colorref())); @@ -3181,6 +3321,42 @@ fn draw_row( track, &antigravity_value_color, ); + model_x += model_usage_width(segment_count) + sc(MODEL_RIGHT_MARGIN); + } + // The Fable weekly meter is appended after all provider columns so the + // shared session/weekly columns stay aligned across both rows. + if show_claude_code { + if let Some((fable_percent, fable_text, fable_accent)) = fable { + draw_usage_bar( + hdc, + model_x, + y, + segment_count, + fable_percent, + fable_text, + fable_accent, + track, + &fable_value_color, + ); + } + // Heading above the Fable bar (drawn in the session row), tinted to + // match the bar so it is clearly the Fable column. + if let Some(label) = fable_label { + let _ = SetTextColor(hdc, COLORREF(fable_value_color.to_colorref())); + let mut label_wide: Vec = label.encode_utf16().collect(); + let mut label_rect = RECT { + left: model_x, + top: y, + right: model_x + model_usage_width(segment_count), + bottom: y + seg_h, + }; + let _ = DrawTextW( + hdc, + &mut label_wide, + &mut label_rect, + DT_LEFT | DT_VCENTER | DT_SINGLELINE, + ); + } } } }