From fb5d6b164085dd5ba3b81c7a1e289c80aac0ac7a Mon Sep 17 00:00:00 2001 From: Caique Reis Date: Tue, 27 Oct 2026 21:48:56 -0300 Subject: [PATCH] feat(opencode): add configurable long window (weekly/monthly) --- src/app_settings.rs | 32 +++++ src/localization/locales/de.toml | 3 + src/localization/locales/en.toml | 3 + src/localization/locales/es.toml | 3 + src/localization/locales/fr.toml | 3 + src/localization/locales/ja.toml | 3 + src/localization/locales/ko.toml | 3 + src/localization/locales/nl.toml | 3 + src/localization/locales/pl.toml | 3 + src/localization/locales/pt-BR.toml | 3 + src/localization/locales/ru.toml | 3 + src/localization/locales/th.toml | 3 + src/localization/locales/tr.toml | 3 + src/localization/locales/zh-CN.toml | 3 + src/localization/locales/zh-TW.toml | 3 + src/models.rs | 38 +++++ src/poller/opencode.rs | 4 + src/studio_app/studio_core.rs | 1 + src/studio_app/studio_helper.rs | 2 +- src/studio_app/studio_settings.rs | 36 +++++ src/theme_engine.rs | 44 ++++++ src/theme_engine/tests.rs | 76 +++++++++- src/theme_engine/theme_expression.rs | 2 +- src/themes/classic-usage-widget.json | 126 ++++++++++++++--- src/themes/classic-vertical.json | 92 +++++++++++-- src/themes/compact-fluent-quad.json | 168 ++++++++++++++++++++--- src/window.rs | 4 + src/window/placement_regression_tests.rs | 1 + 28 files changed, 620 insertions(+), 48 deletions(-) diff --git a/src/app_settings.rs b/src/app_settings.rs index d2e1d7f1..f170f4f8 100644 --- a/src/app_settings.rs +++ b/src/app_settings.rs @@ -23,6 +23,17 @@ pub const POLL_1_HOUR: u32 = POLL_1_HOUR_SECONDS * 1_000; // SetTimer clamps longer intervals to USER_TIMER_MAXIMUM (i32::MAX ms). pub const MAX_POLL_MINUTES: u32 = i32::MAX as u32 / POLL_1_MIN; +/// Which long window the OpenCode widget row shows. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum OpenCodeLongWindow { + /// Always show the 7-day window. + Weekly, + /// Always show the 30-day window. + #[default] + Monthly, +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct SettingsFile { #[serde(default)] @@ -66,6 +77,9 @@ pub struct SettingsFile { show_grok: bool, #[serde(default)] show_copilot: bool, + /// Which long window the OpenCode widget row shows. + #[serde(default)] + pub opencode_long_window: OpenCodeLongWindow, #[serde(default = "default_true")] pub custom_theme_enabled: bool, /// Show what is left of each allowance instead of what has been spent, so @@ -137,6 +151,7 @@ impl Default for SettingsFile { show_cursor: false, show_grok: false, show_copilot: false, + opencode_long_window: OpenCodeLongWindow::Monthly, custom_theme_enabled: true, usage_countdown: false, active_theme_path: None, @@ -504,6 +519,23 @@ fn now_unix() -> u64 { mod tests { use super::*; + #[test] + fn opencode_long_window_defaults_to_monthly_and_round_trips() { + // A fresh settings file (missing the key) deserialises to Monthly. + let settings = decode_settings("{}").unwrap(); + assert_eq!(settings.opencode_long_window, OpenCodeLongWindow::Monthly); + + // All variants survive a round-trip. + for variant in [OpenCodeLongWindow::Weekly, OpenCodeLongWindow::Monthly] { + let settings = SettingsFile { + opencode_long_window: variant, + ..Default::default() + }; + let decoded = decode_settings(&settings_json(&settings).to_string()).unwrap(); + assert_eq!(decoded.opencode_long_window, variant); + } + } + #[test] fn taskbar_lock_is_opt_in_and_survives_settings_round_trip() { assert!(!decode_settings("{}").unwrap().lock_taskbar); diff --git a/src/localization/locales/de.toml b/src/localization/locales/de.toml index df432b1e..ccdba3ad 100644 --- a/src/localization/locales/de.toml +++ b/src/localization/locales/de.toml @@ -128,9 +128,11 @@ copilot_window_title = "Copilot-Nutzungsmonitor" "Close Builder" = "Baukasten schließen" "Codex" = "Codex" "Collect usage from Anthropic" = "Nutzungsdaten von Anthropic abrufen" +"Collect usage from Cursor" = "Nutzungsdaten von Cursor abrufen" "Collect usage from GitHub" = "Nutzungsdaten von GitHub abrufen" "Collect usage from Google" = "Nutzungsdaten von Google abrufen" "Collect usage from OpenAI" = "Nutzungsdaten von OpenAI abrufen" +"Collect usage from OpenCode Go" = "Nutzungsdaten von OpenCode Go abrufen" "Collect usage from xAI" = "Nutzungsdaten von xAI abrufen" "Colour" = "Farbe" "Column" = "Column" @@ -457,3 +459,4 @@ copilot_window_title = "Copilot-Nutzungsmonitor" "Lock in taskbar" = "Lock in taskbar" "Keep the widget docked; never float automatically" = "Keep the widget docked; never float automatically" "Locked" = "Locked" +"Long period" = "Langer Zeitraum" diff --git a/src/localization/locales/en.toml b/src/localization/locales/en.toml index 2273418e..46989166 100644 --- a/src/localization/locales/en.toml +++ b/src/localization/locales/en.toml @@ -128,9 +128,11 @@ copilot_window_title = "Copilot Usage Monitor" "Close Builder" = "Close Builder" "Codex" = "Codex" "Collect usage from Anthropic" = "Collect usage from Anthropic" +"Collect usage from Cursor" = "Collect usage from Cursor" "Collect usage from GitHub" = "Collect usage from GitHub" "Collect usage from Google" = "Collect usage from Google" "Collect usage from OpenAI" = "Collect usage from OpenAI" +"Collect usage from OpenCode Go" = "Collect usage from OpenCode Go" "Collect usage from xAI" = "Collect usage from xAI" "Colour" = "Colour" "Column" = "Column" @@ -457,3 +459,4 @@ copilot_window_title = "Copilot Usage Monitor" "Lock in taskbar" = "Lock in taskbar" "Keep the widget docked; never float automatically" = "Keep the widget docked; never float automatically" "Locked" = "Locked" +"Long period" = "Long period" diff --git a/src/localization/locales/es.toml b/src/localization/locales/es.toml index 41ac2ed6..1bc7e449 100644 --- a/src/localization/locales/es.toml +++ b/src/localization/locales/es.toml @@ -128,9 +128,11 @@ copilot_window_title = "Monitor de uso de Copilot" "Close Builder" = "Cerrar Constructor" "Codex" = "Codex" "Collect usage from Anthropic" = "Recopilar uso de Anthropic" +"Collect usage from Cursor" = "Recopilar uso de Cursor" "Collect usage from GitHub" = "Recopilar uso de GitHub" "Collect usage from Google" = "Recopilar uso de Google" "Collect usage from OpenAI" = "Recopilar uso de OpenAI" +"Collect usage from OpenCode Go" = "Recopilar uso de OpenCode Go" "Collect usage from xAI" = "Recopilar uso de xAI" "Colour" = "Color" "Column" = "Column" @@ -457,3 +459,4 @@ copilot_window_title = "Monitor de uso de Copilot" "Lock in taskbar" = "Fijar en la barra" "Keep the widget docked; never float automatically" = "Mantener el widget acoplado; no flotar automáticamente" "Locked" = "Fijado" +"Long period" = "Período largo" diff --git a/src/localization/locales/fr.toml b/src/localization/locales/fr.toml index 34545dcf..190b939a 100644 --- a/src/localization/locales/fr.toml +++ b/src/localization/locales/fr.toml @@ -128,9 +128,11 @@ copilot_window_title = "Moniteur d'utilisation Copilot" "Close Builder" = "Fermer le Constructeur" "Codex" = "Codex" "Collect usage from Anthropic" = "Collecter l'utilisation depuis Anthropic" +"Collect usage from Cursor" = "Collecter l'utilisation depuis Cursor" "Collect usage from GitHub" = "Collecter l'utilisation depuis GitHub" "Collect usage from Google" = "Collecter l'utilisation depuis Google" "Collect usage from OpenAI" = "Collecter l'utilisation depuis OpenAI" +"Collect usage from OpenCode Go" = "Collecter l'utilisation depuis OpenCode Go" "Collect usage from xAI" = "Collecter l'utilisation depuis xAI" "Colour" = "Couleur" "Column" = "Column" @@ -457,3 +459,4 @@ copilot_window_title = "Moniteur d'utilisation Copilot" "Lock in taskbar" = "Lock in taskbar" "Keep the widget docked; never float automatically" = "Keep the widget docked; never float automatically" "Locked" = "Locked" +"Long period" = "Période longue" diff --git a/src/localization/locales/ja.toml b/src/localization/locales/ja.toml index 6d6de298..a7f448ce 100644 --- a/src/localization/locales/ja.toml +++ b/src/localization/locales/ja.toml @@ -130,9 +130,11 @@ copilot_window_title = "Copilot 使用量モニター" "Close Builder" = "ビルダーを閉じる" "Codex" = "Codex" "Collect usage from Anthropic" = "Anthropic から使用量を取得" +"Collect usage from Cursor" = "Cursor から使用量を取得" "Collect usage from GitHub" = "GitHub から使用量を取得" "Collect usage from Google" = "Google から使用量を取得" "Collect usage from OpenAI" = "OpenAI から使用量を取得" +"Collect usage from OpenCode Go" = "OpenCode Go から使用量を取得" "Collect usage from xAI" = "xAI から使用量を取得" "Colour" = "色" "Column" = "Column" @@ -459,3 +461,4 @@ copilot_window_title = "Copilot 使用量モニター" "Lock in taskbar" = "Lock in taskbar" "Keep the widget docked; never float automatically" = "Keep the widget docked; never float automatically" "Locked" = "Locked" +"Long period" = "長期間" diff --git a/src/localization/locales/ko.toml b/src/localization/locales/ko.toml index 6d73478c..80c388ee 100644 --- a/src/localization/locales/ko.toml +++ b/src/localization/locales/ko.toml @@ -130,9 +130,11 @@ copilot_window_title = "Copilot 사용량 모니터" "Close Builder" = "빌더 닫기" "Codex" = "Codex" "Collect usage from Anthropic" = "Anthropic에서 사용량 수집" +"Collect usage from Cursor" = "Cursor에서 사용량 수집" "Collect usage from GitHub" = "GitHub에서 사용량 수집" "Collect usage from Google" = "Google에서 사용량 수집" "Collect usage from OpenAI" = "OpenAI에서 사용량 수집" +"Collect usage from OpenCode Go" = "OpenCode Go에서 사용량 수집" "Collect usage from xAI" = "xAI에서 사용량 수집" "Colour" = "색상" "Column" = "Column" @@ -459,3 +461,4 @@ copilot_window_title = "Copilot 사용량 모니터" "Lock in taskbar" = "Lock in taskbar" "Keep the widget docked; never float automatically" = "Keep the widget docked; never float automatically" "Locked" = "Locked" +"Long period" = "장기간" diff --git a/src/localization/locales/nl.toml b/src/localization/locales/nl.toml index 50550807..1827bee2 100644 --- a/src/localization/locales/nl.toml +++ b/src/localization/locales/nl.toml @@ -128,9 +128,11 @@ copilot_window_title = "Copilot-gebruiksmonitor" "Close Builder" = "Bouwer sluiten" "Codex" = "Codex" "Collect usage from Anthropic" = "Gebruiksgegevens ophalen bij Anthropic" +"Collect usage from Cursor" = "Gebruiksgegevens ophalen bij Cursor" "Collect usage from GitHub" = "Gebruiksgegevens ophalen bij GitHub" "Collect usage from Google" = "Gebruiksgegevens ophalen bij Google" "Collect usage from OpenAI" = "Gebruiksgegevens ophalen bij OpenAI" +"Collect usage from OpenCode Go" = "Gebruiksgegevens ophalen bij OpenCode Go" "Collect usage from xAI" = "Gebruiksgegevens ophalen bij xAI" "Colour" = "Kleur" "Column" = "Column" @@ -457,3 +459,4 @@ copilot_window_title = "Copilot-gebruiksmonitor" "Lock in taskbar" = "Lock in taskbar" "Keep the widget docked; never float automatically" = "Keep the widget docked; never float automatically" "Locked" = "Locked" +"Long period" = "Lange periode" diff --git a/src/localization/locales/pl.toml b/src/localization/locales/pl.toml index c743ce77..3025bce0 100644 --- a/src/localization/locales/pl.toml +++ b/src/localization/locales/pl.toml @@ -128,9 +128,11 @@ copilot_window_title = "Monitor zużycia Copilot" "Close Builder" = "Zamknij kreator" "Codex" = "Codex" "Collect usage from Anthropic" = "Zbieraj dane o zużyciu z Anthropic" +"Collect usage from Cursor" = "Zbieraj dane o zużyciu z Cursor" "Collect usage from GitHub" = "Zbieraj dane o zużyciu z GitHuba" "Collect usage from Google" = "Zbieraj dane o zużyciu z Google" "Collect usage from OpenAI" = "Zbieraj dane o zużyciu z OpenAI" +"Collect usage from OpenCode Go" = "Zbieraj dane o zużyciu z OpenCode Go" "Collect usage from xAI" = "Zbieraj dane o zużyciu z xAI" "Colour" = "Kolor" "Column" = "Kolumna" @@ -457,3 +459,4 @@ copilot_window_title = "Monitor zużycia Copilot" "Lock in taskbar" = "Lock in taskbar" "Keep the widget docked; never float automatically" = "Keep the widget docked; never float automatically" "Locked" = "Locked" +"Long period" = "Długi okres" diff --git a/src/localization/locales/pt-BR.toml b/src/localization/locales/pt-BR.toml index 7719834b..f817c722 100644 --- a/src/localization/locales/pt-BR.toml +++ b/src/localization/locales/pt-BR.toml @@ -128,9 +128,11 @@ copilot_window_title = "Monitor de uso do Copilot" "Close Builder" = "Fechar Construtor" "Codex" = "Codex" "Collect usage from Anthropic" = "Coletar uso da Anthropic" +"Collect usage from Cursor" = "Coletar uso do Cursor" "Collect usage from GitHub" = "Coletar uso do GitHub" "Collect usage from Google" = "Coletar uso do Google" "Collect usage from OpenAI" = "Coletar uso da OpenAI" +"Collect usage from OpenCode Go" = "Coletar uso do OpenCode Go" "Collect usage from xAI" = "Coletar uso da xAI" "Colour" = "Cor" "Column" = "Column" @@ -457,3 +459,4 @@ copilot_window_title = "Monitor de uso do Copilot" "Lock in taskbar" = "Lock in taskbar" "Keep the widget docked; never float automatically" = "Keep the widget docked; never float automatically" "Locked" = "Locked" +"Long period" = "Período longo" diff --git a/src/localization/locales/ru.toml b/src/localization/locales/ru.toml index 19d8aa49..fa0930c8 100644 --- a/src/localization/locales/ru.toml +++ b/src/localization/locales/ru.toml @@ -128,9 +128,11 @@ copilot_window_title = "Монитор использования Copilot" "Close Builder" = "Закрыть конструктор" "Codex" = "Codex" "Collect usage from Anthropic" = "Получать данные об использовании Anthropic" +"Collect usage from Cursor" = "Получать данные об использовании Cursor" "Collect usage from GitHub" = "Получать данные об использовании GitHub" "Collect usage from Google" = "Получать данные об использовании Google" "Collect usage from OpenAI" = "Получать данные об использовании OpenAI" +"Collect usage from OpenCode Go" = "Получать данные об использовании OpenCode Go" "Collect usage from xAI" = "Получать данные об использовании xAI" "Colour" = "Цвет" "Column" = "Column" @@ -457,3 +459,4 @@ copilot_window_title = "Монитор использования Copilot" "Lock in taskbar" = "Lock in taskbar" "Keep the widget docked; never float automatically" = "Keep the widget docked; never float automatically" "Locked" = "Locked" +"Long period" = "Длительный период" diff --git a/src/localization/locales/th.toml b/src/localization/locales/th.toml index c2a936ee..0f903e13 100644 --- a/src/localization/locales/th.toml +++ b/src/localization/locales/th.toml @@ -130,9 +130,11 @@ copilot_window_title = "ตัวติดตามการใช้งาน C "Close Builder" = "ปิดตัวสร้าง" "Codex" = "Codex" "Collect usage from Anthropic" = "ดึงข้อมูลการใช้งานจาก Anthropic" +"Collect usage from Cursor" = "ดึงข้อมูลการใช้งานจาก Cursor" "Collect usage from GitHub" = "ดึงข้อมูลการใช้งานจาก GitHub" "Collect usage from Google" = "ดึงข้อมูลการใช้งานจาก Google" "Collect usage from OpenAI" = "ดึงข้อมูลการใช้งานจาก OpenAI" +"Collect usage from OpenCode Go" = "ดึงข้อมูลการใช้งานจาก OpenCode Go" "Collect usage from xAI" = "ดึงข้อมูลการใช้งานจาก xAI" "Colour" = "สี" "Column" = "คอลัมน์" @@ -459,3 +461,4 @@ copilot_window_title = "ตัวติดตามการใช้งาน C "Lock in taskbar" = "Lock in taskbar" "Keep the widget docked; never float automatically" = "Keep the widget docked; never float automatically" "Locked" = "Locked" +"Long period" = "ระยะเวลานาน" diff --git a/src/localization/locales/tr.toml b/src/localization/locales/tr.toml index cf8706c8..3123382d 100644 --- a/src/localization/locales/tr.toml +++ b/src/localization/locales/tr.toml @@ -128,9 +128,11 @@ copilot_window_title = "Copilot Kullanım İzleyicisi" "Close Builder" = "Oluşturucuyu kapat" "Codex" = "Codex" "Collect usage from Anthropic" = "Anthropic kullanımını topla" +"Collect usage from Cursor" = "Cursor kullanımını topla" "Collect usage from GitHub" = "GitHub kullanımını topla" "Collect usage from Google" = "Google kullanımını topla" "Collect usage from OpenAI" = "OpenAI kullanımını topla" +"Collect usage from OpenCode Go" = "OpenCode Go kullanımını topla" "Collect usage from xAI" = "xAI kullanımını topla" "Colour" = "Renk" "Column" = "Column" @@ -457,3 +459,4 @@ copilot_window_title = "Copilot Kullanım İzleyicisi" "Lock in taskbar" = "Lock in taskbar" "Keep the widget docked; never float automatically" = "Keep the widget docked; never float automatically" "Locked" = "Locked" +"Long period" = "Uzun dönem" diff --git a/src/localization/locales/zh-CN.toml b/src/localization/locales/zh-CN.toml index 82c94804..92ff1e99 100644 --- a/src/localization/locales/zh-CN.toml +++ b/src/localization/locales/zh-CN.toml @@ -130,9 +130,11 @@ copilot_window_title = "Copilot 使用量监控" "Close Builder" = "关闭构建器" "Codex" = "Codex" "Collect usage from Anthropic" = "从 Anthropic 获取使用量" +"Collect usage from Cursor" = "从 Cursor 获取使用量" "Collect usage from GitHub" = "从 GitHub 获取使用量" "Collect usage from Google" = "从 Google 获取使用量" "Collect usage from OpenAI" = "从 OpenAI 获取使用量" +"Collect usage from OpenCode Go" = "从 OpenCode Go 获取使用量" "Collect usage from xAI" = "从 xAI 获取使用量" "Colour" = "颜色" "Column" = "Column" @@ -459,3 +461,4 @@ copilot_window_title = "Copilot 使用量监控" "Lock in taskbar" = "Lock in taskbar" "Keep the widget docked; never float automatically" = "Keep the widget docked; never float automatically" "Locked" = "Locked" +"Long period" = "长周期" diff --git a/src/localization/locales/zh-TW.toml b/src/localization/locales/zh-TW.toml index b0644822..e17f008b 100644 --- a/src/localization/locales/zh-TW.toml +++ b/src/localization/locales/zh-TW.toml @@ -130,9 +130,11 @@ copilot_window_title = "Copilot 使用量監控" "Close Builder" = "關閉建構器" "Codex" = "Codex" "Collect usage from Anthropic" = "從 Anthropic 取得使用量" +"Collect usage from Cursor" = "從 Cursor 取得使用量" "Collect usage from GitHub" = "從 GitHub 取得使用量" "Collect usage from Google" = "從 Google 取得使用量" "Collect usage from OpenAI" = "從 OpenAI 取得使用量" +"Collect usage from OpenCode Go" = "從 OpenCode Go 取得使用量" "Collect usage from xAI" = "從 xAI 取得使用量" "Colour" = "色彩" "Column" = "Column" @@ -459,3 +461,4 @@ copilot_window_title = "Copilot 使用量監控" "Lock in taskbar" = "Lock in taskbar" "Keep the widget docked; never float automatically" = "Keep the widget docked; never float automatically" "Locked" = "Locked" +"Long period" = "長週期" diff --git a/src/models.rs b/src/models.rs index 1d8e4392..9bd5e699 100644 --- a/src/models.rs +++ b/src/models.rs @@ -85,6 +85,11 @@ pub struct UsageData { /// Kept separate from `weekly` so themes can choose how to display it. #[serde(default, skip_serializing_if = "Option::is_none")] pub monthly: Option, + /// The raw 7-day window for OpenCode, always set when available, independent + /// of the `weekly` auto-selection. Themes read this when + /// `opencode_long_window` is `Weekly` or `Both`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub weekly_7d: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub credits: Option, /// Additional API quotas, exposed only through opt-in theme bindings. @@ -127,6 +132,7 @@ impl UsageData { [&self.session, &self.weekly] .into_iter() .chain(self.monthly.iter()) + .chain(self.weekly_7d.iter()) .chain(self.limits.iter().map(|limit| &limit.usage)) .chain(external_sections) } @@ -631,6 +637,38 @@ mod tests { assert!(json.get("antigravity_external").is_none()); } + #[test] + fn weekly_7d_field_round_trips_and_stays_absent_when_not_set() { + // Present: serialises and deserialises correctly. + let section = UsageSection { + available: true, + percentage: 37.5, + resets_at: None, + }; + let data = UsageData { + weekly_7d: Some(section.clone()), + ..Default::default() + }; + let json = serde_json::to_value(&data).unwrap(); + assert_eq!(json["weekly_7d"]["percentage"], 37.5); + let decoded: UsageData = serde_json::from_value(json).unwrap(); + assert_eq!(decoded.weekly_7d.as_ref().unwrap().percentage, 37.5); + + // Absent: skipped from serialisation (no phantom key). + let empty = UsageData::default(); + let json = serde_json::to_value(&empty).unwrap(); + assert!( + json.get("weekly_7d").is_none(), + "weekly_7d must be omitted when None" + ); + let decoded: UsageData = serde_json::from_value(json).unwrap(); + assert!(decoded.weekly_7d.is_none()); + + // sections() iterator includes weekly_7d when present. + let sections: Vec<_> = data.sections().collect(); + assert!(sections.iter().any(|s| s.percentage == 37.5)); + } + #[test] fn absent_window_is_cached_without_a_percentage() { let section = UsageSection::default(); diff --git a/src/poller/opencode.rs b/src/poller/opencode.rs index 7848b53e..eb528c46 100644 --- a/src/poller/opencode.rs +++ b/src/poller/opencode.rs @@ -124,6 +124,10 @@ fn poll_dashboard(credentials: &DashboardCredentials) -> Result ThemeRuntime { diff --git a/src/studio_app/studio_helper.rs b/src/studio_app/studio_helper.rs index 52941113..ef8f1c4a 100644 --- a/src/studio_app/studio_helper.rs +++ b/src/studio_app/studio_helper.rs @@ -552,7 +552,7 @@ fn window_label(provider: &str, window: &str) -> &'static str { match (provider, window) { ("cursor", "session") => "Auto", ("cursor", "weekly") => "API", - ("opencode", "weekly") => "Long window", + ("opencode", "weekly") => "Long period", (_, "session") => "Session", (_, "five_hour") => "5-hour", (_, "weekly") => "Weekly", diff --git a/src/studio_app/studio_settings.rs b/src/studio_app/studio_settings.rs index fec8218a..4ac72c1d 100644 --- a/src/studio_app/studio_settings.rs +++ b/src/studio_app/studio_settings.rs @@ -97,6 +97,42 @@ impl StudioApp { ); }); } + if descriptor.id == ProviderId::OpenCode + && self.settings.provider_enabled(descriptor.id) + { + let long_window_key = + |window: app_settings::OpenCodeLongWindow| match window { + app_settings::OpenCodeLongWindow::Weekly => "Weekly", + app_settings::OpenCodeLongWindow::Monthly => "Monthly", + }; + ui.horizontal(|ui| { + ui.label(language.text("Long period")); + let selected = + language.text(long_window_key(self.settings.opencode_long_window)); + let mut picked = false; + Dropdown::from_id_salt("opencode_long_window") + .width(150.0) + .selected_text(selected) + .show_ui(ui, |ui| { + for window in [ + app_settings::OpenCodeLongWindow::Weekly, + app_settings::OpenCodeLongWindow::Monthly, + ] { + picked |= dropdown_selectable_value( + ui, + &mut self.settings.opencode_long_window, + window, + language.text(long_window_key(window)), + ) + .changed(); + } + }); + if picked { + changed = true; + self.request_refresh(); + } + }); + } } }); section(ui, language.text("Display"), |ui| { diff --git a/src/theme_engine.rs b/src/theme_engine.rs index 373711c2..1036a25c 100644 --- a/src/theme_engine.rs +++ b/src/theme_engine.rs @@ -1352,6 +1352,7 @@ pub struct ThemeRuntime { pub antigravity_gemini_enabled: bool, pub antigravity_external_enabled: bool, pub crossfade_opacity: f64, + pub opencode_long_window: crate::app_settings::OpenCodeLongWindow, host_width: u32, host_height: u32, } @@ -1369,6 +1370,7 @@ impl Default for ThemeRuntime { antigravity_gemini_enabled: true, antigravity_external_enabled: false, crossfade_opacity: 1.0, + opencode_long_window: crate::app_settings::OpenCodeLongWindow::Monthly, host_width: default_canvas_width(), host_height: default_canvas_height(), } @@ -1403,6 +1405,7 @@ impl ThemeRuntime { antigravity_gemini_enabled: agy, antigravity_external_enabled: false, crossfade_opacity: 1.0, + opencode_long_window: crate::app_settings::OpenCodeLongWindow::Monthly, host_width: default_canvas_width(), host_height: default_canvas_height(), } @@ -1447,6 +1450,15 @@ impl ThemeRuntime { self } + /// Select which long window the OpenCode row shows. + pub fn with_opencode_long_window( + mut self, + window: crate::app_settings::OpenCodeLongWindow, + ) -> Self { + self.opencode_long_window = window; + self + } + /// Supply the selected native host's 96-DPI logical dimensions. Theme /// expressions consume these as `host.width` and `host.height`. pub fn with_host_dimensions(mut self, width: u32, height: u32) -> Self { @@ -1639,6 +1651,11 @@ impl DataContext { context.insert_provider("antigravity.external", None, false, runtime.countdown); context.insert_provider("active", None, false, runtime.countdown); } + let long_window_str = match runtime.opencode_long_window { + crate::app_settings::OpenCodeLongWindow::Weekly => "weekly", + crate::app_settings::OpenCodeLongWindow::Monthly => "monthly", + }; + context.insert_string("opencode.long_window", long_window_str); context } @@ -1759,6 +1776,30 @@ impl DataContext { &format!("{name}.monthly.available"), monthly.is_some() as u8 as f64, ); + let weekly_7d = usage.and_then(|usage| usage.weekly_7d.as_ref()); + self.insert_string(&format!("{name}.weekly_7d.label"), "7d"); + if let Some(weekly_7d) = weekly_7d { + self.insert( + &format!("{name}.weekly_7d.percentage"), + weekly_7d.percentage, + ); + self.insert( + &format!("{name}.weekly_7d.remaining"), + 100.0 - weekly_7d.percentage, + ); + self.insert( + &format!("{name}.weekly_7d.display"), + display(weekly_7d.percentage), + ); + } else { + self.insert(&format!("{name}.weekly_7d.percentage"), 0.0); + self.insert(&format!("{name}.weekly_7d.remaining"), 100.0); + self.insert(&format!("{name}.weekly_7d.display"), display(0.0)); + } + self.insert( + &format!("{name}.weekly_7d.available"), + weekly_7d.map_or(false, |s| s.available) as u8 as f64, + ); self.insert(&format!("{name}.available"), usage.is_some() as u8 as f64); // Carried over from an earlier poll: real figures, not current ones. self.insert( @@ -1843,11 +1884,14 @@ impl DataContext { } let (monthly_unix, monthly_seconds) = reset_value(monthly.and_then(|value| value.resets_at)); + let (weekly_7d_unix, weekly_7d_seconds) = + reset_value(weekly_7d.and_then(|value| value.resets_at)); for (window, unix, seconds) in [ ("session", session_unix, session_seconds), ("five_hour", five_hour_unix, five_hour_seconds), ("weekly", weekly_unix, weekly_seconds), ("monthly", monthly_unix, monthly_seconds), + ("weekly_7d", weekly_7d_unix, weekly_7d_seconds), ] { self.insert(&format!("{name}.{window}.reset.unix"), unix); self.insert(&format!("{name}.{window}.reset.seconds"), seconds); diff --git a/src/theme_engine/tests.rs b/src/theme_engine/tests.rs index 7b71480a..4eed14aa 100644 --- a/src/theme_engine/tests.rs +++ b/src/theme_engine/tests.rs @@ -1013,10 +1013,11 @@ fn starter_theme_round_trips_and_validates() { // five providers over two windows in two modes, plus Grok and Copilot, // which fill only the long-window row, plus a credit overlay on that row // for the three providers that report credits, plus the two eight-segment - // Fable layers. + // Fable layers, plus the four OpenCode configurable + // long-window rows (7d dark/light + 30d dark/light). assert_eq!( segments, - [vec![10; 5 * 2 * 2 + 2 * 2 + 3 * 2], vec![8; 2]].concat() + [vec![10; 32], vec![8; 2]].concat() ); assert!(theme.surfaces[0] .children @@ -1224,6 +1225,75 @@ fn opencode_monthly_window_is_available_to_templates_when_present() { ); } +#[test] +fn opencode_weekly_7d_binding_and_long_window_string_reflect_settings() { + use crate::app_settings::OpenCodeLongWindow; + + let section = crate::models::UsageSection { + available: true, + percentage: 55.0, + resets_at: None, + }; + let usage = crate::models::AppUsageData::from_iter([( + ProviderId::OpenCode, + crate::models::UsageData { + weekly_7d: Some(section.clone()), + ..Default::default() + }, + )]); + + // weekly_7d binding is populated from the field. + let context = DataContext::from_usage(Some(&usage), &Canvas::default()); + assert_eq!( + evaluate("opencode.weekly_7d.percentage", &context).unwrap(), + 55.0 + ); + assert_eq!( + evaluate("opencode.weekly_7d.remaining", &context).unwrap(), + 45.0 + ); + assert_eq!( + evaluate("opencode.weekly_7d.available", &context).unwrap(), + 1.0 + ); + assert_eq!( + format_template("{opencode.weekly_7d.label}", &context), + "7d" + ); + + // Absent weekly_7d produces available=0. + let empty_usage = crate::models::AppUsageData::from_iter([( + ProviderId::OpenCode, + crate::models::UsageData::default(), + )]); + let empty_ctx = DataContext::from_usage(Some(&empty_usage), &Canvas::default()); + assert_eq!( + evaluate("opencode.weekly_7d.available", &empty_ctx).unwrap(), + 0.0 + ); + + // opencode.long_window reflects the runtime setting. + let runtime_for = |window: OpenCodeLongWindow| { + ThemeRuntime::from_providers(ProviderSet::from_enabled([ProviderId::OpenCode])) + .with_opencode_long_window(window) + }; + for (window, expected_str) in [ + (OpenCodeLongWindow::Weekly, "weekly"), + (OpenCodeLongWindow::Monthly, "monthly"), + ] { + let ctx = DataContext::from_usage_with_runtime( + Some(&usage), + &Canvas::default(), + runtime_for(window), + ); + assert_eq!( + ctx.get_string("opencode.long_window").unwrap(), + expected_str, + "long_window binding for {window:?}" + ); + } +} + #[test] fn starter_theme_renders_transparent_pixels_at_declared_size() { let theme = ThemeDocument::starter(); @@ -2302,6 +2372,8 @@ fn compact_fluent_quad_widget_respects_usage_direction() { UsageData { session: section.clone(), weekly: section.clone(), + monthly: Some(section.clone()), + weekly_7d: Some(section.clone()), limits: (provider == ProviderId::Claude) .then(|| UsageLimit { key: "weekly_scoped_fable".into(), diff --git a/src/theme_engine/theme_expression.rs b/src/theme_engine/theme_expression.rs index 21fb22f9..b61b9744 100644 --- a/src/theme_engine/theme_expression.rs +++ b/src/theme_engine/theme_expression.rs @@ -527,7 +527,7 @@ pub(super) fn format_usage_line(base: &str, context: &DataContext) -> Option, active_theme: Option, theme_clock_interval: Option, @@ -793,6 +794,7 @@ fn theme_runtime_from_state(state: &AppState) -> ThemeRuntime { .with_poll_state(poll_ok, has_error) .with_language(state.language) .with_countdown(state.usage_countdown) + .with_opencode_long_window(state.opencode_long_window) .with_nest(nest) .with_floating_card_opacity(opacity) .with_antigravity_pools( @@ -2607,6 +2609,7 @@ pub fn run() { window_state_timer_active: false, custom_theme_enabled, usage_countdown: settings.usage_countdown, + opencode_long_window: settings.opencode_long_window, active_theme_path, active_theme, theme_clock_interval, @@ -3426,6 +3429,7 @@ fn reload_external_settings(hwnd: HWND) { state.poll_interval_ms = settings.poll_interval_ms; state.providers = settings.enabled_providers(); state.usage_countdown = settings.usage_countdown; + state.opencode_long_window = settings.opencode_long_window; dual_mode = state.show_antigravity_gemini && state.show_antigravity_external && state.providers.contains(ProviderId::Antigravity); diff --git a/src/window/placement_regression_tests.rs b/src/window/placement_regression_tests.rs index 44b68ed7..4f922d4e 100644 --- a/src/window/placement_regression_tests.rs +++ b/src/window/placement_regression_tests.rs @@ -46,6 +46,7 @@ pub(super) fn state_for(theme: ThemeDocument, placement: PlacementOverride) -> A window_state_timer_active: false, custom_theme_enabled: true, usage_countdown: false, + opencode_long_window: app_settings::OpenCodeLongWindow::Monthly, active_theme_path: None, active_theme: Some(theme), theme_clock_interval: None,