Compare commits

...

19 Commits
v1.3.7 ... main

Author SHA1 Message Date
Craig Constable
c333fdfa14 v1.4.5 2026-06-20 10:35:08 +10:00
Craig Constable
9771e118b4 chore: ignore local agent instructions 2026-06-20 10:06:51 +10:00
Craig Constable
b46e11fbf8 v1.4.4 2026-06-20 10:02:27 +10:00
Craig Constable
976ce04fb2 Merge pull request #36 from amorel/taskbar-restart-fix 2026-06-20 10:01:57 +10:00
Craig Constable
4c59e836f0 fix: make relaunch mutex wait effective 2026-06-20 10:01:52 +10:00
amorel
adbfaf1107 fix: survive explorer.exe restarts (tray icon no longer disappears)
When explorer.exe restarts it destroys the taskbar, which destroys our embedded child window outright (IsWindow returns false) and leaves the UI thread parked in GetMessage with no window to recreate in place. The tray icon and widget vanish until the app is manually relaunched (see issue #34).

A dedicated watchdog thread (independent of the now-dead message loop) polls the Shell_TrayWnd handle and, when it changes, relaunches the widget as a fresh process that re-embeds into the new taskbar. The relaunched child is flagged via an environment variable so it waits on the single-instance mutex until the previous instance exits, and a throttle backs off if explorer is crash-looping.
2026-06-17 16:27:36 +02:00
Craig Constable
7af314996d v1.4.3
- Add Brazilian Portuguese (pt-BR) localization

- Thanks to @caduaraxa for the contribution in PR #29

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 19:54:11 +10:00
Craig Constable
b094bf9e0b Merge pull request #29 from caduaraxa/translation-pt-br 2026-06-12 19:53:28 +10:00
Craig Constable
c1056a4bd3 feat: wire up Brazilian Portuguese language support
The PR added the translation file only; register it in the
localization module (enum, codes, native name, string lookups,
pt locale detection) and add the context menu entry and command
handler. Also trim trailing whitespace and align field order
with the other language files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 19:53:19 +10:00
Craig Constable
c4b2c18b78 Merge main into pr-29 2026-06-12 19:48:39 +10:00
Craig Constable
286dd89a67 v1.4.2
- Add Russian localization

- Thanks to @st7105 for the contribution in PR #28

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 19:39:35 +10:00
Craig Constable
dc311c9406 Merge pull request #28 from st7105/main 2026-06-12 19:38:02 +10:00
Craig Constable
374d94fac2 fix: wire up Russian in language menu command handler
The PR added IDM_LANG_RUSSIAN to the context menu but not to the
WM_COMMAND handler, so selecting it did nothing. Also reorder
second_suffix to match the Strings field order used by the other
language files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 19:37:31 +10:00
caduaraxa
cf5b6c0741 Add new translation file for PT-BR (Brazilian Portuguese) 2026-06-10 17:10:37 -03:00
st7105
e88ea07245 add Russian language 2026-06-10 12:53:45 +03:00
Craig Constable
b5f038dd39 v1.4.1
fix: correct widget drag state
2026-05-13 08:25:13 +10:00
Craig Constable
4676f6f72e feat: Codex usage now available through the model menu
- Option to have both or a single model visible

- Tray icon for each visible model

- Coloured text in duel mode to assist in identifying which model
2026-05-09 08:06:27 +10:00
Craig Constable
d54588bf6e v1.3.9
feat: check for other credential sources on permanent credential failure.
2026-05-05 18:46:57 +10:00
Craig Constable
f2b378462d v1.3.8
fix: stop focus steal by ignoring tray location events from our own icon updates
2026-05-05 09:38:36 +10:00
19 changed files with 1383 additions and 172 deletions

2
.gitignore vendored
View File

@ -10,6 +10,7 @@
# IDE
.idea/
.vscode/
.history/
*.swp
*.swo
*~
@ -21,6 +22,7 @@ Desktop.ini
# Local release workflow helper
workflow.cmd
AGENTS.md
# Local WinGet manifest generation output
/manifests/

2
Cargo.lock generated
View File

@ -38,7 +38,7 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "claude-code-usage-monitor"
version = "1.3.7"
version = "1.4.5"
dependencies = [
"dirs",
"native-tls",

View File

@ -1,6 +1,6 @@
[package]
name = "claude-code-usage-monitor"
version = "1.3.7"
version = "1.4.5"
edition = "2021"
license = "MIT"
description = "Claude Code Usage Monitor"

View File

@ -5,30 +5,34 @@
![Screenshot](.github/animation.gif)
A lightweight Windows taskbar widget for people already using Claude Code.
A lightweight Windows taskbar widget for people already using Claude Code, with optional Codex usage display.
It sits in your taskbar and shows how much of your Claude Code usage window you have left, without needing to open the terminal or the Claude site.
It sits in your taskbar and shows how much of your Claude Code and/or Codex usage window you have left, without needing to open the terminal or the provider site.
## What You Get
- A **5h** bar for your current 5-hour Claude usage window
- A **7d** bar for your current 7-day window
- Optional Codex usage bars alongside Claude Code
- A live countdown until each limit resets
- A small native widget that lives directly in the Windows taskbar
- A **system tray icon** showing your usage percentage as a color-coded badge
- System tray icon badges showing your enabled model usage percentage
- Left-click the tray icon to toggle the taskbar widget on or off
- Right-click options for refresh, update frequency, language, startup, and updates
- Right-click options for refresh, displayed models, update frequency, language, startup, and updates
## Who This Is For
This app is for Windows users who already have **Claude Code (CLI or App) installed and signed in**.
Codex support is optional. To show Codex usage, install and sign in to the Codex CLI, then enable Codex from the right-click **Models** menu.
It works best if you want a simple "how close am I to the limit?" display that is always visible.
## Requirements
- Windows 10 or Windows 11
- Claude Code (CLI or App) installed and authenticated
- Optional: Codex CLI installed and authenticated, if you want Codex usage
If you use Claude Code through WSL, that is supported too. The monitor can read your Claude Code credentials from Windows or from your WSL environment.
@ -50,18 +54,31 @@ After installing with WinGet, run:
claude-code-usage-monitor
```
Once running, it will appear in your taskbar and as a tray icon in the notification area.
Once running, it will appear in your taskbar and as one or more tray icons in the notification area.
- Drag the left divider to move the taskbar widget
- Right-click the taskbar widget or tray icon for refresh, update frequency, Start with Windows, reset position, language, updates, and exit
- Right-click the taskbar widget or tray icon for refresh, displayed models, update frequency, Start with Windows, reset position, language, updates, and exit
- Left-click the tray icon to toggle the taskbar widget on or off
- Enable `Start with Windows` from the right-click menu if you want it to launch automatically when you sign in
### Models
Use the right-click **Models** menu to choose what the widget displays:
- **Claude Code** is enabled by default
- **Codex** can be enabled alongside Claude Code or shown by itself
When both models are shown, each model has its own usage bar and matching usage text color.
### System Tray Icon
The tray icon shows your current 5-hour usage as a color-coded percentage badge.
The tray icon shows your current 5-hour usage as a percentage badge.
Hovering over the tray icon shows a tooltip with both your 5h and 7d usage.
If both Claude Code and Codex are enabled, the app shows two tray icons: one for Claude Code and one for Codex. If only one model is enabled, it shows one tray icon.
The Claude Code tray icon uses the same warm usage colors as the Claude bar. The Codex tray icon uses a black and white badge style.
Hovering over a tray icon shows the usage values for that model.
## Diagnostics
@ -102,10 +119,12 @@ What the app reads:
- Your local Claude Code OAuth credentials from `~/.claude/.credentials.json`
- If needed, the same credentials file inside an installed WSL distro
- If Codex is enabled, your local Codex credentials from `$CODEX_HOME/auth.json` or `~/.codex/auth.json`
What the app sends over the network:
- Requests to Anthropic's Claude endpoints to read your usage and rate-limit information
- Requests to ChatGPT's Codex usage endpoint to read your Codex usage and rate-limit information, if Codex is enabled
- Requests to GitHub only if you use the app's update check / self-update feature
- If proxy environment variables such as `HTTPS_PROXY`, `HTTP_PROXY`, or `ALL_PROXY` are set, those outbound requests may use that proxy
@ -115,6 +134,7 @@ What the app stores locally:
- Polling frequency
- Language preference
- Last update check time
- Displayed model preferences
What it does **not** do:
@ -122,19 +142,21 @@ What it does **not** do:
- It does not use a separate backend service
- It does not collect analytics or telemetry
- It does not upload your project files
- It does not directly edit your Codex credentials file
Notes:
- If your Claude Code token is expired, the app may ask the local Claude CLI to refresh it in the background
- If your Codex token is expired, the app may ask the local Codex CLI to refresh it in the background. The monitor does not write `auth.json` itself; any credential update is handled by the Codex CLI.
- Portable installs can update themselves by downloading the latest release from this repository
- Proxies should be trusted because proxied Claude requests include your OAuth bearer token inside the TLS connection
- Proxies should be trusted because proxied usage requests include your OAuth bearer token inside the TLS connection
## How It Works
The monitor:
1. Finds your Claude Code login credentials
2. Reads your current usage from Anthropic
1. Finds your enabled model login credentials
2. Reads your current usage from Anthropic and/or ChatGPT
3. Shows the result directly in the Windows taskbar
4. Refreshes periodically in the background

View File

@ -10,6 +10,9 @@ pub(super) const STRINGS: Strings = Strings {
five_minutes: "5 minuten",
fifteen_minutes: "15 minuten",
one_hour: "1 uur",
models: "Modellen",
claude_code_model: "Claude Code",
codex_model: "Codex",
settings: "Instellingen",
start_with_windows: "Opstarten met Windows",
reset_position: "Positie herstellen",
@ -36,5 +39,8 @@ pub(super) const STRINGS: Strings = Strings {
minute_suffix: "m",
token_expired_title: "Claude Code-authenticatiefout",
token_expired_body: "Voer 'claude' uit in een terminal, gebruik daarna '/login' en volg de stappen. Ververs of herstart de app daarna.",
codex_token_expired_title: "Codex-authenticatiefout",
codex_token_expired_body: "Voer 'codex' uit in een terminal en volg de aanmeldstappen. Ververs of herstart de app daarna.",
codex_window_title: "Codex-gebruiksmonitor",
second_suffix: "s",
};

View File

@ -10,6 +10,9 @@ pub(super) const STRINGS: Strings = Strings {
five_minutes: "5 Minutes",
fifteen_minutes: "15 Minutes",
one_hour: "1 Hour",
models: "Models",
claude_code_model: "Claude Code",
codex_model: "Codex",
settings: "Settings",
start_with_windows: "Start with Windows",
reset_position: "Reset Position",
@ -36,5 +39,8 @@ pub(super) const STRINGS: Strings = Strings {
minute_suffix: "m",
token_expired_title: "Claude Code Auth Error",
token_expired_body: "Run 'claude' in a terminal, then use '/login' and follow the prompts. After that, refresh or restart this app.",
codex_token_expired_title: "Codex Auth Error",
codex_token_expired_body: "Run 'codex' in a terminal and follow the sign-in prompts. After that, refresh or restart this app.",
codex_window_title: "Codex Usage Monitor",
second_suffix: "s",
};

View File

@ -10,6 +10,9 @@ pub(super) const STRINGS: Strings = Strings {
five_minutes: "5 minutes",
fifteen_minutes: "15 minutes",
one_hour: "1 heure",
models: "Modeles",
claude_code_model: "Claude Code",
codex_model: "Codex",
settings: "Paramètres",
start_with_windows: "Démarrer avec Windows",
reset_position: "Réinitialiser la position",
@ -36,5 +39,8 @@ pub(super) const STRINGS: Strings = Strings {
minute_suffix: "m",
token_expired_title: "Erreur d'authentification",
token_expired_body: "Exécutez 'claude' dans un terminal, puis utilisez '/login' et suivez les instructions. Ensuite, actualisez ou redémarrez cette application.",
codex_token_expired_title: "Erreur d'authentification Codex",
codex_token_expired_body: "Executez 'codex' dans un terminal et suivez les instructions de connexion. Ensuite, actualisez ou redemarrez cette application.",
codex_window_title: "Moniteur d'utilisation Codex",
second_suffix: "s",
};

View File

@ -10,6 +10,9 @@ pub(super) const STRINGS: Strings = Strings {
five_minutes: "5 Minuten",
fifteen_minutes: "15 Minuten",
one_hour: "1 Stunde",
models: "Modelle",
claude_code_model: "Claude Code",
codex_model: "Codex",
settings: "Einstellungen",
start_with_windows: "Mit Windows starten",
reset_position: "Position zurücksetzen",
@ -36,5 +39,8 @@ pub(super) const STRINGS: Strings = Strings {
minute_suffix: "m",
token_expired_title: "Authentifizierungsfehler",
token_expired_body: "Führen Sie 'claude' in einem Terminal aus, verwenden Sie dann '/login' und folgen Sie den Anweisungen. Aktualisieren oder starten Sie diese App anschließend neu.",
codex_token_expired_title: "Codex-Authentifizierungsfehler",
codex_token_expired_body: "Fuhren Sie 'codex' in einem Terminal aus und folgen Sie den Anmeldeanweisungen. Aktualisieren oder starten Sie diese App anschliessend neu.",
codex_window_title: "Codex-Nutzungsmonitor",
second_suffix: "s",
};

View File

@ -10,6 +10,9 @@ pub(super) const STRINGS: Strings = Strings {
five_minutes: "5分",
fifteen_minutes: "15分",
one_hour: "1時間",
models: "モデル",
claude_code_model: "Claude Code",
codex_model: "Codex",
settings: "設定",
start_with_windows: "Windows と同時に開始",
reset_position: "位置をリセット",
@ -36,5 +39,8 @@ pub(super) const STRINGS: Strings = Strings {
minute_suffix: "分",
token_expired_title: "認証エラー",
token_expired_body: "ターミナルで 'claude' を実行し、'/login' を使って案内に従ってください。その後、このアプリを更新するか再起動してください。",
codex_token_expired_title: "Codex 認証エラー",
codex_token_expired_body: "ターミナルで 'codex' を実行し、サインインの案内に従ってください。その後、このアプリを更新または再起動してください。",
codex_window_title: "Codex 使用量モニター",
second_suffix: "秒",
};

View File

@ -10,6 +10,9 @@ pub(super) const STRINGS: Strings = Strings {
five_minutes: "5분",
fifteen_minutes: "15분",
one_hour: "1시간",
models: "모델",
claude_code_model: "Claude Code",
codex_model: "Codex",
settings: "설정",
start_with_windows: "Windows 시작 시 자동 실행",
reset_position: "위치 초기화",
@ -36,5 +39,8 @@ pub(super) const STRINGS: Strings = Strings {
minute_suffix: "분",
token_expired_title: "인증 오류",
token_expired_body: "터미널에서 'claude'를 실행한 다음 '/login'을 사용하고 안내에 따라 진행하세요. 그런 다음 이 앱을 새로 고치거나 다시 시작하세요.",
codex_token_expired_title: "Codex 인증 오류",
codex_token_expired_body: "터미널에서 'codex'를 실행하고 로그인 안내를 따르세요. 그런 다음 이 앱을 새로 고치거나 다시 시작하세요.",
codex_window_title: "Codex 사용량 모니터",
second_suffix: "초",
};

View File

@ -1,11 +1,13 @@
mod english;
mod dutch;
mod english;
mod french;
mod german;
mod japanese;
mod korean;
mod spanish;
mod traditional_chinese;
mod russian;
mod portuguese_brazil;
use windows::core::PWSTR;
use windows::Win32::Globalization::{
@ -23,10 +25,12 @@ pub enum LanguageId {
Japanese,
Korean,
TraditionalChinese,
Russian,
PortugueseBrazil,
}
impl LanguageId {
pub const ALL: [LanguageId; 8] = [
pub const ALL: [LanguageId; 10] = [
LanguageId::English,
LanguageId::Dutch,
LanguageId::Spanish,
@ -35,6 +39,8 @@ impl LanguageId {
LanguageId::Japanese,
LanguageId::Korean,
LanguageId::TraditionalChinese,
LanguageId::Russian,
LanguageId::PortugueseBrazil,
];
pub fn code(self) -> &'static str {
@ -47,6 +53,8 @@ impl LanguageId {
Self::Japanese => "ja",
Self::Korean => "ko",
Self::TraditionalChinese => "zh-TW",
Self::Russian => "ru",
Self::PortugueseBrazil => "pt-BR",
}
}
@ -60,6 +68,8 @@ impl LanguageId {
Self::Japanese => "日本語",
Self::Korean => "한국어",
Self::TraditionalChinese => "繁體中文",
Self::Russian => "Русский",
Self::PortugueseBrazil => "Português (Brasil)",
}
}
@ -73,6 +83,8 @@ impl LanguageId {
Self::Japanese => japanese::STRINGS,
Self::Korean => korean::STRINGS,
Self::TraditionalChinese => traditional_chinese::STRINGS,
Self::Russian => russian::STRINGS,
Self::PortugueseBrazil => portuguese_brazil::STRINGS,
}
}
@ -86,6 +98,8 @@ impl LanguageId {
Self::Japanese => japanese::UPDATE_VIA_WINGET_LABEL,
Self::Korean => korean::UPDATE_VIA_WINGET_LABEL,
Self::TraditionalChinese => traditional_chinese::UPDATE_VIA_WINGET_LABEL,
Self::Russian => russian::UPDATE_VIA_WINGET_LABEL,
Self::PortugueseBrazil => portuguese_brazil::UPDATE_VIA_WINGET_LABEL,
}
}
@ -114,6 +128,8 @@ impl LanguageId {
None
}
}
"ru" => Some(Self::Russian),
"pt" => Some(Self::PortugueseBrazil),
_ => None,
}
}
@ -128,6 +144,9 @@ pub struct Strings {
pub five_minutes: &'static str,
pub fifteen_minutes: &'static str,
pub one_hour: &'static str,
pub models: &'static str,
pub claude_code_model: &'static str,
pub codex_model: &'static str,
pub settings: &'static str,
pub start_with_windows: &'static str,
pub reset_position: &'static str,
@ -155,6 +174,9 @@ pub struct Strings {
pub second_suffix: &'static str,
pub token_expired_title: &'static str,
pub token_expired_body: &'static str,
pub codex_token_expired_title: &'static str,
pub codex_token_expired_body: &'static str,
pub codex_window_title: &'static str,
}
pub fn resolve_language(language_override: Option<LanguageId>) -> LanguageId {

View File

@ -0,0 +1,46 @@
use super::Strings;
pub(super) const UPDATE_VIA_WINGET_LABEL: &str = "Atualizar via WinGet";
pub(super) const STRINGS: Strings = Strings {
window_title: "Monitor de Uso do Claude Code",
refresh: "Atualizar",
update_frequency: "Frequência de Atualização",
one_minute: "1 Minuto",
five_minutes: "5 Minutos",
fifteen_minutes: "15 Minutos",
one_hour: "1 Hora",
models: "Modelos",
claude_code_model: "Claude Code",
codex_model: "Codex",
settings: "Configurações",
start_with_windows: "Iniciar com o Windows",
reset_position: "Redefinir Posição",
language: "Idioma",
system_default: "Padrão do Sistema",
check_for_updates: "Busca atualizações",
checking_for_updates: "Buscando por atualizações...",
updates: "Atualizações",
update_in_progress: "Uma verificação de atualização já está em andamento.",
up_to_date: "Você já tem a versão mais recente.",
up_to_date_short: "Atualizado",
update_failed: "Não foi possível atualizar automaticamente",
applying_update: "Aplicando atualização...",
update_to: "Atualizar para",
update_available: "Atualização disponível",
update_prompt_now: "Versão {version} está disponível. Deseja atualizar agora?",
exit: "Sair",
show_widget: "Exibir Widget",
session_window: "5h",
weekly_window: "7d",
now: "agora",
day_suffix: "d",
hour_suffix: "h",
minute_suffix: "m",
second_suffix: "s",
token_expired_title: "Erro de Autenticação do Claude Code",
token_expired_body: "Execute 'claude' em um terminal, use '/login' e siga as instruções. Depois disso, atualize ou reinicie este aplicativo.",
codex_token_expired_title: "Erro de Autenticação do Codex",
codex_token_expired_body: "Execute 'codex' em um terminal e siga as instruções de login. Depois disso, atualize ou reinicie este aplicativo.",
codex_window_title: "Monitor de uso do Codex",
};

View File

@ -0,0 +1,46 @@
use super::Strings;
pub(super) const UPDATE_VIA_WINGET_LABEL: &str = "Обновить через WinGet";
pub(super) const STRINGS: Strings = Strings {
window_title: "Монитор использования Claude Code",
refresh: "Обновить",
update_frequency: "Частота обновления",
one_minute: "1 минута",
five_minutes: "5 минут",
fifteen_minutes: "15 минут",
one_hour: "1 час",
models: "Модели",
claude_code_model: "Claude Code",
codex_model: "Codex",
settings: "Настройки",
start_with_windows: "Запускать вместе с Windows",
reset_position: "Сбросить позицию",
language: "Язык",
system_default: "Системный по умолчанию",
check_for_updates: "Проверить обновления",
checking_for_updates: "Проверка обновлений...",
updates: "Обновления",
update_in_progress: "Проверка обновлений уже выполняется.",
up_to_date: "У вас уже установлена последняя версия.",
up_to_date_short: "Актуальная версия",
update_failed: "Не удалось обновить автоматически",
applying_update: "Применение обновления...",
update_to: "Обновить до",
update_available: "Доступно обновление",
update_prompt_now: "Доступна версия {version}. Обновить сейчас?",
exit: "Выход",
show_widget: "Показать виджет",
session_window: "5ч",
weekly_window: "7д",
now: "сейчас",
day_suffix: "д",
hour_suffix: "ч",
minute_suffix: "м",
second_suffix: "с",
token_expired_title: "Ошибка авторизации Claude Code",
token_expired_body: "Запустите 'claude' в терминале, затем используйте '/login' и следуйте инструкциям. После этого обновите или перезапустите приложение.",
codex_token_expired_title: "Ошибка авторизации Codex",
codex_token_expired_body: "Запустите 'codex' в терминале и следуйте инструкциям для входа. После этого обновите или перезапустите приложение.",
codex_window_title: "Монитор использования Codex",
};

View File

@ -10,6 +10,9 @@ pub(super) const STRINGS: Strings = Strings {
five_minutes: "5 minutos",
fifteen_minutes: "15 minutos",
one_hour: "1 hora",
models: "Modelos",
claude_code_model: "Claude Code",
codex_model: "Codex",
settings: "Configuración",
start_with_windows: "Iniciar con Windows",
reset_position: "Restablecer posición",
@ -36,5 +39,8 @@ pub(super) const STRINGS: Strings = Strings {
minute_suffix: "m",
token_expired_title: "Error de autenticación",
token_expired_body: "Ejecuta 'claude' en una terminal, luego usa '/login' y sigue las indicaciones. Después, actualiza o reinicia esta aplicación.",
codex_token_expired_title: "Error de autenticacion de Codex",
codex_token_expired_body: "Ejecuta 'codex' en una terminal y sigue las indicaciones de inicio de sesion. Despues, actualiza o reinicia esta aplicacion.",
codex_window_title: "Monitor de uso de Codex",
second_suffix: "s",
};

View File

@ -10,6 +10,9 @@ pub(super) const STRINGS: Strings = Strings {
five_minutes: "5 分鐘",
fifteen_minutes: "15 分鐘",
one_hour: "1 小時",
models: "模型",
claude_code_model: "Claude Code",
codex_model: "Codex",
settings: "設定",
start_with_windows: "開機時啟動",
reset_position: "重置位置",
@ -36,5 +39,8 @@ pub(super) const STRINGS: Strings = Strings {
minute_suffix: "分",
token_expired_title: "驗證錯誤",
token_expired_body: "請在終端機中執行 'claude',然後使用 '/login' 並依照提示操作。完成後,請重新整理或重新啟動此應用程式。",
codex_token_expired_title: "Codex 驗證錯誤",
codex_token_expired_body: "請在終端機中執行 'codex',並依照登入提示操作。完成後,請重新整理或重新啟動此應用程式。",
codex_window_title: "Codex 使用量監控",
second_suffix: "秒",
};

View File

@ -11,3 +11,9 @@ pub struct UsageData {
pub session: UsageSection,
pub weekly: UsageSection,
}
#[derive(Clone, Debug, Default)]
pub struct AppUsageData {
pub claude_code: Option<UsageData>,
pub codex: Option<UsageData>,
}

View File

@ -7,15 +7,16 @@ use std::os::windows::process::CommandExt;
use crate::diagnose;
use crate::localization::Strings;
use crate::models::{UsageData, UsageSection};
use crate::models::{AppUsageData, UsageData, UsageSection};
const USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage";
const MESSAGES_URL: &str = "https://api.anthropic.com/v1/messages";
const CODEX_USAGE_URL: &str = "https://chatgpt.com/backend-api/wham/usage";
const CREATE_NO_WINDOW: u32 = 0x08000000;
const MODEL_FALLBACK_CHAIN: &[&str] = &["claude-3-haiku-20240307", "claude-haiku-4-5-20251001"];
#[derive(Debug)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PollError {
AuthRequired,
NoCredentials,
@ -43,8 +44,80 @@ struct UsageBucket {
resets_at: Option<String>,
}
pub fn poll() -> Result<UsageData, PollError> {
let mut creds = match read_credentials() {
#[derive(Deserialize)]
struct CodexAuthFile {
tokens: Option<CodexTokenData>,
}
#[derive(Clone, Deserialize)]
struct CodexTokenData {
access_token: String,
account_id: Option<String>,
}
#[derive(Deserialize)]
struct CodexUsageResponse {
rate_limit: Option<Option<Box<CodexRateLimitDetails>>>,
}
#[derive(Deserialize)]
struct CodexRateLimitDetails {
primary_window: Option<Option<Box<CodexRateLimitWindow>>>,
secondary_window: Option<Option<Box<CodexRateLimitWindow>>>,
}
#[derive(Deserialize)]
struct CodexRateLimitWindow {
used_percent: f64,
reset_at: i64,
}
pub fn poll(show_claude_code: bool, show_codex: bool) -> Result<AppUsageData, PollError> {
poll_with(show_claude_code, show_codex, poll_claude_code, poll_codex)
}
fn poll_with(
show_claude_code: bool,
show_codex: bool,
mut poll_claude_code: impl FnMut() -> Result<UsageData, PollError>,
mut poll_codex: impl FnMut() -> Result<UsageData, PollError>,
) -> Result<AppUsageData, PollError> {
let mut data = AppUsageData::default();
let mut first_error = None;
if show_claude_code {
match poll_claude_code() {
Ok(claude_code) => data.claude_code = Some(claude_code),
Err(error) => {
if show_codex {
diagnose::log(format!("Claude Code usage poll failed: {error:?}"));
}
first_error.get_or_insert(error);
}
}
}
if show_codex {
match poll_codex() {
Ok(codex) => data.codex = Some(codex),
Err(error) => {
if show_claude_code {
diagnose::log(format!("Codex usage poll failed: {error:?}"));
}
first_error.get_or_insert(error);
}
}
}
if data.claude_code.is_none() && data.codex.is_none() {
Err(first_error.unwrap_or(PollError::RequestFailed))
} else {
Ok(data)
}
}
fn poll_claude_code() -> Result<UsageData, PollError> {
let creds = match read_first_credentials() {
Some(c) => c,
None => {
diagnose::log("poll failed: no Claude credentials found");
@ -52,26 +125,57 @@ pub fn poll() -> Result<UsageData, PollError> {
}
};
if is_token_expired(creds.expires_at) {
cli_refresh_token(&creds.source);
match read_credentials_from_source(&creds.source) {
Some(refreshed) => creds = refreshed,
None => {
diagnose::log("poll failed: credentials still unavailable after refresh attempt");
return Err(PollError::NoCredentials);
}
}
if is_token_expired(creds.expires_at) {
diagnose::log("poll failed: token is still expired after refresh attempt");
return Err(PollError::TokenExpired);
}
}
let creds = refresh_or_fallback(creds)?;
fetch_usage_with_fallback(&creds.access_token)
}
fn poll_codex() -> Result<UsageData, PollError> {
let creds = match read_codex_credentials() {
Some(creds) => creds,
None => {
diagnose::log("Codex usage poll failed: no Codex credentials found");
return Err(PollError::NoCredentials);
}
};
match fetch_codex_usage(&creds.access_token, creds.account_id.as_deref()) {
Ok(data) => Ok(data),
Err(PollError::AuthRequired) => {
cli_refresh_codex_token();
let refreshed = read_codex_credentials().ok_or(PollError::TokenExpired)?;
fetch_codex_usage(&refreshed.access_token, refreshed.account_id.as_deref())
}
Err(error) => Err(error),
}
}
fn refresh_or_fallback(mut creds: Credentials) -> Result<Credentials, PollError> {
loop {
if !is_token_expired(creds.expires_at) {
return Ok(creds);
}
let source = creds.source.clone();
cli_refresh_token(&source);
match read_credentials_from_source(&source) {
Some(refreshed) if !is_token_expired(refreshed.expires_at) => return Ok(refreshed),
Some(_) => diagnose::log(format!(
"credentials from {source:?} still expired after refresh attempt"
)),
None => diagnose::log(format!(
"credentials from {source:?} unavailable after refresh attempt"
)),
}
match read_next_credentials_after(&source) {
Some(next) => creds = next,
None => return Err(PollError::TokenExpired),
}
}
}
/// Invoke the Claude CLI with a minimal prompt to force its internal
/// OAuth token refresh.
fn cli_refresh_token(source: &CredentialSource) {
@ -160,6 +264,50 @@ fn cli_refresh_wsl_token(distro: &str) {
wait_for_refresh(&mut child);
}
fn cli_refresh_codex_token() {
let codex_path = resolve_windows_codex_path();
let is_cmd = codex_path.to_lowercase().ends_with(".cmd");
let is_ps1 = codex_path.to_lowercase().ends_with(".ps1");
diagnose::log(format!(
"attempting Windows Codex token refresh via {codex_path}"
));
let args: &[&str] = &["exec", "."];
let mut cmd = if is_cmd {
let mut c = Command::new("cmd.exe");
c.arg("/c").arg(&codex_path).args(args);
c
} else if is_ps1 {
let mut c = Command::new("powershell.exe");
c.arg("-NoProfile")
.arg("-ExecutionPolicy")
.arg("Bypass")
.arg("-File")
.arg(&codex_path)
.args(args);
c
} else {
let mut c = Command::new(&codex_path);
c.args(args);
c
};
cmd.creation_flags(CREATE_NO_WINDOW)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(error) => {
diagnose::log_error("unable to spawn Windows Codex token refresh", error);
return;
}
};
wait_for_refresh(&mut child);
}
/// Spawn a command and wait up to `timeout` for it to finish.
/// Returns None if the process fails to start or exceeds the deadline.
fn run_with_timeout(cmd: &mut Command, timeout: Duration) -> Option<std::process::Output> {
@ -235,6 +383,41 @@ fn resolve_windows_claude_path() -> String {
"claude.cmd".to_string()
}
fn resolve_windows_codex_path() -> String {
for name in &["codex.cmd", "codex.ps1", "codex.exe", "codex"] {
if Command::new(name)
.arg("--version")
.creation_flags(CREATE_NO_WINDOW)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok()
{
return name.to_string();
}
}
for name in &["codex.cmd", "codex.ps1", "codex.exe", "codex"] {
if let Ok(output) = Command::new("where.exe")
.arg(name)
.creation_flags(CREATE_NO_WINDOW)
.output()
{
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
if let Some(first_line) = stdout.lines().next() {
let path = first_line.trim().to_string();
if !path.is_empty() {
return path;
}
}
}
}
}
"codex.cmd".to_string()
}
fn build_agent() -> Result<ureq::Agent, PollError> {
let tls = native_tls::TlsConnector::new().map_err(|_| PollError::RequestFailed)?;
Ok(ureq::AgentBuilder::new()
@ -245,7 +428,7 @@ fn build_agent() -> Result<ureq::Agent, PollError> {
pub fn credential_watch_snapshot(mode: CredentialWatchMode) -> CredentialWatchSnapshot {
let sources = match mode {
CredentialWatchMode::ActiveSource => read_credentials()
CredentialWatchMode::ActiveSource => read_first_credentials()
.map(|creds| vec![creds.source])
.unwrap_or_else(all_known_credential_sources),
CredentialWatchMode::AllSources => all_known_credential_sources(),
@ -333,7 +516,7 @@ fn fetch_usage_with_fallback(token: &str) -> Result<UsageData, PollError> {
// Try the dedicated usage endpoint first
match try_usage_endpoint(token)? {
Some(data) => {
// If reset timers are missing, fill them in from the Messages API
// If reset timers are missing, fill them in from the Messages API
if data.session.resets_at.is_none() || data.weekly.resets_at.is_none() {
if let Ok(fallback) = fetch_usage_via_messages(token) {
let mut merged = data;
@ -475,6 +658,64 @@ fn parse_rate_limit_headers(response: &ureq::Response) -> UsageData {
data
}
fn fetch_codex_usage(token: &str, account_id: Option<&str>) -> Result<UsageData, PollError> {
let agent = build_agent()?;
let mut request = agent
.get(CODEX_USAGE_URL)
.set("Authorization", &format!("Bearer {token}"))
.set("User-Agent", "codex-cli");
if let Some(account_id) = account_id.filter(|value| !value.is_empty()) {
request = request.set("ChatGPT-Account-Id", account_id);
}
let resp = match request.call() {
Ok(resp) => resp,
Err(ureq::Error::Status(code, _)) if code == 401 || code == 403 => {
diagnose::log(format!(
"Codex usage endpoint returned auth error status {code}; refresh required"
));
return Err(PollError::AuthRequired);
}
Err(error) => {
diagnose::log_error("Codex usage endpoint request failed", error);
return Err(PollError::RequestFailed);
}
};
let response: CodexUsageResponse = match resp.into_json() {
Ok(response) => response,
Err(error) => {
diagnose::log_error("unable to parse Codex usage response", error);
return Err(PollError::RequestFailed);
}
};
codex_usage_from_response(response).ok_or(PollError::RequestFailed)
}
fn codex_usage_from_response(response: CodexUsageResponse) -> Option<UsageData> {
let details = *response.rate_limit.flatten()?;
let mut data = UsageData::default();
if let Some(window) = details.primary_window.flatten() {
data.session = codex_section_from_window(&window);
}
if let Some(window) = details.secondary_window.flatten() {
data.weekly = codex_section_from_window(&window);
}
Some(data)
}
fn codex_section_from_window(window: &CodexRateLimitWindow) -> UsageSection {
UsageSection {
percentage: window.used_percent,
resets_at: unix_to_system_time(Some(window.reset_at)),
}
}
fn get_header_f64(response: &ureq::Response, name: &str) -> f64 {
response
.header(name)
@ -506,20 +747,18 @@ enum CredentialSource {
Wsl { distro: String },
}
fn read_credentials() -> Option<Credentials> {
let mut candidates = Vec::new();
fn read_first_credentials() -> Option<Credentials> {
if let Some(creds) = read_windows_credentials() {
candidates.push(creds);
return Some(creds);
}
for distro in list_wsl_distros() {
if let Some(creds) = read_wsl_credentials(&distro) {
candidates.push(creds);
return Some(creds);
}
}
choose_best_credentials(candidates)
None
}
fn read_windows_credentials() -> Option<Credentials> {
@ -531,7 +770,10 @@ fn read_windows_credentials() -> Option<Credentials> {
Err(error) => {
if diagnose::is_enabled() {
diagnose::log_error(
&format!("unable to read Windows credentials at {}", cred_path.display()),
&format!(
"unable to read Windows credentials at {}",
cred_path.display()
),
error,
);
}
@ -551,6 +793,34 @@ fn read_credentials_from_source(source: &CredentialSource) -> Option<Credentials
}
}
fn codex_auth_path() -> Option<PathBuf> {
if let Some(codex_home) = std::env::var_os("CODEX_HOME").map(PathBuf::from) {
return Some(codex_home.join("auth.json"));
}
Some(dirs::home_dir()?.join(".codex").join("auth.json"))
}
fn read_codex_credentials() -> Option<CodexTokenData> {
let auth_path = codex_auth_path()?;
let content = match std::fs::read_to_string(&auth_path) {
Ok(content) => content,
Err(error) => {
diagnose::log_error(
&format!(
"unable to read Codex credentials at {}",
auth_path.display()
),
error,
);
return None;
}
};
let auth: CodexAuthFile = serde_json::from_str(&content).ok()?;
auth.tokens.filter(|tokens| !tokens.access_token.is_empty())
}
fn read_wsl_credentials(distro: &str) -> Option<Credentials> {
let output = run_with_timeout(
Command::new("wsl.exe")
@ -600,13 +870,30 @@ fn parse_credentials(content: &str, source: CredentialSource) -> Option<Credenti
})
}
fn choose_best_credentials(mut candidates: Vec<Credentials>) -> Option<Credentials> {
if candidates.is_empty() {
return None;
fn read_next_credentials_after(source: &CredentialSource) -> Option<Credentials> {
match source {
CredentialSource::Windows(_) => {
for distro in list_wsl_distros() {
if let Some(creds) = read_wsl_credentials(&distro) {
return Some(creds);
}
}
}
CredentialSource::Wsl { distro } => {
let mut past_current = false;
for candidate_distro in list_wsl_distros() {
if !past_current {
past_current = candidate_distro == *distro;
continue;
}
if let Some(creds) = read_wsl_credentials(&candidate_distro) {
return Some(creds);
}
}
}
}
candidates.sort_by_key(|creds| is_token_expired(creds.expires_at));
candidates.into_iter().next()
None
}
fn list_wsl_distros() -> Vec<String> {
@ -827,3 +1114,64 @@ pub fn is_past_reset(data: &UsageData) -> bool {
let past = |s: &UsageSection| matches!(s.resets_at, Some(t) if now.duration_since(t).is_ok());
past(&data.session) || past(&data.weekly)
}
pub fn app_is_past_reset(data: &AppUsageData) -> bool {
data.claude_code.as_ref().is_some_and(is_past_reset)
|| data.codex.as_ref().is_some_and(is_past_reset)
}
#[cfg(test)]
mod tests {
use super::*;
fn usage_with_session_percent(percentage: f64) -> UsageData {
UsageData {
session: UsageSection {
percentage,
resets_at: None,
},
weekly: UsageSection::default(),
}
}
#[test]
fn claude_failure_does_not_block_codex_when_both_are_enabled() {
let data = poll_with(
true,
true,
|| Err(PollError::AuthRequired),
|| Ok(usage_with_session_percent(42.0)),
)
.expect("codex data should keep the poll successful");
assert!(data.claude_code.is_none());
assert_eq!(data.codex.unwrap().session.percentage, 42.0);
}
#[test]
fn codex_failure_does_not_block_claude_when_both_are_enabled() {
let data = poll_with(
true,
true,
|| Ok(usage_with_session_percent(64.0)),
|| Err(PollError::RequestFailed),
)
.expect("claude data should keep the poll successful");
assert_eq!(data.claude_code.unwrap().session.percentage, 64.0);
assert!(data.codex.is_none());
}
#[test]
fn returns_first_error_when_no_enabled_provider_succeeds() {
let error = poll_with(
true,
true,
|| Err(PollError::AuthRequired),
|| Err(PollError::RequestFailed),
)
.expect_err("all-provider failure should return an error");
assert_eq!(error, PollError::AuthRequired);
}
}

View File

@ -1,16 +1,17 @@
use windows::core::PCWSTR;
use windows::Win32::Foundation::*;
use windows::Win32::Graphics::Gdi::*;
use windows::Win32::System::LibraryLoader::GetModuleFileNameW;
use windows::Win32::UI::Shell::{
ExtractIconExW, NIF_ICON, NIF_INFO, NIF_MESSAGE, NIF_TIP, NIM_ADD, NIM_DELETE, NIM_MODIFY,
NIIF_WARNING, NOTIFYICONDATAW, Shell_NotifyIconW,
ExtractIconExW, Shell_NotifyIconW, NIF_ICON, NIF_INFO, NIF_MESSAGE, NIF_TIP, NIIF_WARNING,
NIM_ADD, NIM_DELETE, NIM_MODIFY, NOTIFYICONDATAW,
};
use windows::Win32::UI::WindowsAndMessaging::*;
use windows::core::PCWSTR;
use crate::native_interop::{self, Color, WM_APP_TRAY};
const TRAY_ICON_ID: u32 = 1;
const CLAUDE_TRAY_ICON_ID: u32 = 1;
const CODEX_TRAY_ICON_ID: u32 = 2;
/// Menu item ID for toggling widget visibility (used by window.rs context menu).
pub const IDM_TOGGLE_WIDGET: u16 = 50;
@ -22,6 +23,27 @@ pub enum TrayAction {
ShowContextMenu,
}
#[derive(Clone, Copy)]
pub enum TrayIconKind {
Claude,
Codex,
}
pub struct TrayIconData {
pub kind: TrayIconKind,
pub percent: Option<f64>,
pub tooltip: String,
}
impl TrayIconKind {
fn id(self) -> u32 {
match self {
Self::Claude => CLAUDE_TRAY_ICON_ID,
Self::Codex => CODEX_TRAY_ICON_ID,
}
}
}
fn lerp_channel(start: u8, end: u8, t: f64) -> u8 {
(start as f64 + (end as f64 - start as f64) * t.clamp(0.0, 1.0)).round() as u8
}
@ -60,10 +82,19 @@ fn interpolated_fill(percent: f64) -> Color {
stops[stops.len() - 1].1
}
fn codex_fill(percent: f64) -> Color {
if percent >= 90.0 {
Color::from_hex("#FFFFFF")
} else {
Color::from_hex("#111111")
}
}
/// Create a rounded-rectangle tray icon badge showing the usage percentage.
/// `percent` = None means "no data/loading" and uses the embedded app icon.
pub fn create_icon(percent: Option<f64>) -> HICON {
if percent.is_none() {
/// For Claude, `percent` = None uses the embedded app icon as the loading state.
/// For Codex, `percent` = None uses a black/white Codex placeholder badge.
pub fn create_icon(kind: TrayIconKind, percent: Option<f64>) -> HICON {
if matches!(kind, TrayIconKind::Claude) && percent.is_none() {
let app_icon = load_embedded_app_icon();
if !app_icon.is_invalid() {
return app_icon;
@ -73,14 +104,33 @@ pub fn create_icon(percent: Option<f64>) -> HICON {
let size = 64_i32;
let margin = 0_i32;
let radius = 2_i32;
let outline = 0_i32;
let outline = if matches!(kind, TrayIconKind::Codex) {
3_i32
} else {
0_i32
};
let fill = interpolated_fill(percent.unwrap_or(0.0));
let text_col = Color::from_hex("#FFFFFF");
let fill = match kind {
TrayIconKind::Claude => interpolated_fill(percent.unwrap_or(0.0)),
TrayIconKind::Codex => codex_fill(percent.unwrap_or(0.0)),
};
let text_col = match kind {
TrayIconKind::Claude => Color::from_hex("#FFFFFF"),
TrayIconKind::Codex if percent.unwrap_or(0.0) >= 90.0 => Color::from_hex("#111111"),
TrayIconKind::Codex => Color::from_hex("#FFFFFF"),
};
let outline_col = match kind {
TrayIconKind::Claude => fill,
TrayIconKind::Codex if percent.unwrap_or(0.0) >= 90.0 => Color::from_hex("#111111"),
TrayIconKind::Codex => Color::from_hex("#FFFFFF"),
};
let display_text = match percent {
Some(p) => format!("{}", p.round().clamp(0.0, 999.0) as u32),
None => String::new(),
None => match kind {
TrayIconKind::Claude => String::new(),
TrayIconKind::Codex => "C".to_string(),
},
};
let font_h = match display_text.len() {
@ -107,8 +157,8 @@ pub fn create_icon(percent: Option<f64>) -> HICON {
};
let mut bits: *mut std::ffi::c_void = std::ptr::null_mut();
let dib = CreateDIBSection(mem_dc, &bmi, DIB_RGB_COLORS, &mut bits, None, 0)
.unwrap_or_default();
let dib =
CreateDIBSection(mem_dc, &bmi, DIB_RGB_COLORS, &mut bits, None, 0).unwrap_or_default();
if dib.is_invalid() {
let _ = DeleteDC(mem_dc);
@ -119,8 +169,7 @@ pub fn create_icon(percent: Option<f64>) -> HICON {
let old_bmp = SelectObject(mem_dc, dib);
// Zero-fill (transparent background)
let pixel_data =
std::slice::from_raw_parts_mut(bits as *mut u32, (size * size) as usize);
let pixel_data = std::slice::from_raw_parts_mut(bits as *mut u32, (size * size) as usize);
for px in pixel_data.iter_mut() {
*px = 0;
}
@ -129,7 +178,22 @@ pub fn create_icon(percent: Option<f64>) -> HICON {
let null_pen = GetStockObject(NULL_PEN);
let old_pen = SelectObject(mem_dc, null_pen);
// Fill rounded rect
if outline > 0 {
let br_outline = CreateSolidBrush(COLORREF(outline_col.to_colorref()));
let old_brush = SelectObject(mem_dc, br_outline);
let _ = RoundRect(
mem_dc,
margin,
margin,
size - margin + 1,
size - margin + 1,
(radius + 1) * 2,
(radius + 1) * 2,
);
SelectObject(mem_dc, old_brush);
let _ = DeleteObject(br_outline);
}
let br_fill = CreateSolidBrush(COLORREF(fill.to_colorref()));
let old_brush = SelectObject(mem_dc, br_fill);
let _ = RoundRect(
@ -251,12 +315,12 @@ fn load_embedded_app_icon() -> HICON {
/// Show a Windows balloon notification from the tray icon.
/// Used to alert the user when re-authentication is required.
pub fn notify_balloon(hwnd: HWND, title: &str, message: &str) {
pub fn notify_balloon(hwnd: HWND, kind: TrayIconKind, title: &str, message: &str) {
unsafe {
let mut nid: NOTIFYICONDATAW = std::mem::zeroed();
nid.cbSize = std::mem::size_of::<NOTIFYICONDATAW>() as u32;
nid.hWnd = hwnd;
nid.uID = TRAY_ICON_ID;
nid.uID = kind.id();
nid.uFlags = NIF_INFO;
nid.dwInfoFlags = NIIF_WARNING;
copy_wide(title, &mut nid.szInfoTitle);
@ -279,13 +343,13 @@ fn copy_wide_256(s: &str, buf: &mut [u16; 256]) {
}
/// Register the tray icon with the shell.
pub fn add(hwnd: HWND, percent: Option<f64>, tooltip: &str) {
let hicon = create_icon(percent);
pub fn add(hwnd: HWND, kind: TrayIconKind, percent: Option<f64>, tooltip: &str) {
let hicon = create_icon(kind, percent);
unsafe {
let mut nid: NOTIFYICONDATAW = std::mem::zeroed();
nid.cbSize = std::mem::size_of::<NOTIFYICONDATAW>() as u32;
nid.hWnd = hwnd;
nid.uID = TRAY_ICON_ID;
nid.uID = kind.id();
nid.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP;
nid.uCallbackMessage = WM_APP_TRAY;
nid.hIcon = hicon;
@ -298,13 +362,13 @@ pub fn add(hwnd: HWND, percent: Option<f64>, tooltip: &str) {
}
/// Update the tray icon colour and tooltip to reflect current usage.
pub fn update(hwnd: HWND, percent: Option<f64>, tooltip: &str) {
let hicon = create_icon(percent);
pub fn update(hwnd: HWND, kind: TrayIconKind, percent: Option<f64>, tooltip: &str) {
let hicon = create_icon(kind, percent);
unsafe {
let mut nid: NOTIFYICONDATAW = std::mem::zeroed();
nid.cbSize = std::mem::size_of::<NOTIFYICONDATAW>() as u32;
nid.hWnd = hwnd;
nid.uID = TRAY_ICON_ID;
nid.uID = kind.id();
nid.uFlags = NIF_ICON | NIF_TIP;
nid.hIcon = hicon;
copy_to_tip(tooltip, &mut nid.szTip);
@ -316,16 +380,44 @@ pub fn update(hwnd: HWND, percent: Option<f64>, tooltip: &str) {
}
/// Remove the tray icon from the shell.
pub fn remove(hwnd: HWND) {
pub fn remove(hwnd: HWND, kind: TrayIconKind) {
unsafe {
let mut nid: NOTIFYICONDATAW = std::mem::zeroed();
nid.cbSize = std::mem::size_of::<NOTIFYICONDATAW>() as u32;
nid.hWnd = hwnd;
nid.uID = TRAY_ICON_ID;
nid.uID = kind.id();
let _ = Shell_NotifyIconW(NIM_DELETE, &nid);
}
}
pub fn sync(hwnd: HWND, icons: &[TrayIconData]) {
let show_claude = icons
.iter()
.find(|icon| matches!(icon.kind, TrayIconKind::Claude));
let show_codex = icons
.iter()
.find(|icon| matches!(icon.kind, TrayIconKind::Codex));
if let Some(icon) = show_claude {
add(hwnd, icon.kind, icon.percent, &icon.tooltip);
update(hwnd, icon.kind, icon.percent, &icon.tooltip);
} else {
remove(hwnd, TrayIconKind::Claude);
}
if let Some(icon) = show_codex {
add(hwnd, icon.kind, icon.percent, &icon.tooltip);
update(hwnd, icon.kind, icon.percent, &icon.tooltip);
} else {
remove(hwnd, TrayIconKind::Codex);
}
}
pub fn remove_all(hwnd: HWND) {
remove(hwnd, TrayIconKind::Claude);
remove(hwnd, TrayIconKind::Codex);
}
/// Interpret a tray callback message and return the action to take.
pub fn handle_message(lparam: LPARAM) -> TrayAction {
let mouse_msg = lparam.0 as u32;

File diff suppressed because it is too large Load Diff